From 83131d7e9712fa80efd4025c1f8abbe549f9406f Mon Sep 17 00:00:00 2001 From: hunglp6d <89095484+hunglp6d@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:58:21 +0700 Subject: [PATCH 01/17] feat(cli): add --secret-material-env to provider refresh configure (#2178) * feat(cli): add --secret-material-env to provider refresh configure * feat(cli): reject duplicate secret material keys Signed-off-by: Hung Le --------- Signed-off-by: Hung Le --- crates/openshell-cli/src/main.rs | 12 +- crates/openshell-cli/src/run.rs | 138 ++++++++++++++++- .../tests/provider_commands_integration.rs | 145 ++++++++++++++++-- docs/sandboxes/manage-providers.mdx | 10 +- 4 files changed, 283 insertions(+), 22 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index e02c7c9cd5..66484562d4 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -898,6 +898,11 @@ enum ProviderRefreshCommands { #[arg(long = "material", value_name = "KEY=VALUE")] material: Vec, + /// Secret refresh material resolved from the CLI environment + /// (`ENVVAR` defaults to `KEY`); `KEY` is auto-marked secret. + #[arg(long = "secret-material-env", value_name = "KEY[=ENVVAR]")] + secret_material_env: Vec, + /// Material keys that are secret and must not be exposed. #[arg(long = "secret-material-key", value_name = "KEY")] secret_material_keys: Vec, @@ -2922,6 +2927,7 @@ async fn main() -> Result<()> { credential_key, strategy, material, + secret_material_env, secret_material_keys, credential_expires_at, } => { @@ -2932,6 +2938,7 @@ async fn main() -> Result<()> { credential_key: &credential_key, strategy: strategy.as_str(), material: &material, + secret_material_env: &secret_material_env, secret_material_keys: &secret_material_keys, credential_expires_at_ms: credential_expires_at, }, @@ -4189,6 +4196,8 @@ mod tests { "oauth2-client-credentials", "--material", "tenant_id=abc", + "--secret-material-env", + "client_secret=GRAPH_CLIENT_SECRET", "--secret-material-key", "client_secret", "--credential-expires-at", @@ -4202,10 +4211,11 @@ mod tests { ProviderRefreshCommands::Configure { strategy: CliProviderRefreshStrategy::Oauth2ClientCredentials, credential_expires_at: Some(1_767_225_600_000), + ref secret_material_env, .. } )) - }) + }) if secret_material_env == &["client_secret=GRAPH_CLIENT_SECRET".to_string()] )); let rotate = Cli::try_parse_from([ diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index f56bb71512..0182e1b668 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4007,6 +4007,47 @@ pub fn parse_env_pairs(items: &[String]) -> Result> { Ok(map) } +/// Resolve `--secret-material-env KEY[=ENVVAR]` values from the CLI process +/// environment (`ENVVAR` defaults to `KEY`) so secrets never transit argv. +pub fn parse_secret_material_env_pairs(items: &[String]) -> Result> { + let mut map = HashMap::new(); + + for item in items { + let (key, env_name) = match item.split_once('=') { + Some((key, env_name)) => (key.trim(), env_name.trim()), + None => (item.trim(), item.trim()), + }; + if key.is_empty() { + return Err(miette::miette!("--secret-material-env key cannot be empty")); + } + if env_name.is_empty() { + return Err(miette::miette!( + "--secret-material-env {key} names an empty environment variable" + )); + } + + let value = std::env::var(env_name).map_err(|_| { + miette::miette!( + "--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value" + ) + })?; + if value.trim().is_empty() { + return Err(miette::miette!( + "--secret-material-env {key} requires local env var '{env_name}' to be set to a non-empty value" + )); + } + + if map.contains_key(key) { + return Err(miette::miette!( + "--secret-material-env key '{key}' supplied more than once" + )); + } + map.insert(key.to_string(), value); + } + + Ok(map) +} + fn is_valid_env_name(key: &str) -> bool { let mut bytes = key.bytes(); let Some(first) = bytes.next() else { @@ -5282,6 +5323,7 @@ pub struct ProviderRefreshConfigInput<'a> { pub credential_key: &'a str, pub strategy: &'a str, pub material: &'a [String], + pub secret_material_env: &'a [String], pub secret_material_keys: &'a [String], pub credential_expires_at_ms: Option, } @@ -5292,7 +5334,21 @@ pub async fn provider_refresh_config( tls: &TlsOptions, ) -> Result<()> { let strategy = provider_refresh_strategy(input.strategy)?; - let material = parse_key_value_pairs(input.material, "--material")?; + let mut material = parse_key_value_pairs(input.material, "--material")?; + let mut secret_material_keys = input.secret_material_keys.to_vec(); + // Env-resolved secrets are auto-marked secret; duplicate keys are an + // error rather than a precedence order. + for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? { + if material.contains_key(&key) { + return Err(miette!( + "duplicate material key '{key}': supplied via both --material and --secret-material-env" + )); + } + if !secret_material_keys.contains(&key) { + secret_material_keys.push(key.clone()); + } + material.insert(key, value); + } let mut client = grpc_client(server, tls).await?; let status = client .configure_provider_refresh(ConfigureProviderRefreshRequest { @@ -5300,7 +5356,7 @@ pub async fn provider_refresh_config( credential_key: input.credential_key.to_string(), strategy: strategy as i32, material, - secret_material_keys: input.secret_material_keys.to_vec(), + secret_material_keys, expires_at_ms: input.credential_expires_at_ms, }) .await @@ -7845,11 +7901,12 @@ mod tests { gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle, inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, - parse_credential_pairs, parse_driver_config_json, plaintext_gateway_is_remote, - progress_step_from_metadata, 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, + parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, + plaintext_gateway_is_remote, progress_step_from_metadata, + 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 hyper::StatusCode; @@ -7993,6 +8050,73 @@ mod tests { )); } + #[test] + fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() { + let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material"); + + let parsed = + parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_NAMED".to_string()]) + .expect("parse"); + assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string())); + } + + #[test] + fn parse_secret_material_env_pairs_defaults_env_name_to_key() { + let _guard = EnvVarGuard::set("NAV_PARSE_SME_KEY_ONLY", "key-only-material"); + + let parsed = parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()]) + .expect("parse"); + assert_eq!( + parsed.get("NAV_PARSE_SME_KEY_ONLY"), + Some(&"key-only-material".to_string()) + ); + } + + #[test] + fn parse_secret_material_env_pairs_rejects_missing_environment() { + let _guard = EnvVarGuard::unset("NAV_PARSE_SME_MISSING"); + + let err = + parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()]) + .expect_err("missing env should error"); + assert!(err.to_string().contains( + "requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value" + )); + } + + #[test] + fn parse_secret_material_env_pairs_rejects_empty_environment_value() { + let _guard = EnvVarGuard::set("NAV_PARSE_SME_EMPTY", " "); + + let err = parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_EMPTY".to_string()]) + .expect_err("blank env should error"); + assert!(err.to_string().contains( + "requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value" + )); + } + + #[test] + fn parse_secret_material_env_pairs_rejects_empty_key() { + let err = parse_secret_material_env_pairs(&["=NAV_PARSE_SME_NO_KEY".to_string()]) + .expect_err("empty key should error"); + assert!(err.to_string().contains("key cannot be empty")); + } + + #[test] + fn parse_secret_material_env_pairs_rejects_duplicate_keys() { + let _guard = EnvVarGuard::set("NAV_PARSE_SME_DUP", "value"); + + let err = parse_secret_material_env_pairs(&[ + "private_key=NAV_PARSE_SME_DUP".to_string(), + "private_key=NAV_PARSE_SME_DUP".to_string(), + ]) + .expect_err("duplicate key should error"); + assert!( + err.to_string() + .contains("key 'private_key' supplied more than once") + ); + } + #[test] fn parse_credential_expiry_pairs_accepts_epoch_millis_and_rfc3339() { let parsed = parse_credential_expiry_pairs(&[ diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5a6e53eb15..53b178acbb 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -62,6 +62,8 @@ enum ProviderRefreshRequestLog { Configure { provider_name: String, credential_key: String, + material: HashMap, + secret_material_keys: Vec, expires_at_ms: Option, }, Rotate { @@ -661,6 +663,8 @@ impl OpenShell for TestOpenShell { .push(ProviderRefreshRequestLog::Configure { provider_name: request.provider.clone(), credential_key: request.credential_key.clone(), + material: request.material.clone(), + secret_material_keys: request.secret_material_keys.clone(), expires_at_ms: request.expires_at_ms, }); let configure_failure = self @@ -1187,6 +1191,7 @@ async fn provider_refresh_cli_run_functions_wire_requests() { credential_key: "MS_GRAPH_ACCESS_TOKEN", strategy: "oauth2_client_credentials", material: &["tenant_id=tenant".to_string()], + secret_material_env: &[], secret_material_keys: &["client_secret".to_string()], credential_expires_at_ms: Some(1_767_225_600_000), }, @@ -1216,6 +1221,8 @@ async fn provider_refresh_cli_run_functions_wire_requests() { ProviderRefreshRequestLog::Configure { provider_name: "my-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]), + secret_material_keys: vec!["client_secret".to_string()], expires_at_ms: Some(1_767_225_600_000), }, ProviderRefreshRequestLog::Status { @@ -1234,6 +1241,118 @@ 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; + + run::provider_create( + &ts.endpoint, + "gc-bridge", + "outlook", + false, + &["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()], + false, + &[], + &ts.tls, + ) + .await + .expect("provider create"); + + // The env value reaches the request and is auto-marked secret. + let guard = EnvVarGuard::set(&[("OPENSHELL_ITEST_SME_PRIVATE_KEY", "pem-from-env")]); + run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "gc-bridge", + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", + strategy: "google_service_account_jwt", + material: &["client_email=bot@p.iam.gserviceaccount.com".to_string()], + secret_material_env: &["private_key=OPENSHELL_ITEST_SME_PRIVATE_KEY".to_string()], + secret_material_keys: &[], + credential_expires_at_ms: None, + }, + &ts.tls, + ) + .await + .expect("provider refresh configure"); + drop(guard); + + let requests = ts.state.refresh_requests.lock().await.clone(); + assert_eq!( + requests, + vec![ProviderRefreshRequestLog::Configure { + provider_name: "gc-bridge".to_string(), + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN".to_string(), + material: HashMap::from([ + ( + "client_email".to_string(), + "bot@p.iam.gserviceaccount.com".to_string() + ), + ("private_key".to_string(), "pem-from-env".to_string()), + ]), + secret_material_keys: vec!["private_key".to_string()], + expires_at_ms: None, + }] + ); +} + +#[tokio::test] +async fn provider_refresh_configure_rejects_key_supplied_via_both_material_and_env() { + let ts = run_server().await; + + let guard = EnvVarGuard::set(&[("OPENSHELL_ITEST_SME_DUP_KEY", "pem-from-env")]); + let err = run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "gc-bridge", + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", + strategy: "google_service_account_jwt", + material: &["private_key=argv-value".to_string()], + secret_material_env: &["private_key=OPENSHELL_ITEST_SME_DUP_KEY".to_string()], + secret_material_keys: &[], + credential_expires_at_ms: None, + }, + &ts.tls, + ) + .await + .expect_err("duplicate key across --material and --secret-material-env should fail"); + drop(guard); + + assert!( + err.to_string() + .contains("duplicate material key 'private_key'") + ); + // Rejected client-side: nothing reached the gateway. + assert!(ts.state.refresh_requests.lock().await.is_empty()); +} + +#[tokio::test] +async fn provider_refresh_configure_fails_closed_when_secret_material_env_is_unset() { + let ts = run_server().await; + + let err = run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "gc-bridge", + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", + strategy: "google_service_account_jwt", + material: &[], + secret_material_env: &["private_key=OPENSHELL_ITEST_SME_DEFINITELY_UNSET".to_string()], + secret_material_keys: &[], + credential_expires_at_ms: None, + }, + &ts.tls, + ) + .await + .expect_err("unset env should fail before any request is sent"); + + assert!(err.to_string().contains( + "requires local env var 'OPENSHELL_ITEST_SME_DEFINITELY_UNSET' to be set to a non-empty value" + )); + // Fails closed on the client side: nothing reached the gateway. + assert!(ts.state.refresh_requests.lock().await.is_empty()); +} + #[tokio::test] async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() { let ts = run_server().await; @@ -2197,14 +2316,15 @@ async fn provider_create_from_gcloud_adc_happy_path() { 2, "expected configure + rotate refresh requests" ); - assert_eq!( - requests[0], + assert!(matches!( + &requests[0], ProviderRefreshRequestLog::Configure { - provider_name: "my-vertex".to_string(), - credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(), + provider_name, + credential_key, expires_at_ms: None, - } - ); + .. + } if provider_name == "my-vertex" && credential_key == "GOOGLE_VERTEX_AI_TOKEN" + )); assert_eq!( requests[1], ProviderRefreshRequestLog::Rotate { @@ -2577,14 +2697,15 @@ async fn provider_create_from_gcloud_adc_with_config_keys() { 2, "exactly one configure call and one rotate call expected" ); - assert_eq!( - refresh_requests[0], + assert!(matches!( + &refresh_requests[0], ProviderRefreshRequestLog::Configure { - provider_name: "vertex-with-config".to_string(), - credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(), + provider_name, + credential_key, expires_at_ms: None, - } - ); + .. + } if provider_name == "vertex-with-config" && credential_key == "GOOGLE_VERTEX_AI_TOKEN" + )); assert_eq!( refresh_requests[1], ProviderRefreshRequestLog::Rotate { diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 647a196a5b..1324859ed7 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -147,11 +147,17 @@ openshell provider refresh configure my-graph \ --strategy oauth2-client-credentials \ --material tenant_id="$TENANT_ID" \ --material client_id="$CLIENT_ID" \ - --material client_secret="$CLIENT_SECRET" \ - --secret-material-key client_secret \ + --secret-material-env client_secret=CLIENT_SECRET \ --credential-expires-at 1767225600000 ``` +Pass secret material with `--secret-material-env KEY[=ENVVAR]` (`ENVVAR` +defaults to `KEY`): the CLI reads the value from its own environment, so the +secret never appears in the host process table the way an expanded +`--material KEY="$VALUE"` argument would, and `KEY` is automatically marked +secret. Keep non-secret material on `--material`; `--secret-material-key KEY` +still marks a key supplied through `--material` as secret. + Check refresh status: ```shell From 88710225d7e61066b415fc55bbaf73048b641b7d Mon Sep 17 00:00:00 2001 From: Kirit Thadaka Date: Thu, 9 Jul 2026 12:12:57 -0700 Subject: [PATCH 02/17] docs(telemetry): Added first telemetry report for the community (#2190) * docs(telemetry): add community telemetry reports page Add telemetry/README.md to publish aggregate usage trends every two weeks, and link to it from the Telemetry section of the main README. First report covers the July 8, 2026 window. Signed-off-by: Kirit Thadaka * docs(telemetry): note telemetry start date (June 1, 2026) Clarify that all-time figures are cumulative from #1433, so readers know when the all-time counts begin. Signed-off-by: Kirit Thadaka --------- Signed-off-by: Kirit Thadaka --- README.md | 2 ++ telemetry/README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 telemetry/README.md diff --git a/README.md b/README.md index 2b9749652a..ff72647a5c 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ Telemetry events are limited to anonymous operational categories and counts, suc Opting out applies only to telemetry emitted by OpenShell. Third-party services, model providers, inference endpoints, agents, or tools that you configure and use with OpenShell may have their own terms and privacy practices. +We publish aggregate usage trends from this telemetry every two weeks. See the [community telemetry reports](telemetry/README.md) for the latest summary. + ## Notice and Disclaimer This software automatically retrieves, accesses or interacts with external materials. Those retrieved materials are not distributed with this software and are governed solely by separate terms, conditions and licenses. You are solely responsible for finding, reviewing and complying with all applicable terms, conditions, and licenses, and for verifying the security, integrity and suitability of any retrieved materials for your specific use case. This software is provided "AS IS", without warranty of any kind. The author makes no representations or warranties regarding any retrieved materials, and assumes no liability for any losses, damages, liabilities or legal consequences from your use or inability to use this software or any retrieved materials. Use this software and the retrieved materials at your own risk. diff --git a/telemetry/README.md b/telemetry/README.md new file mode 100644 index 0000000000..0d0f105606 --- /dev/null +++ b/telemetry/README.md @@ -0,0 +1,56 @@ +# OpenShell Community Telemetry Reports + +OpenShell collects anonymous, aggregate usage telemetry (see the [Telemetry section](../README.md#telemetry) of the main README for what's collected and how to opt out). We publish a summary of the trends here every two weeks so the community can see how the project is being used. + +Telemetry collection landed in [#1433](https://github.com/NVIDIA/OpenShell/pull/1433) on **June 1, 2026**, so all "All-time" figures are cumulative from that date. + +Numbers are aggregate counts only — no user data, code, prompts, or command contents are collected. Reports are listed newest first. + +--- + +## Update — July 8, 2026 + +First public telemetry update. The **Last 2 weeks** column covers the trailing two-week window; **All-time** is cumulative since telemetry landed on June 1, 2026 (~5 weeks). A large share of all-time activity falls within this first two-week window. + +| Metric | Last 2 weeks | All-time | +|---|---:|---:| +| Sandboxes created | 269,220 | 383,224 | +| Sandboxes deleted | 247,727 | 343,216 | +| Sandbox creation failures | 5,345 | 12,822 | +| Actions denied | 2,323,468 | 3,861,935 | +| Network activity events | 29,935,431 | 43,079,862 | + +Creation failure rate held around 2% over the last two weeks (~3% all-time). + +**Sandbox drivers (last 2 weeks).** Docker dominates at 208,816, followed by Podman (34,637) and Kubernetes (23,480). VM (1,562), unknown (422), and MXC (303) make up the long tail. All-time we've also seen a handful of apple-container and macos sandboxes. + +```mermaid +xychart-beta + title "Sandboxes Created by Driver (Last 2 Weeks)" + x-axis ["docker", "podman", "kubernetes", "vm", "unknown", "mxc"] + y-axis "Sandboxes created" 0 --> 220000 + bar [208816, 34637, 23480, 1562, 422, 303] +``` + +**Where sandboxes are created (last 2 weeks).** The United States leads by a wide margin, followed by Israel, Australia, Hong Kong, India, Singapore, South Korea, Germany, Japan, and China. + +**Providers (last 2 weeks).** `custom` profiles lead (~30k), then `openai` (~21k), with nvidia, claude, anthropic, github, gitlab, opencode, codex, and copilot trailing. + +```mermaid +xychart-beta + title "Top Provider Profiles (Last 2 Weeks, approx.)" + x-axis ["custom", "openai", "nvidia", "claude", "other"] + y-axis "Profiles created" 0 --> 32000 + bar [30300, 21100, 1200, 1000, 3000] +``` + +**Policy.** Roughly 73% of policy decisions were approved over the last two weeks. Of denied sandbox connections, ~99% fell under the "Connect Policy" deny group, with "Bypass" denials near zero. + +```mermaid +pie showData + title Policy Decisions — Last 2 Weeks (approx.) + "Approved" : 3700 + "Rejected" : 1400 +``` + +Next update: ~July 22, 2026. From 4970108805e52ff9d192e3d89246c091c824e7f8 Mon Sep 17 00:00:00 2001 From: Adam Miller Date: Thu, 9 Jul 2026 14:38:24 -0500 Subject: [PATCH 03/17] change packit target to new correct copr project (#2185) Signed-off-by: Adam Miller --- .packit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.packit.yaml b/.packit.yaml index 5d1f650630..6379d8db83 100644 --- a/.packit.yaml +++ b/.packit.yaml @@ -66,7 +66,7 @@ jobs: trigger: commit branch: main owner: "maxamillion" - project: "openshell" + project: "nvidia-openshell" identifier: main-commit targets: - fedora-all @@ -79,7 +79,7 @@ jobs: - job: copr_build trigger: release owner: "maxamillion" - project: "openshell" + project: "nvidia-openshell" targets: - fedora-all - epel-10 From 420a855ddc21a20ac528f902bd2ed7f3fc133dc9 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Thu, 9 Jul 2026 17:34:40 -0400 Subject: [PATCH 04/17] test(supervisor-network): add proxy hostname parser regression tests (#2197) Add regression coverage for parser differentials in the egress proxy's CONNECT hostname handling and OPA wildcard policy matching. Signed-off-by: Shane Utt --- .../openshell-supervisor-network/src/opa.rs | 63 ++++++ .../openshell-supervisor-network/src/proxy.rs | 211 ++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index fbab5fedd7..aaa79f9281 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1575,6 +1575,40 @@ mod tests { assert!(!decision.allowed); } + // -- wildcard host: malformed hostname regression tests -- + + #[test] + fn wildcard_host_nul_byte_causes_opa_error() { + let engine = wildcard_host_engine(); + let result = engine.evaluate_network(&wildcard_input("sub\0.example.com")); + assert!( + result.is_err(), + "NUL byte is an internal glob placeholder — OPA rejects it (fail closed)" + ); + } + + #[test] + fn wildcard_host_nul_byte_extra_label_causes_opa_error() { + let engine = wildcard_host_engine(); + let result = engine.evaluate_network(&wildcard_input("evil.com\0.example.com")); + assert!( + result.is_err(), + "NUL byte in hostname causes OPA evaluation failure (fail closed)" + ); + } + + #[test] + fn wildcard_host_percent_encoded_dot_no_match() { + let engine = wildcard_host_engine(); + let decision = engine + .evaluate_network(&wildcard_input("evil%2eexample.com")) + .unwrap(); + assert!( + !decision.allowed, + "percent-encoded dot should not be decoded by OPA glob" + ); + } + #[test] fn query_sandbox_config_extracts_filesystem() { let engine = test_engine(); @@ -6444,4 +6478,33 @@ network_policies: let input = l7_input("h.test", 80, "HEAD", "/protected"); assert!(!eval_l7(&engine, &input)); } + + // --------------------------------------------------------------------------- + // Test Utilities + // --------------------------------------------------------------------------- + + fn wildcard_host_engine() -> OpaEngine { + let data = r#" +network_policies: + wildcard_test: + name: wildcard_test + endpoints: + - host: "*.example.com" + port: 443 + binaries: + - path: /usr/bin/test +"#; + OpaEngine::from_strings(TEST_POLICY, data).expect("failed to load wildcard test policy") + } + + fn wildcard_input(host: &str) -> NetworkInput { + NetworkInput { + host: host.into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/test"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + } + } } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0d2c8c0258..6d60536362 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -7110,6 +7110,195 @@ network_policies: assert!(result.is_err()); } + // -- parse_target: CONNECT target parser regression tests -- + + #[test] + fn test_parse_target_valid_baseline() { + let (host, port) = parse_target("example.com:443").unwrap(); + assert_eq!(host, "example.com"); + assert_eq!(port, 443); + } + + #[test] + fn test_parse_target_preserves_case() { + let (host, port) = parse_target("EXAMPLE.COM:443").unwrap(); + assert_eq!(host, "EXAMPLE.COM", "parse_target should preserve case"); + assert_eq!(port, 443); + } + + #[test] + fn test_parse_target_accepts_empty_host() { + let (host, port) = parse_target(":443").unwrap(); + assert!(host.is_empty(), "empty host accepted without validation"); + assert_eq!(port, 443); + } + + #[test] + fn test_parse_target_nul_byte_passes_through() { + let (host, _) = parse_target("evil.com\0.safe.com:443").unwrap(); + assert_eq!( + host, "evil.com\0.safe.com", + "NUL byte not stripped or rejected" + ); + } + + #[test] + fn test_parse_target_control_char_passes_through() { + let (host, _) = parse_target("evil\x01.com:443").unwrap(); + assert!( + host.contains('\x01'), + "control characters pass through without validation" + ); + } + + #[test] + fn test_parse_target_percent_encoded_dot_is_literal() { + let (host, _) = parse_target("evil%2ecom:443").unwrap(); + assert_eq!( + host, "evil%2ecom", + "percent-encoded dot not decoded — literal %2e in host" + ); + } + + #[test] + fn test_parse_target_percent_encoded_nul_is_literal() { + let (host, _) = parse_target("evil%00.safe.com:443").unwrap(); + assert_eq!( + host, "evil%00.safe.com", + "percent-encoded NUL not decoded — literal %00 in host" + ); + } + + #[test] + fn test_parse_target_rejects_missing_port_separator() { + assert!( + parse_target("hostonly").is_err(), + "missing colon should be rejected" + ); + } + + #[test] + fn test_parse_target_rejects_non_numeric_port() { + assert!( + parse_target("host:notaport").is_err(), + "non-numeric port should be rejected" + ); + } + + #[test] + fn test_parse_target_rejects_port_overflow() { + assert!( + parse_target("host:65536").is_err(), + "port > 65535 should be rejected by u16 parse" + ); + } + + #[test] + fn test_parse_target_accepts_port_zero() { + let (_, port) = parse_target("host:0").unwrap(); + assert_eq!(port, 0); + } + + #[test] + fn test_parse_target_accepts_port_max() { + let (_, port) = parse_target("host:65535").unwrap(); + assert_eq!(port, 65535); + } + + #[test] + fn test_parse_target_bracket_chars_pass_through() { + let (host, _) = parse_target("a]b[c:443").unwrap(); + assert_eq!(host, "a]b[c", "brackets pass through without validation"); + } + + #[test] + fn test_parse_target_oversized_hostname_accepted() { + let long_host = "a".repeat(254); + let target = format!("{long_host}:443"); + let (host, _) = parse_target(&target).unwrap(); + assert_eq!( + host.len(), + 254, + "hostname exceeding DNS 253-char limit not rejected" + ); + } + + #[test] + fn test_parse_target_backslash_passes_through() { + let (host, _) = parse_target("evil.com\\..safe.com:443").unwrap(); + assert!( + host.contains('\\'), + "backslash passes through without validation" + ); + } + + #[test] + fn test_parse_target_slash_passes_through() { + let (host, _) = parse_target("evil.com/../safe.com:443").unwrap(); + assert!( + host.contains('/'), + "forward slash passes through without validation" + ); + } + + #[test] + fn test_parse_target_extra_colon_fails_port_parse() { + assert!( + parse_target("host:80:extra").is_err(), + "trailing content after port should fail u16 parse" + ); + } + + #[test] + fn test_parse_target_ipv6_bracket_notation_fails() { + assert!( + parse_target("[::1]:443").is_err(), + "split_once splits at first colon inside brackets — port parse fails" + ); + } + + // -- parse_proxy_uri: hostname parser regression tests -- + + #[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(); + assert_eq!( + host, "evil.com\0.safe.com", + "NUL byte not stripped or rejected in forward proxy URI" + ); + assert_eq!(port, 80); + } + + #[test] + fn test_parse_proxy_uri_control_char_in_host() { + let (_, host, _, _) = parse_proxy_uri("http://evil\x01.com:80/").unwrap(); + assert!( + host.contains('\x01'), + "control characters pass through without validation" + ); + } + + #[test] + fn test_parse_proxy_uri_percent_encoded_dot_in_host() { + let (_, host, _, _) = parse_proxy_uri("http://evil%2ecom:80/").unwrap(); + assert_eq!( + host, "evil%2ecom", + "percent-encoded dot not decoded — literal %2e in host" + ); + } + + #[test] + fn test_parse_proxy_uri_oversized_hostname() { + let long_host = "a".repeat(254); + let uri = format!("http://{long_host}:80/"); + let (_, host, _, _) = parse_proxy_uri(&uri).unwrap(); + assert_eq!( + host.len(), + 254, + "hostname exceeding DNS 253-char limit not rejected" + ); + } + // --- rewrite_forward_request tests --- #[tokio::test] @@ -7612,6 +7801,28 @@ network_policies: ); } + // -- SSRF: malformed hostname resolution regression tests -- + + #[tokio::test] + async fn test_resolve_reject_internal_fails_closed_on_nul_hostname() { + let result = resolve_and_reject_internal("evil.com\0.safe.com", 443, 0).await; + assert!( + result.is_err(), + "NUL-containing hostname should fail DNS resolution (fail closed)" + ); + } + + #[tokio::test] + async fn test_resolve_allowed_ips_fails_closed_on_nul_hostname() { + let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()]) + .unwrap_or_else(|_| vec!["0.0.0.0/0".parse::().unwrap()]); + let result = resolve_and_check_allowed_ips("evil.com\0.safe.com", 443, &nets, 0).await; + assert!( + result.is_err(), + "NUL-containing hostname should fail DNS resolution (fail closed)" + ); + } + // -- implicit_allowed_ips_for_ip_host -- #[test] From 5f38b7c42e918f4dc2f7d56206692e39bab5ef8e Mon Sep 17 00:00:00 2001 From: Ian Miller <75687988+r3v5@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:37:30 +0100 Subject: [PATCH 05/17] fix(tui): route warning logs to status bar instead of stderr (#2210) * fix(tui): route warning logs to status bar instead of stderr tracing::warn/info/debug calls in the TUI crate write to stderr via the global tracing subscriber. In ratatui's alternate-screen/raw-mode, stderr writes corrupt the terminal layout. Error-state sandboxes amplify this as background gRPC polls fail every 2s tick. Replace all 25 tracing calls with app.status_text assignments for direct-access sites, and Vec accumulation for the spawned start_port_forwards task. Add ForwardWarnings event variant to decouple forward warning delivery from the sandbox name in CreateResult, preventing downstream gRPC lookup failures. Closes #2120 Signed-off-by: Ian Miller * feat(tui): add ForwardWarnings event variant New event type for non-fatal port-forward warnings during sandbox creation. Keeps warning delivery separate from the sandbox name in CreateResult to avoid corrupting downstream gRPC lookups. Signed-off-by: Ian Miller * chore(tui): remove tracing dependency Compile-time guard against reintroducing stderr-writing tracing calls in the TUI crate. 14 other crates retain the dependency. Signed-off-by: Ian Miller --------- Signed-off-by: Ian Miller --- Cargo.lock | 1 - crates/openshell-tui/Cargo.toml | 1 - crates/openshell-tui/src/event.rs | 2 + crates/openshell-tui/src/lib.rs | 102 ++++++++++++++++-------------- 4 files changed, 58 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13b670f558..916969ac5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4018,7 +4018,6 @@ dependencies = [ "terminal-colorsaurus", "tokio", "tonic", - "tracing", "url", ] diff --git a/crates/openshell-tui/Cargo.toml b/crates/openshell-tui/Cargo.toml index 2381661364..caa2ec1ab4 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -25,7 +25,6 @@ tonic = { workspace = true, features = ["tls-native-roots"] } miette = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true } -tracing = { workspace = true } url = { workspace = true } [lints] diff --git a/crates/openshell-tui/src/event.rs b/crates/openshell-tui/src/event.rs index 4c6eeb8b46..6964ad4ac2 100644 --- a/crates/openshell-tui/src/event.rs +++ b/crates/openshell-tui/src/event.rs @@ -51,6 +51,8 @@ pub enum Event { SandboxSettingSetResult(Result), /// Sandbox setting delete result: `Ok(revision)` or `Err(message)`. SandboxSettingDeleteResult(Result), + /// Non-fatal warnings from port-forward setup after sandbox creation. + ForwardWarnings(Vec), } pub struct EventHandler { diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d38..da77dc10c3 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -235,7 +235,7 @@ pub async fn run( app.apply_global_settings(settings, revision); } Err(msg) => { - tracing::warn!("failed to fetch global settings: {msg}"); + app.status_text = format!("failed to fetch global settings: {msg}"); } }, Some(Event::GlobalSettingSetResult(result)) => { @@ -285,6 +285,9 @@ pub async fn run( } fetch_sandbox_detail(&mut app).await; } + Some(Event::ForwardWarnings(warnings)) => { + app.status_text = format!("port forward issues: {}", warnings.join("; ")); + } Some(Event::Mouse(mouse)) => match mouse.kind { MouseEventKind::ScrollUp if app.focus == Focus::SandboxLogs => { app.scroll_logs(-3); @@ -722,10 +725,14 @@ async fn handle_sandbox_delete(app: &mut App) { }; // Stop any active port forwards before deleting (mirrors CLI behavior). - if let Ok(stopped) = openshell_core::forward::stop_forwards_for_sandbox(&sandbox_name) { - for port in &stopped { - tracing::info!("stopped forward of port {port} for sandbox {sandbox_name}"); - } + if let Ok(stopped) = openshell_core::forward::stop_forwards_for_sandbox(&sandbox_name) + && !stopped.is_empty() + { + let ports: Vec = stopped.iter().map(ToString::to_string).collect(); + app.status_text = format!( + "stopped port forwards [{}] for sandbox {sandbox_name}", + ports.join(", ") + ); } let req = openshell_core::proto::DeleteSandboxRequest { name: sandbox_name }; @@ -777,11 +784,11 @@ async fn fetch_sandbox_detail(app: &mut App) { } } Ok(Err(e)) => { - tracing::warn!("failed to fetch sandbox detail: {}", e.message()); + app.status_text = format!("failed to fetch sandbox detail: {}", e.message()); None } Err(_) => { - tracing::warn!("sandbox detail request timed out"); + app.status_text = "sandbox detail request timed out".to_string(); None } }; @@ -812,10 +819,10 @@ async fn fetch_sandbox_detail(app: &mut App) { app.apply_sandbox_settings(inner.settings); } Ok(Err(e)) => { - tracing::warn!("failed to fetch sandbox policy: {}", e.message()); + app.status_text = format!("failed to fetch sandbox policy: {}", e.message()); } Err(_) => { - tracing::warn!("sandbox policy request timed out"); + app.status_text = "sandbox policy request timed out".to_string(); } } } @@ -1419,7 +1426,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { // Start port forwards if requested. if !ports.is_empty() { - start_port_forwards( + let forward_warnings = start_port_forwards( &mut client, &endpoint, &gateway_name, @@ -1428,6 +1435,9 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { &ports, ) .await; + if !forward_warnings.is_empty() { + let _ = tx.send(Event::ForwardWarnings(forward_warnings)); + } } } @@ -1448,7 +1458,9 @@ async fn start_port_forwards( sandbox_name: &str, sandbox_id: &str, specs: &[openshell_core::forward::ForwardSpec], -) { +) -> Vec { + let mut warnings = Vec::new(); + // Create SSH session. let session = { let req = openshell_core::proto::CreateSshSessionRequest { @@ -1457,18 +1469,20 @@ async fn start_port_forwards( match tokio::time::timeout(Duration::from_secs(10), client.create_ssh_session(req)).await { Ok(Ok(resp)) => resp.into_inner(), Ok(Err(e)) => { - tracing::warn!("SSH session failed for forwards: {}", e.message()); - return; + warnings.push(format!("SSH session failed for forwards: {}", e.message())); + return warnings; } Err(_) => { - tracing::warn!("SSH session timed out for forwards"); - return; + warnings.push("SSH session timed out for forwards".to_string()); + return warnings; } } }; if let Err(err) = validate_ssh_session_response(&session) { - tracing::warn!("gateway returned invalid SSH session response for forwards: {err}"); - return; + warnings.push(format!( + "gateway returned invalid SSH session response for forwards: {err}" + )); + return warnings; } // Resolve gateway address. @@ -1482,8 +1496,8 @@ async fn start_port_forwards( let exe = match std::env::current_exe() { Ok(p) => p, Err(e) => { - tracing::warn!("failed to find executable for forwards: {e}"); - return; + warnings.push(format!("failed to find executable for forwards: {e}")); + return warnings; } }; let proxy_command = build_proxy_command( @@ -1563,16 +1577,18 @@ async fn start_port_forwards( } } Ok(Ok(false)) => { - tracing::warn!("SSH forward exited with error for port {port_val}"); + warnings.push(format!("SSH forward exited with error for port {port_val}")); } Ok(Err(e)) => { - tracing::warn!("forward failed for port {port_val}: {e}"); + warnings.push(format!("forward failed for port {port_val}: {e}")); } Err(e) => { - tracing::warn!("forward task panicked for port {port_val}: {e}"); + warnings.push(format!("forward task panicked for port {port_val}: {e}")); } } } + + warnings } // --------------------------------------------------------------------------- @@ -1927,11 +1943,11 @@ async fn refresh_providers(app: &mut App) { .map(|profile| (profile.id.clone(), profile)) .collect::>(), Ok(Err(e)) => { - tracing::warn!("failed to list provider profiles: {}", e.message()); + app.status_text = format!("failed to list provider profiles: {}", e.message()); HashMap::new() } Err(_) => { - tracing::warn!("list provider profiles timed out"); + app.status_text = "list provider profiles timed out".to_string(); HashMap::new() } } @@ -1946,10 +1962,10 @@ async fn refresh_providers(app: &mut App) { let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await; match result { Ok(Err(e)) => { - tracing::warn!("failed to list providers: {}", e.message()); + app.status_text = format!("failed to list providers: {}", e.message()); } Err(_) => { - tracing::warn!("list providers timed out"); + app.status_text = "list providers timed out".to_string(); } Ok(Ok(resp)) => { let providers = resp.into_inner().providers; @@ -1994,10 +2010,10 @@ async fn refresh_global_settings(app: &mut App) { tokio::time::timeout(Duration::from_secs(5), app.client.get_gateway_config(req)).await; match result { Ok(Err(e)) => { - tracing::warn!("failed to fetch global settings: {}", e.message()); + app.status_text = format!("failed to fetch global settings: {}", e.message()); } Err(_) => { - tracing::warn!("get gateway settings timed out"); + app.status_text = "get gateway settings timed out".to_string(); } Ok(Ok(resp)) => { let inner = resp.into_inner(); @@ -2275,10 +2291,10 @@ async fn refresh_sandboxes(app: &mut App) { let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_sandboxes(req)).await; match result { Ok(Err(e)) => { - tracing::warn!("failed to list sandboxes: {}", e.message()); + app.status_text = format!("failed to list sandboxes: {}", e.message()); } Err(_) => { - tracing::warn!("list sandboxes timed out"); + app.status_text = "list sandboxes timed out".to_string(); } Ok(Ok(resp)) => { let sandboxes = resp.into_inner().sandboxes; @@ -2387,10 +2403,10 @@ async fn refresh_sandbox_policy(app: &mut App) { app.apply_sandbox_settings(inner.settings); } Ok(Err(e)) => { - tracing::warn!("failed to refresh sandbox policy: {}", e.message()); + app.status_text = format!("failed to refresh sandbox policy: {}", e.message()); } Err(_) => { - tracing::warn!("sandbox policy refresh timed out"); + app.status_text = "sandbox policy refresh timed out".to_string(); } } } @@ -2406,20 +2422,14 @@ async fn refresh_draft_chunks(app: &mut App) { status_filter: String::new(), }; - match tokio::time::timeout(Duration::from_secs(5), app.client.get_draft_policy(req)).await { - Ok(Ok(resp)) => { - let inner = resp.into_inner(); - app.draft_chunks = inner.chunks; - app.draft_version = inner.draft_version; - if app.draft_selected >= app.draft_chunks.len() && !app.draft_chunks.is_empty() { - app.draft_selected = app.draft_chunks.len() - 1; - } - } - Ok(Err(e)) => { - tracing::debug!("draft chunks refresh: {}", e.message()); - } - Err(_) => { - tracing::debug!("draft chunks refresh timed out"); + if let Ok(Ok(resp)) = + tokio::time::timeout(Duration::from_secs(5), app.client.get_draft_policy(req)).await + { + let inner = resp.into_inner(); + app.draft_chunks = inner.chunks; + app.draft_version = inner.draft_version; + if app.draft_selected >= app.draft_chunks.len() && !app.draft_chunks.is_empty() { + app.draft_selected = app.draft_chunks.len() - 1; } } } From ccdac9cec49af1f15afaaa1dcdc694bedab040c3 Mon Sep 17 00:00:00 2001 From: Kirit Thadaka Date: Fri, 10 Jul 2026 08:38:26 -0700 Subject: [PATCH 06/17] fix(mcp): include tool names in policy logs (#2189) Signed-off-by: Kirit93 --- crates/openshell-ocsf/src/format/shorthand.rs | 70 ++++++++++++++++++- .../src/l7/relay.rs | 47 ++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 3143b6d68e..0cb5d906c3 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -218,7 +218,19 @@ impl OcsfEvent { (false, true) => format!(" {action}"), (false, false) => format!(" {action}{arrow}"), }; - format!("HTTP:{method} {sev}{detail}{rule_ctx}{reason_ctx}") + // Denied HTTP events surface their message through reason_ctx. + // Allowed MCP decisions also need the JSON-RPC message so the + // selected tool name is visible in the shorthand log stream. + let message_ctx = if action != "DENIED" + && e.firewall_rule + .as_ref() + .is_some_and(|rule| rule.rule_type == "l7-mcp") + { + message_tag(&e.base) + } else { + String::new() + }; + format!("HTTP:{method} {sev}{detail}{rule_ctx}{reason_ctx}{message_ctx}") } Self::SshActivity(e) => { @@ -534,6 +546,62 @@ mod tests { ); } + #[test] + fn test_http_activity_shorthand_mcp_shows_tool_for_allow_and_deny() { + let mut event_base = base(4002, "HTTP Activity", 4, "Network Activity", 0, "Other"); + event_base.set_message( + "JSONRPC_L7_REQUEST decision=allow rule_methods=tools/call tools=move_head", + ); + let event = OcsfEvent::HttpActivity(HttpActivityEvent { + base: event_base, + http_request: Some(HttpRequest::new( + "POST", + Url::new("http", "host.openshell.internal", "/mcp", 8766), + )), + http_response: None, + src_endpoint: None, + dst_endpoint: None, + proxy_endpoint: None, + actor: None, + firewall_rule: Some(FirewallRule::new("reachy-mcp", "l7-mcp")), + action: Some(ActionId::Allowed), + disposition: Some(DispositionId::Allowed), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert!(shorthand.contains("engine:l7-mcp")); + assert!(shorthand.contains("tools=move_head")); + + let mut denied_base = base(4002, "HTTP Activity", 4, "Network Activity", 0, "Other"); + denied_base.severity = crate::enums::SeverityId::Medium; + denied_base.set_message( + "JSONRPC_L7_REQUEST decision=deny rule_methods=tools/call tools=move_head", + ); + let denied_event = OcsfEvent::HttpActivity(HttpActivityEvent { + base: denied_base, + http_request: Some(HttpRequest::new( + "POST", + Url::new("http", "host.openshell.internal", "/mcp", 8766), + )), + http_response: None, + src_endpoint: None, + dst_endpoint: None, + proxy_endpoint: None, + actor: None, + firewall_rule: Some(FirewallRule::new("reachy-mcp", "l7-mcp")), + action: Some(ActionId::Denied), + disposition: Some(DispositionId::Blocked), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let denied_shorthand = denied_event.format_shorthand(); + assert!(denied_shorthand.contains("[reason:")); + assert!(denied_shorthand.contains("tools=move_head")); + } + #[test] fn test_network_activity_shorthand_denied_shows_reason() { let mut b = base(4001, "Network Activity", 4, "Network Activity", 1, "Open"); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index ed2bde1135..c43fb35f9d 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -571,7 +571,11 @@ fn l7_protocol_log_summary( } if let Some(info) = jsonrpc_info { - return format!(" rule_methods={}", rule_method_names_for_log(info)); + return format!( + " rule_methods={} tools={}", + rule_method_names_for_log(info), + tool_names_for_log(info) + ); } String::new() @@ -1428,8 +1432,9 @@ pub(crate) fn jsonrpc_log_message( reason: &str, ) -> String { let rule_methods = rule_method_names_for_log(info); + let tools = tool_names_for_log(info); format!( - "JSONRPC_L7_REQUEST decision={decision} http_method={http_method} endpoint={endpoint} rule_methods={rule_methods} policy_version={policy_version} reason={reason}" + "JSONRPC_L7_REQUEST decision={decision} rule_methods={rule_methods} tools={tools} http_method={http_method} endpoint={endpoint} policy_version={policy_version} reason={reason}" ) } @@ -1444,6 +1449,20 @@ pub(crate) fn rule_method_names_for_log(info: &crate::l7::jsonrpc::JsonRpcReques .join(",") } +pub(crate) fn tool_names_for_log(info: &crate::l7::jsonrpc::JsonRpcRequestInfo) -> String { + let tools = info + .calls + .iter() + .filter_map(|call| call.tool.as_deref()) + .map(sanitize_log_token) + .collect::>(); + if tools.is_empty() { + "-".to_string() + } else { + tools.join(",") + } +} + fn sanitize_log_token(value: &str) -> String { value .chars() @@ -2538,7 +2557,6 @@ network_policies: let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); assert!(allowed, "{reason}"); - request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( br#"{"jsonrpc":"2.0","id":1,"method":"reports.search","params":["ignored",{"nested":true}]}"#, crate::l7::jsonrpc::JsonRpcInspectionMode::JsonRpc, @@ -2604,6 +2622,17 @@ network_policies: let (allowed, reason) = evaluate_l7_request(&tunnel_engine, &ctx, &request).unwrap(); assert!(allowed, "{reason}"); + let allowed_info = request.jsonrpc.as_ref().expect("parsed MCP request"); + let allowed_message = jsonrpc_log_message( + "allow", + "POST", + "api.example.test:443/mcp", + allowed_info, + 42, + &reason, + ); + assert!(allowed_message.contains("rule_methods=tools/call")); + assert!(allowed_message.contains("tools=read_status")); request.jsonrpc = Some(crate::l7::jsonrpc::parse_jsonrpc_body( br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_resource","arguments":{"scope":"workspace/main"}}}"#, @@ -2625,6 +2654,17 @@ network_policies: reason.contains("deny rule"), "deny reason should identify policy denial: {reason}" ); + let denied_message = jsonrpc_log_message( + "deny", + "POST", + "api.example.test:443/mcp", + parsed, + 42, + &reason, + ); + assert!(denied_message.contains("rule_methods=tools/call")); + assert!(denied_message.contains("tools=delete_resource")); + assert!(!denied_message.contains("workspace/main")); } #[test] @@ -2644,6 +2684,7 @@ network_policies: assert!(message.contains("endpoint=jsonrpc.example.com:443/rpc")); assert!(message.contains("rule_methods=reports.archive")); + assert!(message.contains("tools=-")); assert!(message.contains("policy_version=42")); assert!(!message.contains("delete_resource")); assert!(!message.contains("secret-scope")); From caaa51653701854941192cef01a8e05088cc1b89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:42:13 +0000 Subject: [PATCH 07/17] chore(deps): bump astral-sh/setup-uv from 8.3.1 to 8.3.2 (#2206) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.3.1 to 8.3.2. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/f98e06938123ccabd21905ea5d0069192241f9f1...11f9893b081a58869d3b5fccaea48c9e9e46f990) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 8.3.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index c1ca4b39be..f84f4cdfef 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -93,7 +93,7 @@ jobs: path: docs-website - name: Install uv - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: version: "0.10.12" From 8c0ecac8cfad2ddcbe6d9ab1c0132df90503221b Mon Sep 17 00:00:00 2001 From: Christian Zaccaria <73656840+ChristianZaccaria@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:45:56 +0100 Subject: [PATCH 08/17] docs(openshift): simplify install steps and add Helm README entries for OpenShift overrides (#2125) Signed-off-by: ChristianZaccaria --- deploy/helm/openshell/README.md | 2 ++ deploy/helm/openshell/README.md.gotmpl | 2 ++ docs/kubernetes/openshift.mdx | 12 +++--------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index f3a86f884f..6bc459567b 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -25,6 +25,8 @@ helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart --version -The OpenShift install path is experimental. It currently requires running sandbox pods under the `privileged` SCC and installing the gateway with TLS and the PKI init job disabled. Use only for evaluation on a private network. +The OpenShift install path is experimental. It currently requires running sandbox pods under the `privileged` SCC and installing the gateway with TLS disabled. Use only for evaluation on a private network. OpenShift's [Security Context Constraints](https://docs.openshift.com/container-platform/latest/authentication/managing-security-context-constraints.html) reject the chart's default pod security settings. Installing on OpenShift requires precreating the namespace, granting the `privileged` SCC to the sandbox service account, and overriding a few chart values so the cluster admission controller can assign UIDs and FS groups itself. @@ -46,7 +46,6 @@ oc adm policy add-scc-to-user privileged -z openshell-sandbox -n openshell helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ --version \ --namespace openshell \ - --set pkiInitJob.enabled=false \ --set server.disableTls=true \ --set podSecurityContext.fsGroup=null \ --set securityContext.runAsUser=null @@ -54,14 +53,9 @@ helm install openshell oci://ghcr.io/nvidia/openshell/helm-chart \ | Override | Reason | |---|---| -| `pkiInitJob.enabled=false` | Skips the built-in TLS PKI Job. TLS must also be disabled unless you provide TLS Secrets another way. | -| `server.disableTls=true` | The gateway has no certificates without `pkiInitJob`, so it must run plaintext. | +| `server.disableTls=true` | Runs the gateway over plaintext HTTP for simpler evaluation. | | `podSecurityContext.fsGroup=null` / `securityContext.runAsUser=null` | Clear the chart's hardcoded UID and fsGroup so OpenShift's SCC admission can assign them. | -The gateway still needs the sandbox JWT signing Secret. When disabling -`pkiInitJob` without enabling cert-manager, pre-create that Secret before -installing the chart. - ## Wait for the gateway to be ready ```shell @@ -90,6 +84,6 @@ openshell status ## Next Steps -- For TLS-enabled deployments, refer to [Managing Certificates](/kubernetes/managing-certificates) after SCC-compatible PKI is supported. +- For TLS-enabled deployments, refer to [Managing Certificates](/kubernetes/managing-certificates). - To expose the gateway externally, refer to [Ingress](/kubernetes/ingress). - To configure OIDC authentication, refer to [Access Control](/kubernetes/access-control). From 233d207e70170fd3c5770d3547b62733445ccfe2 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Fri, 10 Jul 2026 18:51:39 +0200 Subject: [PATCH 09/17] docs(issues): require release and duplicate checks (#2214) Signed-off-by: Evan Lezar --- .agents/skills/create-github-issue/SKILL.md | 13 ++++++--- .agents/skills/triage-issue/SKILL.md | 29 ++++++++++++++++----- .github/ISSUE_TEMPLATE/bug_report.yml | 15 +++++++++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/.agents/skills/create-github-issue/SKILL.md b/.agents/skills/create-github-issue/SKILL.md index b1311a297e..d196fc8c8d 100644 --- a/.agents/skills/create-github-issue/SKILL.md +++ b/.agents/skills/create-github-issue/SKILL.md @@ -17,7 +17,7 @@ This project uses YAML form issue templates. When creating issues, match the tem ### Bug Reports -Do not add a type label automatically. The body must include an **Agent Diagnostic** section — this is required by the template and enforced by project convention. Apply area or topic labels only when they are clearly known. +Do not add a type label automatically. The body must include an **Agent Diagnostic** section — this is required by the template and enforced by project convention. The diagnostic must identify the OpenShell version tested, whether the latest release or known fixes were checked, and whether possible duplicate issues were searched. If the agent cannot verify the latest release or search existing issues, say so explicitly instead of guessing. Apply area or topic labels only when they are clearly known. ```bash gh issue create \ @@ -25,8 +25,13 @@ gh issue create \ --body "$(cat <<'EOF' ## Agent Diagnostic - +- Skills loaded: +- OpenShell version tested: +- Latest release checked: +- Known fixes reviewed: +- Possible duplicates reviewed: +- Findings: +- Remaining reason for filing: ## Description @@ -44,6 +49,8 @@ What was found? What was tried?> - OS: - Docker: - OpenShell: +- Latest release checked: +- Possible duplicates checked: ## Logs diff --git a/.agents/skills/triage-issue/SKILL.md b/.agents/skills/triage-issue/SKILL.md index 54e726a5d9..8d602a9023 100644 --- a/.agents/skills/triage-issue/SKILL.md +++ b/.agents/skills/triage-issue/SKILL.md @@ -51,7 +51,7 @@ Query all open issues with the `state:triage-needed` label and process them in s gh issue list --label "state:triage-needed" --state open --json number,title --jq '.[].number' ``` -For each issue returned, run the full triage workflow (Steps 1-6). Report a summary at the end listing each issue and its classification. +For each issue returned, run the full triage workflow (Steps 1-7). Report a summary at the end listing each issue and its classification. ## Step 1: Fetch the Issue @@ -89,7 +89,23 @@ Check whether the issue body contains a substantive agent diagnostic section. Lo **If the diagnostic section is substantive**, proceed to Step 4. -## Step 4: Diagnose and Validate +## Step 4: Check Reported Version and Known Fixes + +Before deeper diagnosis, determine whether the report may already be fixed in a newer release. + +1. Extract the reported OpenShell version from the issue body, Agent Diagnostic, environment section, logs, and comments. If no version is provided, record that as missing context and continue. +2. Check current release information and known fixes when available: + - `gh release list --limit 10` + - `gh release view ` + - linked issues, merged PRs, release notes, and local git tags/history +3. If network access or release metadata is unavailable, state the limitation in the triage comment instead of guessing. + +If the issue targets an older OpenShell release and a newer release or merged PR appears to address the same behavior: + +- If the reporter has already reproduced the issue on the fixed/current release, continue to Step 5. +- If the reporter has not tested the fixed/current release, use the `fixed-in-release` classification in Step 6. Reference the fixing version and PR/issue when known, and ask for a fresh report or reopen if the issue still reproduces on that version. + +## Step 5: Diagnose and Validate Assess the report by investigating the codebase. Use the `principal-engineer-reviewer` sub-agent via the Task tool: @@ -114,7 +130,7 @@ Based on the sub-agent's analysis, also attempt to validate the report directly: - For inference and provider-topology issues: reference the `debug-inference` skill's known failure patterns - For CLI/usage issues: reference the `openshell-cli` skill's command reference -## Step 5: Classify +## Step 6: Classify Based on the investigation, classify the issue into one of these categories: @@ -122,12 +138,13 @@ Based on the investigation, classify the issue into one of these categories: |---------------|----------|--------| | **bug-confirmed** | Agent diagnostic and codebase analysis confirm a real defect | Apply relevant `area:*` or `topic:*` labels as needed, remove `state:triage-needed`, and assign the built-in `Bug` issue type manually if needed | | **feature-valid** | Design proposal is sound, feasible given the architecture | Apply relevant `area:*` or `topic:*` labels as needed, remove `state:triage-needed`, and assign the built-in `Feature` issue type manually if needed | +| **fixed-in-release** | Report targets an older OpenShell release and a newer release or merged PR appears to address the behavior; no fixed/current-release reproduction is provided | Comment with the fixing version and PR/issue when known. Close as completed when the fix is clear, or request a retest if confirmation is still needed. Remove `state:triage-needed` when closing | | **duplicate** | An existing open issue covers this | Link the duplicate, close with comment | | **user-error** | The reported behavior is expected, or the issue is a misconfiguration | Comment with explanation and guidance, close | | **needs-more-info** | Report is substantive but missing critical reproduction details | Comment requesting specifics, keep `state:triage-needed` | | **needs-investigation** | Report appears valid but requires deeper analysis (spike candidate) | Label `spike`, remove `state:triage-needed` | -## Step 6: Post Triage Comment +## Step 7: Post Triage Comment Post a structured comment with the triage marker: @@ -136,7 +153,7 @@ Post a structured comment with the triage marker: > > ## Triage Assessment > -> **Classification:** +> **Classification:** > > ### Summary > <2-3 sentences: what was found, whether the report is valid> @@ -148,7 +165,7 @@ Post a structured comment with the triage marker: > ``` -Apply the appropriate labels as determined in Step 5. +Apply the appropriate labels as determined in Step 6. **Do not apply `state:agent-ready`.** That is always a human decision. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 74fa4bf5d4..de5d8112ae 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,15 +9,20 @@ body: OpenShell is an agent-first project. Before filing this bug, point your coding agent at the repo and have it investigate using the available skills (`debug-openshell-cluster`, `debug-inference`, `openshell-cli`, etc.). See [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md) for the full skills table. + Please also check the latest OpenShell release and search existing issues for possible duplicates before filing. If you cannot upgrade, retest, or search existing issues, explain why in the diagnostic. + - type: textarea id: agent-diagnostic attributes: label: Agent Diagnostic description: | - Paste the output from your agent's investigation of this bug. What skills did it load? What did it find? What did it try? + Paste the output from your agent's investigation of this bug. What skills did it load? What did it find? What did it try? Which OpenShell version did it test, and did it check the latest release, known fixes, and possible duplicates? placeholder: | Example: - Loaded `debug-inference` skill + - Tested OpenShell v0.x.x + - Checked latest release / known fixes: no matching fix found + - Searched existing issues for duplicates: no matching issue found - Ran `openshell inference get` and `openshell provider get ollama` - Found `OPENAI_BASE_URL=http://127.0.0.1:11434/v1`, which is unreachable from the gateway - Updated the provider to use `host.openshell.internal`, but the issue persists because the gateway is remote @@ -47,11 +52,13 @@ body: id: environment attributes: label: Environment - description: OS, Docker version, OpenShell version, and any other relevant details. + description: OS, Docker version, OpenShell version tested, whether the latest release and existing issues were checked, and any other relevant details. placeholder: | - OS: macOS 15.2 / Ubuntu 24.04 / Windows 11 + WSL2 - Docker: Docker Desktop 4.x / Docker Engine 27.x - OpenShell: v0.x.x (output of `openshell --version`) + - Latest release checked: yes / no, because ... + - Possible duplicates checked: yes / no, because ... validations: required: true @@ -75,5 +82,9 @@ body: required: true - label: I loaded relevant skills (e.g., `debug-openshell-cluster`, `debug-inference`, `openshell-cli`) required: true + - label: I checked the latest OpenShell release and either reproduced the issue there or explained why I cannot upgrade/test it + required: true + - label: I searched existing issues for possible duplicates or explained why I could not + required: true - label: My agent could not resolve this — the diagnostic above explains why required: true From 10702133a30e9c6cc621e8ea241171b518c17296 Mon Sep 17 00:00:00 2001 From: Florent BENOIT Date: Fri, 10 Jul 2026 20:26:49 +0200 Subject: [PATCH 10/17] fix(core): pin supervisor image tag to gateway version for all drivers (#2070) * fix(core): pin supervisor image tag to gateway version for all drivers The Podman and Kubernetes drivers defaulted the supervisor image to `:latest` via DEFAULT_SUPERVISOR_IMAGE, while the Docker driver already resolved a version-pinned tag. Extract the tag resolution logic into openshell-core so all three drivers use the same OPENSHELL_IMAGE_TAG > IMAGE_TAG > CARGO_PKG_VERSION priority chain. Closes #2068 Signed-off-by: Florent Benoit * refactor(core): simplify supervisor image tag resolver to slice-based API Remove the Docker driver's wrapper functions and call openshell_core::config::default_supervisor_image() directly. Simplify resolve_supervisor_image_tag to accept &[&str] instead of three separate parameters. Signed-off-by: Florent Benoit Signed-off-by: Florent Benoit --------- Signed-off-by: Florent Benoit Signed-off-by: Florent Benoit --- crates/openshell-core/src/config.rs | 74 ++++++++++++++++++- crates/openshell-driver-docker/src/lib.rs | 53 +------------ crates/openshell-driver-docker/src/tests.rs | 31 +++----- .../openshell-driver-kubernetes/src/config.rs | 4 +- .../openshell-driver-kubernetes/src/main.rs | 2 +- crates/openshell-driver-podman/src/config.rs | 4 +- .../openshell-driver-podman/src/container.rs | 5 +- crates/openshell-driver-podman/src/main.rs | 6 +- docs/reference/gateway-config.mdx | 12 ++- 9 files changed, 105 insertions(+), 86 deletions(-) diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index ba6b9d401f..a0f4ec9cf5 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -37,8 +37,40 @@ 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"; -/// Default OCI image for the openshell-sandbox supervisor binary. -pub const DEFAULT_SUPERVISOR_IMAGE: &str = "ghcr.io/nvidia/openshell/supervisor:latest"; +/// Default OCI repository for the supervisor image (no tag). +pub const DEFAULT_SUPERVISOR_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/supervisor"; + +/// Return the default supervisor image reference with a version-pinned tag. +#[must_use] +pub fn default_supervisor_image() -> String { + format!( + "{DEFAULT_SUPERVISOR_IMAGE_REPO}:{}", + default_supervisor_image_tag() + ) +} + +fn default_supervisor_image_tag() -> String { + resolve_supervisor_image_tag(&[ + option_env!("OPENSHELL_IMAGE_TAG").unwrap_or(""), + option_env!("IMAGE_TAG").unwrap_or(""), + env!("CARGO_PKG_VERSION"), + ]) +} + +/// Resolve the supervisor image tag from an ordered list of candidates. +/// +/// Returns the first non-empty, non-`"0.0.0"` candidate, falling back to +/// `"dev"` when none qualifies. Replaces `+` with `-` for OCI tag +/// compatibility. +#[must_use] +pub fn resolve_supervisor_image_tag(candidates: &[&str]) -> String { + candidates + .iter() + .copied() + .find(|t| !t.is_empty() && *t != "0.0.0") + .unwrap_or("dev") + .replace('+', "-") +} /// CDI device identifier for requesting all NVIDIA GPUs. pub const CDI_GPU_DEVICE_ALL: &str = "nvidia.com/gpu=all"; @@ -1064,4 +1096,42 @@ mod tests { } } } + + #[test] + fn supervisor_image_tag_prefers_explicit_build_tags() { + use super::resolve_supervisor_image_tag; + assert_eq!( + resolve_supervisor_image_tag(&["1.2.3", "sha", "0.0.0"]), + "1.2.3" + ); + assert_eq!(resolve_supervisor_image_tag(&["", "sha", "0.0.0"]), "sha"); + assert_eq!(resolve_supervisor_image_tag(&["", "", "1.2.3"]), "1.2.3"); + assert_eq!(resolve_supervisor_image_tag(&["", "", "0.0.0"]), "dev"); + assert_eq!( + resolve_supervisor_image_tag(&["latest", "", "1.2.3"]), + "latest" + ); + } + + #[test] + fn supervisor_image_tag_sanitizes_build_metadata_for_oci() { + use super::resolve_supervisor_image_tag; + assert_eq!( + resolve_supervisor_image_tag(&["", "", "0.0.37-dev.156+g1d3b741ee"]), + "0.0.37-dev.156-g1d3b741ee", + ); + assert_eq!( + resolve_supervisor_image_tag(&["0.0.37-dev.156+g1d3b741ee", "", "0.0.0"]), + "0.0.37-dev.156-g1d3b741ee", + ); + } + + #[test] + fn default_supervisor_image_is_version_pinned() { + use super::default_supervisor_image; + let image = default_supervisor_image(); + assert!(image.starts_with("ghcr.io/nvidia/openshell/supervisor:")); + let tag = image.rsplit_once(':').unwrap().1; + assert!(!tag.is_empty()); + } } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index d9bea99e7c..06d575a1e1 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -78,55 +78,6 @@ const HOST_OPENSHELL_INTERNAL: &str = "host.openshell.internal"; const HOST_DOCKER_INTERNAL: &str = "host.docker.internal"; const DOCKER_NETWORK_DRIVER: &str = "bridge"; -/// Default image holding the Linux `openshell-sandbox` binary. The gateway -/// pulls this image and extracts the binary to a host-side cache when no -/// explicit `supervisor_bin`, configured `supervisor_image`, sibling binary, -/// or local build is available. -const DEFAULT_DOCKER_SUPERVISOR_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/supervisor"; - -/// Return the default `ghcr.io/nvidia/openshell/supervisor:` reference -/// used when no supervisor binary override is provided. -pub fn default_docker_supervisor_image() -> String { - format!( - "{DEFAULT_DOCKER_SUPERVISOR_IMAGE_REPO}:{}", - default_docker_supervisor_image_tag() - ) -} - -/// Image tag baked in at compile time to pair the gateway with a matching -/// supervisor image. -/// -/// Build pipelines pass `OPENSHELL_IMAGE_TAG` explicitly. The `IMAGE_TAG` -/// fallback covers image build wrappers that already tag the gateway and -/// supervisor together. Standalone release binaries also patch the Cargo -/// package version, so use it when it has been set to a real release value. -fn default_docker_supervisor_image_tag() -> String { - resolve_default_docker_supervisor_image_tag( - option_env!("OPENSHELL_IMAGE_TAG"), - option_env!("IMAGE_TAG"), - env!("CARGO_PKG_VERSION"), - ) -} - -fn resolve_default_docker_supervisor_image_tag( - openshell_image_tag: Option<&'static str>, - image_tag: Option<&'static str>, - cargo_pkg_version: &'static str, -) -> String { - let tag = openshell_image_tag - .filter(|tag| !tag.is_empty()) - .or_else(|| image_tag.filter(|tag| !tag.is_empty())) - .unwrap_or_else(|| { - if cargo_pkg_version.is_empty() || cargo_pkg_version == "0.0.0" { - "dev" - } else { - cargo_pkg_version - } - }); - - tag.replace('+', "-") -} - /// Queried by the Docker driver to decide when a sandbox's supervisor /// relay is live. Implementations return `true` once a sandbox has an /// active `ConnectSupervisor` session registered. @@ -3079,7 +3030,9 @@ fn resolve_supervisor_bin_source( // Tier 5: pull the release-matched default supervisor image and extract // the binary to a host-side cache keyed by image content digest. - Ok(SupervisorBinSource::Image(default_docker_supervisor_image())) + Ok(SupervisorBinSource::Image( + openshell_core::config::default_supervisor_image(), + )) } pub(crate) async fn resolve_supervisor_bin( diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index d42d41099d..560d47de7a 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -2018,7 +2018,7 @@ fn docker_guest_tls_paths_allows_plain_http_without_tls_flags() { #[test] fn default_docker_supervisor_image_uses_nvidia_ghcr_repo() { - let image = default_docker_supervisor_image(); + let image = openshell_core::config::default_supervisor_image(); assert!( image.starts_with("ghcr.io/nvidia/openshell/supervisor:"), "unexpected default image reference: {image}", @@ -2057,36 +2057,25 @@ fn configured_supervisor_image_takes_precedence_over_local_binaries() { #[test] fn docker_supervisor_image_tag_prefers_explicit_build_tags() { + use openshell_core::config::resolve_supervisor_image_tag; assert_eq!( - resolve_default_docker_supervisor_image_tag(Some("1.2.3"), Some("sha"), "0.0.0"), - "1.2.3", - ); - assert_eq!( - resolve_default_docker_supervisor_image_tag(None, Some("sha"), "0.0.0"), - "sha", - ); - assert_eq!( - resolve_default_docker_supervisor_image_tag(None, None, "1.2.3"), - "1.2.3", - ); - assert_eq!( - resolve_default_docker_supervisor_image_tag(Some(""), Some(""), "0.0.0"), - "dev", + resolve_supervisor_image_tag(&["1.2.3", "sha", "0.0.0"]), + "1.2.3" ); + assert_eq!(resolve_supervisor_image_tag(&["", "sha", "0.0.0"]), "sha"); + assert_eq!(resolve_supervisor_image_tag(&["", "", "1.2.3"]), "1.2.3"); + assert_eq!(resolve_supervisor_image_tag(&["", "", "0.0.0"]), "dev"); } #[test] fn docker_supervisor_image_tag_sanitizes_build_metadata_for_docker() { + use openshell_core::config::resolve_supervisor_image_tag; assert_eq!( - resolve_default_docker_supervisor_image_tag(None, None, "0.0.37-dev.156+g1d3b741ee"), + resolve_supervisor_image_tag(&["", "", "0.0.37-dev.156+g1d3b741ee"]), "0.0.37-dev.156-g1d3b741ee", ); assert_eq!( - resolve_default_docker_supervisor_image_tag( - Some("0.0.37-dev.156+g1d3b741ee"), - None, - "0.0.0", - ), + resolve_supervisor_image_tag(&["0.0.37-dev.156+g1d3b741ee", "", "0.0.0"]), "0.0.37-dev.156-g1d3b741ee", ); } diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 292563c2e6..23f80f680b 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 -use openshell_core::config::DEFAULT_SUPERVISOR_IMAGE; +use openshell_core::config; use serde::{Deserialize, Deserializer, Serialize}; use std::path::Path; use std::str::FromStr; @@ -288,7 +288,7 @@ impl Default for KubernetesComputeConfig { // is Podman vocabulary and is not a valid Kubernetes value. image_pull_policy: String::new(), image_pull_secrets: Vec::new(), - supervisor_image: DEFAULT_SUPERVISOR_IMAGE.to_string(), + supervisor_image: config::default_supervisor_image(), supervisor_image_pull_policy: String::new(), supervisor_sideload_method: SupervisorSideloadMethod::default(), supervisor_topology: SupervisorTopology::default(), diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index 77f671dcb2..a300fe6a07 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -127,7 +127,7 @@ async fn main() -> Result<()> { image_pull_secrets: args.sandbox_image_pull_secrets, supervisor_image: args .supervisor_image - .unwrap_or_else(|| openshell_core::config::DEFAULT_SUPERVISOR_IMAGE.to_string()), + .unwrap_or_else(openshell_core::config::default_supervisor_image), supervisor_image_pull_policy: args.supervisor_image_pull_policy.unwrap_or_default(), supervisor_sideload_method: args.supervisor_sideload_method, supervisor_topology: args.supervisor_topology, diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 0e29f52dd2..def8e5f3dc 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/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 -use openshell_core::config::{DEFAULT_STOP_TIMEOUT_SECS, DEFAULT_SUPERVISOR_IMAGE}; +use openshell_core::config::DEFAULT_STOP_TIMEOUT_SECS; use std::net::IpAddr; use std::path::PathBuf; use std::str::FromStr; @@ -255,7 +255,7 @@ impl Default for PodmanComputeConfig { network_name: DEFAULT_NETWORK_NAME.to_string(), host_gateway_ip: Self::default_host_gateway_ip(), stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS, - supervisor_image: DEFAULT_SUPERVISOR_IMAGE.to_string(), + supervisor_image: openshell_core::config::default_supervisor_image(), guest_tls_ca: None, guest_tls_cert: None, guest_tls_key: None, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index ad44fca43e..5eb9a5c873 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1836,7 +1836,7 @@ mod tests { let vol = &image_volumes[0]; assert_eq!( vol["source"].as_str(), - Some("ghcr.io/nvidia/openshell/supervisor:latest"), + Some(openshell_core::config::default_supervisor_image().as_str()), "image volume source should be the supervisor image" ); assert_eq!( @@ -1922,8 +1922,9 @@ mod tests { let image_volumes = spec["image_volumes"] .as_array() .expect("image_volumes should be an array"); + let expected_supervisor = openshell_core::config::default_supervisor_image(); assert!(image_volumes.iter().any(|volume| { - volume["source"].as_str() == Some("ghcr.io/nvidia/openshell/supervisor:latest") + volume["source"].as_str() == Some(expected_supervisor.as_str()) && volume["destination"].as_str() == Some("/opt/openshell/bin") })); assert!(image_volumes.iter().any(|volume| { diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index e6ba7b9ffc..c57aff4277 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -90,7 +90,7 @@ struct Args { /// OCI image containing the openshell-sandbox supervisor binary. #[arg(long, env = "OPENSHELL_SUPERVISOR_IMAGE")] - supervisor_image: String, + supervisor_image: Option, /// Host path to the CA certificate for sandbox mTLS. #[arg(long, env = "OPENSHELL_PODMAN_TLS_CA")] @@ -130,7 +130,9 @@ async fn main() -> Result<()> { sandbox_ssh_socket_path: args.sandbox_ssh_socket_path, network_name: args.network_name, stop_timeout_secs: args.stop_timeout, - supervisor_image: args.supervisor_image, + supervisor_image: args + .supervisor_image + .unwrap_or_else(openshell_core::config::default_supervisor_image), guest_tls_ca: args.podman_tls_ca, guest_tls_cert: args.podman_tls_cert, guest_tls_key: args.podman_tls_key, diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 88b82870de..8f840d1776 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -88,7 +88,8 @@ disable_tls = false # Shared driver defaults. These inherit into [openshell.drivers.] tables # when the driver-specific table does not override them. default_image = "ghcr.io/nvidia/openshell/sandbox:latest" -supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" +# Defaults to the gateway version; override to pin a specific build. +# supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" client_tls_secret_name = "openshell-client-tls" service_account_name = "openshell-sandbox" host_gateway_ip = "10.0.0.1" @@ -172,7 +173,8 @@ service_account_name = "openshell-sandbox" default_image = "ghcr.io/nvidia/openshell/sandbox:latest" image_pull_policy = "IfNotPresent" image_pull_secrets = ["regcred"] -supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" +# Defaults to the gateway version; override to pin a specific build. +# supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" supervisor_image_pull_policy = "IfNotPresent" # Use the image volume on Kubernetes >= 1.35 (GA in 1.36); switch to "init-container" # on older clusters or where the ImageVolume feature gate is off. @@ -228,7 +230,8 @@ grpc_endpoint = "https://host.openshell.internal:17670" # Skip the image-pull-and-extract step by pointing at a locally built binary. supervisor_bin = "/usr/local/libexec/openshell/openshell-sandbox" # When supervisor_bin is omitted, Docker extracts /openshell-sandbox from this image. -supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" +# Defaults to the gateway version; override to pin a specific build. +# supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" guest_tls_ca = "/etc/openshell/certs/ca.pem" guest_tls_cert = "/etc/openshell/certs/client.pem" guest_tls_key = "/etc/openshell/certs/client-key.pem" @@ -270,7 +273,8 @@ network_name = "openshell" # host_gateway_ip = "192.168.127.254" sandbox_ssh_socket_path = "/run/openshell/ssh.sock" stop_timeout_secs = 10 -supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" +# Defaults to the gateway version; override to pin a specific build. +# supervisor_image = "ghcr.io/nvidia/openshell/supervisor:" guest_tls_ca = "/etc/openshell/certs/ca.pem" guest_tls_cert = "/etc/openshell/certs/client.pem" guest_tls_key = "/etc/openshell/certs/client-key.pem" From bebf440b2545f68a59e70848b6d326b1ec9bc972 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Fri, 10 Jul 2026 12:31:11 -0700 Subject: [PATCH 11/17] fix(helm): propagate supervisor image overrides (#2216) Signed-off-by: Taylor Mutch --- architecture/build.md | 3 ++ deploy/helm/openshell/README.md | 4 +-- deploy/helm/openshell/templates/_helpers.tpl | 23 ++++++++++-- .../openshell/templates/gateway-config.yaml | 2 ++ .../openshell/tests/gateway_config_test.yaml | 36 +++++++++++++++++++ deploy/helm/openshell/values.yaml | 10 +++--- docs/reference/sandbox-compute-drivers.mdx | 2 +- 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/architecture/build.md b/architecture/build.md index a3cb2e25fb..5537e63619 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -98,6 +98,9 @@ Runtime layout: Gateway image builds bake the corresponding supervisor image tag into the gateway binary so Docker sandboxes do not depend on `:latest` by default. +The Helm chart omits the supervisor image from gateway configuration unless an +operator supplies a repository or tag override, preserving that build-time +pairing for Kubernetes sandboxes as well. Package formulas also pin Docker supervisor extraction to the matching release image tag so standalone gateway binaries do not infer image tags from package versions. diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 6bc459567b..4a22d640f5 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -237,8 +237,8 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | serviceAccount.create | bool | `true` | Create a service account for the gateway. | | serviceAccount.name | string | `""` | Existing service account name to use when serviceAccount.create is false. | | supervisor.image.pullPolicy | string | `""` | Supervisor image pull policy. Defaults to the gateway image pull policy when empty. | -| supervisor.image.repository | string | `"ghcr.io/nvidia/openshell/supervisor"` | Supervisor image repository. | -| supervisor.image.tag | string | `""` | Supervisor image tag. Defaults to the chart appVersion when empty. | +| supervisor.image.repository | string | `"ghcr.io/nvidia/openshell/supervisor"` | Supervisor image repository. Changing it uses the effective gateway image tag unless tag is also set. | +| supervisor.image.tag | string | `""` | Supervisor image tag override. Empty uses the version pinned into the gateway unless repository is changed. | | supervisor.sideloadMethod | string | `""` | How the supervisor binary is delivered into sandbox pods. Empty (default) = auto-detect from cluster version: K8s >= v1.35 -> "image-volume" (ImageVolume enabled by default; GA in v1.36) K8s < v1.35 -> "init-container" (copies via init container + emptyDir) On K8s v1.33-v1.34 with the ImageVolume feature gate manually enabled, set this to "image-volume" explicitly. | | supervisor.topology | string | `"combined"` | Supervisor pod topology for Kubernetes sandboxes. "combined" runs networking and process supervision in the agent container. | | tolerations | list | `[]` | Tolerations for the gateway pod. | diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index 5872dc4043..1b4598088f 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -78,12 +78,29 @@ so a released chart automatically pulls the matching image without extra overrid {{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) }} {{- end }} +{{/* Official supervisor repository used by the gateway's built-in default. */}} +{{- define "openshell.defaultSupervisorRepository" -}} +ghcr.io/nvidia/openshell/supervisor +{{- 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. +*/}} +{{- define "openshell.supervisorImageOverrideEnabled" -}} +{{- $defaultRepository := include "openshell.defaultSupervisorRepository" . -}} +{{- $repository := .Values.supervisor.image.repository | default $defaultRepository -}} +{{- if or (ne $repository $defaultRepository) .Values.supervisor.image.tag -}}true{{- end -}} +{{- end }} + {{/* -Supervisor image reference. Same appVersion fallback as openshell.image so -the supervisor and gateway images stay in sync across releases. +Supervisor image override. A tag-only override uses the official repository; +a repository-only override uses the effective gateway image tag. */}} {{- define "openshell.supervisorImage" -}} -{{- printf "%s:%s" .Values.supervisor.image.repository (.Values.supervisor.image.tag | default .Chart.AppVersion) }} +{{- $repository := .Values.supervisor.image.repository | default (include "openshell.defaultSupervisorRepository" .) -}} +{{- $tag := .Values.supervisor.image.tag | default .Values.image.tag | default .Chart.AppVersion -}} +{{- printf "%s:%s" $repository $tag }} {{- end }} {{/* diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 0e75a311f0..55beac7a19 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -34,7 +34,9 @@ data: log_level = {{ .Values.server.logLevel | quote }} sandbox_namespace = {{ include "openshell.sandboxNamespace" . | quote }} default_image = {{ .Values.server.sandboxImage | quote }} + {{- if include "openshell.supervisorImageOverrideEnabled" . }} supervisor_image = {{ include "openshell.supervisorImage" . | quote }} + {{- end }} {{- if .Values.server.hostGatewayIP }} host_gateway_ip = {{ .Values.server.hostGatewayIP | quote }} {{- end }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 50051e0036..797182558e 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -90,6 +90,42 @@ tests: path: data["gateway.toml"] pattern: '(?ms)\[openshell\.drivers\.kubernetes\].*?supervisor_topology\s*=\s*"combined"' + - it: uses the gateway built-in supervisor image by default + template: templates/gateway-config.yaml + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'supervisor_image\s*=' + + - it: renders a supervisor tag override with the official repository + template: templates/gateway-config.yaml + set: + supervisor.image.tag: 1.2.3 + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'supervisor_image\s*=\s*"ghcr\.io/nvidia/openshell/supervisor:1\.2\.3"' + + - it: renders a supervisor repository override with the effective gateway tag + template: templates/gateway-config.yaml + set: + image.tag: gateway-build + supervisor.image.repository: registry.example.com/openshell/supervisor + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'supervisor_image\s*=\s*"registry\.example\.com/openshell/supervisor:gateway-build"' + + - it: renders complete supervisor repository and tag overrides + template: templates/gateway-config.yaml + set: + supervisor.image.repository: registry.example.com/openshell/supervisor + supervisor.image.tag: supervisor-build + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: 'supervisor_image\s*=\s*"registry\.example\.com/openshell/supervisor:supervisor-build"' + - it: renders explicit combined supervisor topology under [openshell.drivers.kubernetes] template: templates/gateway-config.yaml set: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index fd73dd0713..f36b533d13 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -26,16 +26,16 @@ image: # -- Gateway image tag. Defaults to the chart appVersion when empty. tag: "" -# Supervisor image - provides the openshell-sandbox binary injected into sandbox -# pods. tag defaults to appVersion (same as the gateway image) so both stay in -# sync when the chart is released. +# Supervisor image for the openshell-sandbox binary injected into sandbox pods. +# The default repository and empty tag use the version-pinned image built into +# the gateway. Changing the repository or setting a tag enables a Helm override. supervisor: image: - # -- Supervisor image repository. + # -- Supervisor image repository. Changing it uses the effective gateway image tag unless tag is also set. repository: ghcr.io/nvidia/openshell/supervisor # -- Supervisor image pull policy. Defaults to the gateway image pull policy when empty. pullPolicy: "" - # -- Supervisor image tag. Defaults to the chart appVersion when empty. + # -- Supervisor image tag override. Empty uses the version pinned into the gateway unless repository is changed. tag: "" # -- How the supervisor binary is delivered into sandbox pods. # Empty (default) = auto-detect from cluster version: diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 714ddd6c33..e5cb8e254d 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -303,7 +303,7 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA | `image_pull_secrets` | `server.sandboxImagePullSecrets` | Attach Kubernetes image pull secrets to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | | `grpc_endpoint` | `server.grpcEndpoint` | Set the gateway callback endpoint reachable from sandbox pods. | | `client_tls_secret_name` | `server.tls.clientTlsSecretName` | Mount sandbox client TLS materials from a Kubernetes secret. | -| `supervisor_image` | `supervisor.image.repository` / `supervisor.image.tag` | Set the supervisor image that provides the `openshell-sandbox` binary. | +| `supervisor_image` | `supervisor.image.repository` / `supervisor.image.tag` | Override the supervisor image that provides the `openshell-sandbox` binary. The default repository with an empty tag uses the version-pinned image built into the gateway. Changing the repository uses the effective gateway image tag, while setting a tag pins that version explicitly. | | `supervisor_image_pull_policy` | `supervisor.image.pullPolicy` | Set the Kubernetes image pull policy for the supervisor image. | | `supervisor_sideload_method` | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect from cluster version. Set to `image-volume` to mount the supervisor OCI image directly as a volume (requires Kubernetes 1.33+ with the ImageVolume feature gate; GA in 1.36), or `init-container` to copy it through an init container on older clusters. | | `app_armor_profile` | `server.appArmorProfile` | Set the sandbox agent container's AppArmor profile. Helm defaults this to `Unconfined` so AppArmor-enabled nodes do not block supervisor network namespace setup. Set the Helm value to an empty string to omit the field, or use `RuntimeDefault` or `Localhost/` for operator-managed profiles. | From 8eacb4779f7fe5fc56dfe2834421f78a480e5474 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Fri, 10 Jul 2026 13:01:39 -0700 Subject: [PATCH 12/17] feat(kubernetes): add sidecar supervisor topology (#2076) * feat(kubernetes): add sidecar supervisor topology Add the Kubernetes sidecar supervisor topology, its Helm/Skaffold configuration, topology documentation, and sidecar e2e matrix coverage. Skip root-only sandbox identity rewriting when process enforcement is network-only so the low-permission sidecar process container can start successfully. Signed-off-by: Taylor Mutch * fix(supervisor): avoid similar process id names Signed-off-by: Taylor Mutch * fix(supervisor): avoid similar process id names Signed-off-by: Taylor Mutch * fix(sandbox): avoid similar proxy id names Signed-off-by: Taylor Mutch * docs(kubernetes): clarify sidecar topology limits Signed-off-by: Taylor Mutch * fix(kubernetes): keep sidecar process leaf capless Signed-off-by: Taylor Mutch * fix(kubernetes): refresh sidecar provider env snapshots Signed-off-by: Taylor Mutch * test(supervisor): align hot-swap identity regression Signed-off-by: Taylor Mutch * fix(kubernetes): stage sidecar mtls files before proxy chown Signed-off-by: Taylor Mutch * fix(kubernetes): simplify sidecar supervisor topology Signed-off-by: Taylor Mutch * chore(helm): reuse sidecar skaffold values Signed-off-by: Taylor Mutch * fix(supervisor): avoid similar iptables helper names Signed-off-by: Taylor Mutch * fix(e2e): harden kube gateway wrapper setup Signed-off-by: Taylor Mutch * fix(supervisor): avoid nft batch rollback on OCP Run nftables setup as individual commands so optional conntrack and log expressions can fail without rolling back required table, chain, and reject rules. Signed-off-by: Seth Jennings Signed-off-by: Taylor Mutch * fix(kubernetes): preserve process identity in sidecar topology Render sidecar pods with a shared process namespace, keep binary-aware network policy enabled, and move Kubernetes sidecar settings under the nested sidecar config table. Also apply unprivileged Landlock/seccomp setup in NetworkOnly supervisor mode so sidecar topology keeps sandbox child hardening without privileged process setup. Signed-off-by: Taylor Mutch * refactor(kubernetes): replace sidecar snapshots with control socket Coordinate sidecar policy and provider bootstrap over a local Unix socket so the process leaf no longer reads policy/provider snapshot files. Report entrypoint startup through the control channel and keep gateway credentials confined to the network sidecar. Signed-off-by: Taylor Mutch * feat(kubernetes): support relaxed sidecar network identity Signed-off-by: Taylor Mutch * fix(sandbox): satisfy sidecar clippy lint Signed-off-by: Taylor Mutch * refactor(kubernetes): standardize topology naming Signed-off-by: Taylor Mutch * fix(sandbox): satisfy linux clippy timeout import Signed-off-by: Taylor Mutch * fix(kubernetes): support kata sidecar on ipv4 pods Signed-off-by: Taylor Mutch * fix(kubernetes): satisfy linux clippy for sidecar fallback Signed-off-by: Taylor Mutch * chore(kubernetes): remove stale supervisor topology references Signed-off-by: Taylor Mutch * fix(kubernetes): enable sidecar binary policy inspection Signed-off-by: Taylor Mutch * fix(kubernetes): harden sidecar control boundary Signed-off-by: Taylor Mutch * fix(kubernetes): couple sidecar supervisor lifecycles Signed-off-by: Taylor Mutch --------- Signed-off-by: Taylor Mutch Signed-off-by: Seth Jennings Co-authored-by: Seth Jennings --- .../skills/debug-openshell-cluster/SKILL.md | 51 + .agents/skills/helm-dev-environment/SKILL.md | 30 +- .github/workflows/branch-e2e.yml | 11 +- Cargo.lock | 3 + architecture/build.md | 9 +- architecture/compute-runtimes.md | 40 +- crates/openshell-core/src/grpc_client.rs | 7 +- .../src/provider_credentials.rs | 90 ++ crates/openshell-core/src/sandbox_env.rs | 28 + crates/openshell-driver-kubernetes/README.md | 40 +- .../openshell-driver-kubernetes/src/config.rs | 186 +++- .../openshell-driver-kubernetes/src/driver.rs | 981 +++++++++++++++++- crates/openshell-driver-kubernetes/src/lib.rs | 5 +- .../openshell-driver-kubernetes/src/main.rs | 33 +- crates/openshell-driver-podman/README.md | 4 +- .../openshell-driver-podman/src/container.rs | 5 +- crates/openshell-sandbox/Cargo.toml | 5 + crates/openshell-sandbox/src/lib.rs | 715 +++++++++++-- crates/openshell-sandbox/src/main.rs | 299 +++++- .../openshell-sandbox/src/sidecar_control.rs | 783 ++++++++++++++ .../data/sandbox-policy.rego | 14 + .../src/identity.rs | 69 +- .../openshell-supervisor-network/src/opa.rs | 202 +++- .../openshell-supervisor-network/src/proxy.rs | 322 ++++-- .../openshell-supervisor-network/src/run.rs | 4 +- .../openshell-supervisor-process/src/lib.rs | 2 + .../src/netns/mod.rs | 419 ++++++-- .../src/netns/nft_ruleset.rs | 525 ++++++++-- .../src/process.rs | 109 +- .../openshell-supervisor-process/src/run.rs | 124 ++- .../src/sandbox/linux/landlock.rs | 78 +- .../src/sandbox/linux/mod.rs | 17 +- .../openshell-supervisor-process/src/ssh.rs | 215 +++- .../src/supervisor_session.rs | 84 +- .../src/unix_socket.rs | 60 ++ deploy/docker/Dockerfile.supervisor | 25 +- deploy/helm/openshell/README.md | 4 +- .../openshell/ci/values-sidecar-kata.yaml | 24 + deploy/helm/openshell/ci/values-sidecar.yaml | 18 + deploy/helm/openshell/skaffold.yaml | 17 + .../openshell/templates/gateway-config.yaml | 6 +- .../openshell/tests/gateway_config_test.yaml | 32 +- deploy/helm/openshell/values.yaml | 16 +- docs/kubernetes/access-control.mdx | 2 +- docs/kubernetes/ingress.mdx | 2 +- docs/kubernetes/managing-certificates.mdx | 2 +- docs/kubernetes/openshift.mdx | 7 +- docs/kubernetes/setup.mdx | 3 +- docs/kubernetes/topology.mdx | 195 +++- docs/reference/gateway-config.mdx | 19 +- docs/reference/sandbox-compute-drivers.mdx | 28 + e2e/with-kube-gateway.sh | 19 +- tasks/helm.toml | 30 + tasks/test.toml | 5 + 54 files changed, 5463 insertions(+), 560 deletions(-) create mode 100644 crates/openshell-sandbox/src/sidecar_control.rs create mode 100644 crates/openshell-supervisor-process/src/unix_socket.rs create mode 100644 deploy/helm/openshell/ci/values-sidecar-kata.yaml create mode 100644 deploy/helm/openshell/ci/values-sidecar.yaml diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 5bc04beb39..82ce9650cd 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -268,6 +268,57 @@ kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\ kubectl -n get sandbox -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}' ``` +If `topology = "sidecar"` is rendered under `[openshell.drivers.kubernetes]`, +sandbox pods should have an `openshell-network-init` init container running +`--mode=network-init`, an `agent` container running +`openshell-sandbox --mode=process`, and an `openshell-supervisor-network` +container running `--mode=network`. The init container owns nftables setup and +should be the only sidecar topology container with `NET_ADMIN`. It also needs +`CHOWN`/`FOWNER` to hand shared emptyDir state to the effective sidecar UID. The +default binary-aware network sidecar runs as UID 0 with primary GID +`sandbox_gid` and adds `SYS_PTRACE` plus `DAC_READ_SEARCH`. When +`process_binary_aware_network_policy = false`, it runs as the configured +non-root `proxy_uid` without those inspection capabilities. The pod `fsGroup` +is set to `sandbox_gid` in both modes. + +In sidecar topology only the network sidecar should mount the gateway bootstrap +credentials (`openshell-sa-token` and `openshell-client-tls`). The process +container should not receive `OPENSHELL_ENDPOINT`, gateway TLS env vars, the +sandbox token file, or those credential mounts. Instead, the network sidecar +serves policy and provider environment state over the Unix control socket from +`OPENSHELL_SIDECAR_CONTROL_SOCKET` (`/run/openshell-sidecar/control.sock` by +default). The process supervisor must be the first and only client. After +validating its peer UID, GID, and PID, the sidecar unlinks the listener. If the +connection later closes, the network sidecar exits non-zero so Kubernetes can +restart it with a fresh listener. If the process supervisor fails before +launching the workload, +inspect both containers for control-socket bind, connect, bootstrap, or update +errors. If new SSH/exec sessions do not pick up refreshed provider environment, +inspect the network sidecar settings-poll logs and the process container logs +for provider environment update handling; the process container should consume +newer provider-env revisions without receiving gateway credentials. + +The process container reports the workload entrypoint PID over the same control +socket, and the network sidecar uses that PID for binary-scoped policy +decisions through `/proc`. If rules with `policy.binaries` are unexpectedly +denied, inspect the sidecar control logs and confirm the pod has +`shareProcessNamespace: true`. +The shared state directory should preserve `sandbox_gid` inheritance +(`02775`). Sidecar SSH uses the Linux abstract socket +`@openshell-sidecar-ssh`; the network sidecar verifies its peer PID before +bridging gateway relay requests. No `ssh.sock` file should appear in the shared +state directory. +Inspect all three when sandbox registration or egress enforcement fails: + +```bash +kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\.toml}' | grep -E '^\[openshell\.drivers\.kubernetes\]|^topology\s*=' +kubectl -n get pod -o jsonpath='{range .spec.initContainers[*]}{.name}{" "}{.command}{"\n"}{end}' +kubectl -n get pod -o jsonpath='{range .spec.containers[*]}{.name}{" "}{.command}{"\n"}{end}' +kubectl -n logs -c openshell-network-init --tail=200 +kubectl -n logs -c openshell-supervisor-network --tail=200 +kubectl -n logs -c agent --tail=200 +``` + ### Step 6: Check VM-Backed Gateways Use the VM driver logs and host diagnostics available in the user's environment. Verify: diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index bffa4e2e86..adb40a9575 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -60,9 +60,25 @@ mise run helm:skaffold:dev mise run helm:skaffold:run ``` +**Supervisor sidecar topology** (build once and leave running): +```bash +mise run helm:skaffold:run:sidecar +``` + +**Supervisor sidecar topology with TLS/mTLS enabled** (build once and leave running): +```bash +mise run helm:skaffold:run:sidecar-mtls +``` + Both commands build the `gateway` and `supervisor` images and deploy the OpenShell Helm -chart. 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. +chart. The sidecar profile renders an `openshell-network-init` init container for +nftables setup and an `openshell-supervisor-network` runtime sidecar for proxying. +Binary-aware policy mode runs that sidecar as UID 0 with `SYS_PTRACE` and +`DAC_READ_SEARCH`; relaxed mode can run it as the configured proxy 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. The gateway Service uses ClusterIP. Access is via Envoy Gateway (port `8080`) or `kubectl port-forward`. @@ -74,7 +90,8 @@ create the Secret named `openshell-ha-pg` with a `uri` key, then run ### TLS behaviour `ci/values-skaffold.yaml` sets `server.disableTls: true`, so Skaffold-based deploys run -plaintext by default. To test with TLS enabled, comment out that line and redeploy. +plaintext by default. To test sidecar topology with TLS enabled, use +`mise run helm:skaffold:run:sidecar-mtls`. | Mode | `server.disableTls` | Gateway scheme | |------|---------------------|----------------| @@ -126,6 +143,12 @@ openshell sandbox list --gateway-endpoint https://localhost:8090 mise run helm:skaffold:delete ``` +For a sidecar-profile deployment: + +```bash +mise run helm:skaffold:delete:sidecar +``` + ### Delete the cluster entirely ```bash @@ -250,6 +273,7 @@ for dependencies still declared in `Chart.yaml`. | `deploy/helm/openshell/ci/values-gateway.yaml` | Envoy Gateway GRPCRoute + Gateway overlay | | `deploy/helm/openshell/ci/values-high-availability.yaml` | HA test overlay (`replicaCount: 2` with external PostgreSQL Secret) | | `deploy/helm/openshell/ci/values-keycloak.yaml` | Keycloak OIDC overlay | +| `deploy/helm/openshell/ci/values-sidecar.yaml` | Supervisor sidecar topology overlay for Kubernetes e2e/dev | | `deploy/helm/openshell/ci/values-spire.yaml` | SPIFFE/SPIRE provider token grant overlay | | `deploy/helm/openshell/ci/values-spire-stack.yaml` | SPIRE hardened chart values for local dev | | `deploy/helm/openshell/ci/values-tls-disabled.yaml` | Lint-only: TLS + auth disabled (reverse-proxy edge termination) | diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index ebe7834068..859f22ef95 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -121,16 +121,25 @@ jobs: include: - agent_sandbox_api: v1beta1 agent_sandbox_version: v0.5.0 + topology: combined + extra_helm_values: "" - agent_sandbox_api: v1alpha1 agent_sandbox_version: v0.4.6 + topology: combined + extra_helm_values: "" + - agent_sandbox_api: v1beta1 + agent_sandbox_version: v0.5.0 + topology: sidecar + extra_helm_values: deploy/helm/openshell/ci/values-sidecar.yaml permissions: contents: read packages: read uses: ./.github/workflows/e2e-kubernetes-test.yml with: image-tag: ${{ github.sha }} - job-name: Kubernetes E2E (Rust smoke, Agent Sandbox ${{ matrix.agent_sandbox_api }}) + 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 }} kubernetes-ha-e2e: needs: [pr_metadata, build-gateway, build-supervisor] diff --git a/Cargo.lock b/Cargo.lock index 916969ac5f..76dedf0625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3827,12 +3827,15 @@ dependencies = [ "clap", "futures", "miette", + "nix", "openshell-core", "openshell-ocsf", "openshell-policy", "openshell-supervisor-network", "openshell-supervisor-process", + "prost", "rustls", + "serde", "serde_json", "temp-env", "tempfile", diff --git a/architecture/build.md b/architecture/build.md index 5537e63619..ef62a6c30c 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -91,10 +91,11 @@ Runtime layout: 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. -- **Supervisor**: `scratch` base, static musl binary at `/openshell-sandbox`. - Static linkage is required because the image is mounted/extracted into - sandbox environments (Docker extraction, Podman image volumes, Kubernetes - init-container copy-self) and cannot rely on a dynamic loader. +- **Supervisor**: Alpine base with `nftables`, static musl binary at + `/openshell-sandbox`. Static linkage keeps the binary usable when the image + is mounted/extracted into sandbox environments (Docker extraction, Podman + image volumes, Kubernetes init-container copy-self), while `nftables` supports + Kubernetes supervisor sidecar egress enforcement. Gateway image builds bake the corresponding supervisor image tag into the gateway binary so Docker sandboxes do not depend on `:latest` by default. diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index f122fda5d7..d5d0912e23 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -81,7 +81,7 @@ The supervisor must be available inside each sandbox workload: |---|---| | Docker | Bind-mounted local supervisor binary, or a binary extracted from the configured supervisor image. | | Podman | Read-only OCI image volume containing the supervisor binary. | -| Kubernetes | Sandbox pod image or pod template configuration. | +| Kubernetes | Supervisor image side-loaded into the sandbox pod by image volume or init container. | | VM | Embedded in the guest rootfs bundle. | | Extension | Defined by the out-of-tree driver. | @@ -89,6 +89,44 @@ Driver-controlled environment variables must override sandbox image or template values for sandbox ID, sandbox name, gateway endpoint, relay socket path, TLS paths, and command metadata. +Kubernetes can run the supervisor in the default combined topology or in a +sidecar topology. Combined mode keeps network and process supervision in the +agent container. Sidecar mode runs network enforcement, the proxy, and gateway +session in a dedicated sidecar, while the agent container runs only the +process-supervision leaf and launches the user workload after the sidecar +serves bootstrap state over a local control socket. The network sidecar owns +gateway credentials and sends policy plus workload-facing provider environment +state to the process leaf over that socket. It also streams provider +environment updates after settings polls so future process sessions see +updated provider env without giving the process leaf gateway access. The +pre-workload process supervisor is the only accepted control client: the +network sidecar verifies its UID, GID, and PID with peer credentials, removes +the listener after accepting it, and ignores workload-supplied relay targets. +SSH relays use a Linux abstract socket and verify its peer PID against that +authenticated process-supervisor connection, so workload filesystem access +cannot replace the relay endpoint. Either supervisor exits when this control +connection closes. This couples their restart lifecycle and prevents a workload +that survives an isolated network-sidecar restart from becoming the next +authoritative control client. In sidecar mode, an init container performs the +privileged pod-network nftables setup with +`NET_ADMIN`. The default binary-aware network sidecar runs as UID 0 without +`NET_ADMIN` and adds `SYS_PTRACE` plus `DAC_READ_SEARCH` so it can resolve +cross-UID workload process/binary identity through shared `/proc`. Operators +can set the sidecar `process_binary_aware_network_policy` flag false to run the +sidecar as the configured non-root proxy UID, omit both inspection capabilities, +and downgrade network policy to endpoint/L7 matching without `policy.binaries`. +The init path applies nftables as individual commands so optional conntrack and +log expressions can fail without rolling back the required table, chain, and +reject rules. +The agent container runs as the resolved sandbox UID/GID with no added Linux +capabilities. Sidecar mode preserves gateway session and SSH behavior, but +treats the process leaf as network-only: Landlock filesystem policy and child +seccomp still apply where supported, while process privilege dropping and +supervisor identity mount isolation do not run because the agent container is +already unprivileged. Sidecar pods use a shared process namespace so the +network sidecar can resolve workload process and binary identity through +`/proc/`. + ## Images The gateway image and Helm chart are built from this repository. Sandbox images diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 57f6bdd816..92eab00408 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -167,9 +167,14 @@ async fn build_plain_channel(endpoint: &str) -> Result { .into_diagnostic() .wrap_err_with(|| format!("failed to read client key from {key_path}"))?; - let tls_config = ClientTlsConfig::new() + let mut tls_config = ClientTlsConfig::new() .ca_certificate(Certificate::from_pem(ca_pem)) .identity(Identity::from_pem(cert_pem, key_pem)); + if let Ok(server_name) = std::env::var(sandbox_env::GATEWAY_TLS_SERVER_NAME) + && !server_name.is_empty() + { + tls_config = tls_config.domain_name(server_name); + } ep = ep .tls_config(tls_config) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index fd38453ccd..65310b3a43 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -63,6 +63,63 @@ impl ProviderCredentialState { } } + /// Build a static provider state from an already-prepared child + /// environment snapshot. + /// + /// Kubernetes sidecar topology uses this in the process-only supervisor: + /// the network sidecar owns provider credential resolvers and sends the + /// workload-facing env map over a local control channel. The process leaf + /// must inject that map into child processes without re-placeholderizing it + /// or holding the gateway-side resolver material. + pub fn from_child_env_snapshot(revision: u64, child_env: HashMap) -> Self { + let snapshot = Arc::new(ProviderCredentialSnapshot { + revision, + child_env, + dynamic_credentials: HashMap::new(), + }); + + Self { + inner: Arc::new(RwLock::new(ProviderCredentialStateInner { + current: snapshot, + generations: VecDeque::new(), + current_resolver: None, + combined_resolver: None, + suppressed_keys: HashSet::new(), + })), + } + } + + /// Install an already-prepared child environment snapshot. + /// + /// This is intentionally narrower than [`Self::install_environment`]: it + /// updates only the workload-facing env map and clears resolver state so a + /// process that does not own gateway/provider resolver material can still + /// pick up refreshed provider env for future child processes. + pub fn install_child_env_snapshot( + &self, + revision: u64, + mut child_env: HashMap, + ) -> usize { + let mut inner = self + .inner + .write() + .expect("provider credential state poisoned"); + + for key in &inner.suppressed_keys { + child_env.remove(key); + } + + inner.current = Arc::new(ProviderCredentialSnapshot { + revision, + child_env, + dynamic_credentials: HashMap::new(), + }); + inner.generations.clear(); + inner.current_resolver = None; + inner.combined_resolver = None; + inner.current.child_env.len() + } + pub fn snapshot(&self) -> Arc { self.inner .read() @@ -594,6 +651,39 @@ mod tests { ); } + #[test] + fn child_env_snapshot_install_updates_env_without_resolver_material() { + let state = ProviderCredentialState::from_child_env_snapshot( + 1, + HashMap::from([ + ("GITHUB_TOKEN".to_string(), "old".to_string()), + ("GCE_METADATA_HOST".to_string(), "marker".to_string()), + ]), + ); + state.remove_env_key("GCE_METADATA_HOST"); + + let env_count = state.install_child_env_snapshot( + 2, + HashMap::from([ + ("GITHUB_TOKEN".to_string(), "new".to_string()), + ("GCE_METADATA_HOST".to_string(), "marker".to_string()), + ]), + ); + + let snapshot = state.snapshot(); + assert_eq!(snapshot.revision, 2); + assert_eq!(env_count, 1); + assert_eq!( + snapshot.child_env.get("GITHUB_TOKEN").map(String::as_str), + Some("new") + ); + assert!(!snapshot.child_env.contains_key("GCE_METADATA_HOST")); + assert!( + state.resolver().is_none(), + "child-env snapshots must not install provider resolver material" + ); + } + #[test] fn stale_generation_falls_back_to_current_credential_after_retention_window() { let state = ProviderCredentialState::from_environment( diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index c56a1c889d..f066580636 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -29,6 +29,34 @@ pub const SANDBOX_COMMAND: &str = "OPENSHELL_SANDBOX_COMMAND"; /// Deployment-controlled telemetry toggle propagated to the sandbox supervisor. pub const TELEMETRY_ENABLED: &str = "OPENSHELL_TELEMETRY_ENABLED"; +/// Supervisor pod/runtime topology. Kubernetes sidecar mode sets this to +/// `"sidecar"`; the default combined supervisor path omits it. +pub const SUPERVISOR_TOPOLOGY: &str = "OPENSHELL_SUPERVISOR_TOPOLOGY"; + +/// Network enforcement backend selected by the compute driver. +pub const NETWORK_ENFORCEMENT_MODE: &str = "OPENSHELL_NETWORK_ENFORCEMENT_MODE"; + +/// Whether network policy evaluation must bind requests to the peer binary. +/// +/// The default when unset is `"required"`. Kubernetes sidecar experiments may +/// set this to `"relaxed"` to enforce endpoint and L7 policy without per-binary +/// `/proc` identity binding. +pub const NETWORK_BINARY_IDENTITY: &str = "OPENSHELL_NETWORK_BINARY_IDENTITY"; + +/// Unix socket used by Kubernetes sidecar topology for local coordination. +/// +/// The network sidecar owns gateway credentials and serves policy/provider +/// state over this socket instead of exposing gateway credentials to the agent +/// container. +pub const SIDECAR_CONTROL_SOCKET: &str = "OPENSHELL_SIDECAR_CONTROL_SOCKET"; + +/// Optional TLS server name override used when connecting to the gateway. +pub const GATEWAY_TLS_SERVER_NAME: &str = "OPENSHELL_GATEWAY_TLS_SERVER_NAME"; + +/// Directory where the network supervisor writes the proxy CA files consumed +/// by workload child processes. +pub const PROXY_TLS_DIR: &str = "OPENSHELL_PROXY_TLS_DIR"; + /// Path to the CA certificate for mTLS communication with the gateway. pub const TLS_CA: &str = "OPENSHELL_TLS_CA"; diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 48ddfb8f11..688660dbb5 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -53,9 +53,43 @@ pods do not need direct external ingress for SSH. ## Container Security Context -The driver grants the sandbox agent container the Linux capabilities the -supervisor needs for namespace setup and policy enforcement. It can also request -a Kubernetes AppArmor profile through `app_armor_profile`. +The default `combined` supervisor topology grants the sandbox agent container +the Linux capabilities the supervisor needs for namespace setup and process, +filesystem, and network policy enforcement. + +The `sidecar` supervisor topology moves pod-level network setup into a root init +container. In the default process/binary-aware mode, the long-lived network +sidecar runs as UID 0 with `allowPrivilegeEscalation: false`, drops default +Linux capabilities, and adds only `SYS_PTRACE` plus `DAC_READ_SEARCH` for +cross-UID workload `/proc` inspection. The agent container also runs as the +resolved sandbox UID/GID with `allowPrivilegeEscalation: false` and +`capabilities.drop: ["ALL"]`. +Set `sidecar.process_binary_aware_network_policy = false` to run the network +sidecar as the configured non-root `sidecar.proxy_uid`, omit the extra `/proc` +inspection capabilities, and enforce endpoint/L7 network policy without +matching `policy.binaries`. +In this mode OpenShell preserves gateway session and SSH behavior, but the +process supervisor does not perform root-to-sandbox privilege dropping or +supervisor identity mount isolation. It still applies Landlock filesystem policy +and child seccomp filters where the kernel/runtime supports them. Network +endpoint and L7 policy remain enforced by the network sidecar, and +sidecar pods use a shared process namespace so the network sidecar can resolve +process/binary identity through `/proc/`. + +Sidecar mode keeps gateway credentials in the network sidecar. The agent +container does not mount the projected service-account token used for sandbox +token bootstrap, does not mount the sandbox client TLS secret, and does not get +gateway callback environment variables. The process supervisor receives policy +and provider environment state from the sidecar over a local control socket in +the shared sidecar state volume. The sidecar accepts only the pre-workload +process-supervisor connection, authenticates its UID/GID/PID with peer +credentials, and removes the listener afterward. SSH relays use a Linux +abstract socket whose peer PID must match that authenticated supervisor. Both +supervisors exit if the control connection closes, coupling their container +restart lifecycle before a new authoritative client can be established. + +The driver can request a Kubernetes AppArmor profile through +`app_armor_profile`. Supported values are `Unconfined`, `RuntimeDefault`, and `Localhost/`. An empty or unset value omits diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 23f80f680b..1eeaac8396 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -15,6 +15,9 @@ pub const DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME: &str = "default"; /// Default storage size for the workspace PVC. pub const DEFAULT_WORKSPACE_STORAGE_SIZE: &str = "2Gi"; +/// Default non-root UID for relaxed Kubernetes network supervisor sidecars. +pub const DEFAULT_PROXY_UID: u32 = 1337; + /// How the supervisor binary is delivered into sandbox pods. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -59,12 +62,16 @@ pub enum SupervisorTopology { /// Run networking and process supervision in the agent container. #[default] Combined, + /// Run network supervision in a privileged sidecar and process supervision + /// as a low-capability wrapper in the agent container. + Sidecar, } impl std::fmt::Display for SupervisorTopology { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Combined => f.write_str("combined"), + Self::Sidecar => f.write_str("sidecar"), } } } @@ -75,11 +82,49 @@ impl FromStr for SupervisorTopology { fn from_str(s: &str) -> Result { match s { "combined" => Ok(Self::Combined), - other => Err(format!("unknown supervisor topology '{other}'")), + "sidecar" => Ok(Self::Sidecar), + other => Err(format!("unknown topology '{other}'")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct KubernetesSidecarConfig { + /// UID used by relaxed long-running network sidecars in `sidecar` + /// topology. The network init container installs nftables rules that + /// exempt this UID, so it must not match the sandbox workload UID. + /// Strict process/binary-aware sidecars run as UID 0 so Kubernetes grants + /// the requested `/proc` inspection capabilities into the effective set. + pub proxy_uid: u32, + /// Require process/binary-aware network policy enforcement in sidecar + /// topology. When disabled, the network sidecar runs as `proxy_uid`, + /// drops the extra `/proc` inspection permissions, and evaluates + /// endpoint/L7 policy without matching `policy.binaries`. + pub process_binary_aware_network_policy: bool, +} + +impl Default for KubernetesSidecarConfig { + fn default() -> Self { + Self { + proxy_uid: DEFAULT_PROXY_UID, + process_binary_aware_network_policy: true, } } } +impl KubernetesSidecarConfig { + pub fn validate_proxy_uid(&self) -> Result<(), String> { + if self.proxy_uid < openshell_policy::MIN_SANDBOX_UID { + return Err(format!( + "sidecar.proxy_uid must be at least {}", + openshell_policy::MIN_SANDBOX_UID + )); + } + Ok(()) + } +} + /// Kubernetes `AppArmor` profile requested for the sandbox agent container. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppArmorProfile { @@ -205,7 +250,9 @@ pub struct KubernetesComputeConfig { /// How the supervisor binary is delivered into sandbox pods. pub supervisor_sideload_method: SupervisorSideloadMethod, /// How the supervisor is arranged for Kubernetes sandbox pods. - pub supervisor_topology: SupervisorTopology, + pub topology: SupervisorTopology, + /// Sidecar-only settings used when `topology = "sidecar"`. + pub sidecar: KubernetesSidecarConfig, pub grpc_endpoint: String, pub ssh_socket_path: String, pub client_tls_secret_name: String, @@ -291,7 +338,8 @@ impl Default for KubernetesComputeConfig { supervisor_image: config::default_supervisor_image(), supervisor_image_pull_policy: String::new(), supervisor_sideload_method: SupervisorSideloadMethod::default(), - supervisor_topology: SupervisorTopology::default(), + topology: SupervisorTopology::default(), + sidecar: KubernetesSidecarConfig::default(), grpc_endpoint: String::new(), ssh_socket_path: "/run/openshell/ssh.sock".to_string(), client_tls_secret_name: String::new(), @@ -336,6 +384,10 @@ impl KubernetesComputeConfig { ) } + pub fn validate_proxy_uid(&self) -> Result<(), String> { + self.sidecar.validate_proxy_uid() + } + /// Resolve the sandbox UID/GID pair. /// /// Resolution order: @@ -351,6 +403,7 @@ impl KubernetesComputeConfig { if let Some(uid) = self.sandbox_uid { return uid; } + // Try OpenShift SCC annotation. if let Some(anns) = namespace_annotations && let Some(range) = anns.get(ANNOTATION_SCC_UID_RANGE) && let Some(uid) = Self::from_open_shift_uid_range(range) @@ -462,39 +515,140 @@ mod tests { } #[test] - fn default_service_account_name_is_default() { + fn default_topology_is_combined() { let cfg = KubernetesComputeConfig::default(); - assert_eq!( - cfg.service_account_name, - DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME - ); + assert_eq!(cfg.topology, SupervisorTopology::Combined); + assert_eq!(cfg.topology.to_string(), "combined"); } #[test] - fn default_supervisor_topology_is_combined() { + fn default_proxy_uid_is_dedicated_non_root_uid() { let cfg = KubernetesComputeConfig::default(); - assert_eq!(cfg.supervisor_topology, SupervisorTopology::Combined); - assert_eq!(cfg.supervisor_topology.to_string(), "combined"); + assert_eq!(cfg.sidecar.proxy_uid, DEFAULT_PROXY_UID); } #[test] - fn serde_override_supervisor_topology_combined() { + fn default_sidecar_requires_process_binary_aware_network_policy() { + let cfg = KubernetesComputeConfig::default(); + assert!(cfg.sidecar.process_binary_aware_network_policy); + } + + #[test] + fn serde_override_topology_sidecar() { let json = serde_json::json!({ - "supervisor_topology": "combined" + "topology": "sidecar" }); let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); - assert_eq!(cfg.supervisor_topology, SupervisorTopology::Combined); + assert_eq!(cfg.topology, SupervisorTopology::Sidecar); } #[test] - fn serde_rejects_invalid_supervisor_topology() { + fn serde_override_topology_combined() { let json = serde_json::json!({ - "supervisor_topology": "unsupported" + "topology": "combined" + }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.topology, SupervisorTopology::Combined); + } + + #[test] + fn serde_rejects_sidecar_binary_identity_field() { + let json = serde_json::json!({ + "sidecar": { + "binary_identity": "shared-pid" + } + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn serde_override_sidecar_process_binary_aware_network_policy_nested() { + let json = serde_json::json!({ + "sidecar": { + "process_binary_aware_network_policy": false + } + }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert!(!cfg.sidecar.process_binary_aware_network_policy); + } + + #[test] + fn serde_override_sidecar_proxy_uid_nested() { + let json = serde_json::json!({ + "sidecar": { + "proxy_uid": 2000 + } + }); + let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.sidecar.proxy_uid, 2000); + cfg.validate_proxy_uid().unwrap(); + } + + #[test] + fn validate_proxy_uid_rejects_privileged_uid() { + let cfg = KubernetesComputeConfig { + sidecar: KubernetesSidecarConfig { + proxy_uid: 999, + ..KubernetesSidecarConfig::default() + }, + ..KubernetesComputeConfig::default() + }; + let err = cfg.validate_proxy_uid().unwrap_err(); + assert!(err.contains("proxy_uid")); + } + + #[test] + fn serde_rejects_invalid_topology() { + let json = serde_json::json!({ + "topology": "unsupported" }); let err = serde_json::from_value::(json).unwrap_err(); assert!(err.to_string().contains("unknown variant")); } + #[test] + fn serde_rejects_removed_topology_alias_field() { + let mut json = serde_json::Map::new(); + json.insert( + ["supervisor", "topology"].join("_"), + serde_json::json!("sidecar"), + ); + let err = + serde_json::from_value::(serde_json::Value::Object(json)) + .unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn serde_rejects_removed_flat_sidecar_fields() { + for json in [ + serde_json::json!({ "sidecar_binary_identity": "shared-pid" }), + serde_json::json!({ "proxy_uid": 2000 }), + ] { + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + } + + #[test] + fn serde_rejects_removed_process_enforcement_field() { + let json = serde_json::json!({ + "process_enforcement": "network-only" + }); + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn default_service_account_name_is_default() { + let cfg = KubernetesComputeConfig::default(); + assert_eq!( + cfg.service_account_name, + DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME + ); + } + #[test] fn serde_override_workspace_storage_size() { let json = serde_json::json!({ diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 166f18b1cc..db7492d27d 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -5,8 +5,9 @@ use super::AppArmorProfile; use crate::config::{ - DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_SANDBOX_UID, DEFAULT_WORKSPACE_STORAGE_SIZE, - KubernetesComputeConfig, SupervisorSideloadMethod, + DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_SANDBOX_UID, + DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, SupervisorSideloadMethod, + SupervisorTopology, }; use futures::{Stream, StreamExt, TryStreamExt}; use k8s_openapi::api::core::v1::{Event as KubeEventObj, Namespace, Node}; @@ -221,6 +222,9 @@ impl KubernetesComputeDriver { config .validate_sandbox_identity_config() .map_err(KubernetesDriverError::Precondition)?; + config + .validate_proxy_uid() + .map_err(KubernetesDriverError::Precondition)?; let base_config = match kube::Config::incluster() { Ok(c) => c, Err(_) => kube::Config::infer() @@ -549,7 +553,8 @@ impl KubernetesComputeDriver { .map_err(KubernetesDriverError::Message)?; // Resolve sandbox UID/GID from config or OpenShift SCC namespace annotations. - let (resolved_uid, resolved_gid, ns_annotations) = self.resolve_sandbox_identity().await; + let (resolved_user_id, resolved_group_id, ns_annotations) = + self.resolve_sandbox_identity().await; let params = SandboxPodParams { default_image: &self.config.default_image, @@ -558,6 +563,12 @@ impl KubernetesComputeDriver { supervisor_image: &self.config.supervisor_image, supervisor_image_pull_policy: &self.config.supervisor_image_pull_policy, supervisor_sideload_method: self.config.supervisor_sideload_method, + topology: self.config.topology, + proxy_uid: self.config.sidecar.proxy_uid, + process_binary_aware_network_policy: self + .config + .sidecar + .process_binary_aware_network_policy, service_account_name: &self.config.service_account_name, sandbox_id: &sandbox.id, sandbox_name: &sandbox.name, @@ -574,9 +585,10 @@ impl KubernetesComputeDriver { provider_spiffe_workload_api_socket_path: &self .config .provider_spiffe_workload_api_socket_path, - sandbox_uid: resolved_uid, - sandbox_gid: resolved_gid, + sandbox_uid: resolved_user_id, + sandbox_gid: resolved_group_id, }; + validate_sidecar_proxy_identity(¶ms)?; let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource); // Copy only the SCC-related annotations onto the Sandbox CR for @@ -1035,6 +1047,32 @@ const SUPERVISOR_VOLUME_NAME: &str = "openshell-supervisor-bin"; /// Name of the init container that installs the supervisor binary. const SUPERVISOR_INIT_CONTAINER_NAME: &str = "openshell-supervisor-install"; +/// Name of the init container that prepares pod-level sidecar networking. +const SUPERVISOR_NETWORK_INIT_CONTAINER_NAME: &str = "openshell-network-init"; + +/// Container name for the network-only supervisor sidecar. +const SUPERVISOR_NETWORK_SIDECAR_NAME: &str = "openshell-supervisor-network"; + +/// UID used by strict process/binary-aware sidecars so Kubernetes grants the +/// requested capability set into the effective set without privilege escalation. +const BINARY_AWARE_SIDECAR_PROXY_UID: u32 = 0; + +/// Shared volume used by the network sidecar and process-only supervisor for +/// local coordination in sidecar topology. +const SIDECAR_STATE_VOLUME_NAME: &str = "openshell-sidecar-state"; +const SIDECAR_STATE_MOUNT_PATH: &str = "/run/openshell-sidecar"; +const SIDECAR_CONTROL_SOCKET: &str = "/run/openshell-sidecar/control.sock"; +// Linux abstract socket names are scoped to the pod's shared network namespace. +// Unlike a filesystem socket in the shared state volume, the workload cannot +// unlink and replace this relay endpoint after the trusted supervisor binds it. +const SIDECAR_SSH_SOCKET_FILE: &str = "@openshell-sidecar-ssh"; + +/// Shared TLS work directory. The network sidecar writes the proxy CA bundle +/// here, while the agent container consumes it after sidecar bootstrap. +const SIDECAR_TLS_VOLUME_NAME: &str = "openshell-supervisor-tls"; +const SIDECAR_TLS_MOUNT_PATH: &str = "/etc/openshell-tls/proxy"; +const SIDECAR_CLIENT_TLS_MOUNT_PATH: &str = "/etc/openshell-tls/proxy/client"; + /// Build the emptyDir volume that holds the supervisor binary. /// /// The init container writes the binary here; the agent container reads it. @@ -1109,31 +1147,12 @@ fn supervisor_init_container( spec } -/// Apply supervisor side-load transforms to an already-built pod template JSON. -/// -/// Depending on the sideload method: -/// - **`ImageVolume`**: mounts the supervisor OCI image directly as a read-only -/// volume (no init container needed, requires K8s >= v1.33). -/// - **`InitContainer`**: injects an emptyDir volume and an init container that -/// copies the supervisor binary from the supervisor image into that volume. -/// -/// In both cases, the agent container gets a command override to run the -/// side-loaded binary and `runAsUser: 0` so it can create network namespaces, -/// set up the proxy, and configure Landlock/seccomp. -#[allow(clippy::similar_names)] -fn apply_supervisor_sideload( - pod_template: &mut serde_json::Value, +fn apply_supervisor_binary_source( + spec: &mut serde_json::Map, supervisor_image: &str, supervisor_image_pull_policy: &str, method: SupervisorSideloadMethod, - sandbox_uid: u32, - sandbox_gid: u32, ) { - let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else { - return; - }; - - // 1. Add the volume (image source or emptyDir depending on method) let volumes = spec .entry("volumes") .or_insert_with(|| serde_json::json!([])) @@ -1152,7 +1171,6 @@ fn apply_supervisor_sideload( } } - // 2. Add the init container only for the init-container method if method == SupervisorSideloadMethod::InitContainer { let init_containers = spec .entry("initContainers") @@ -1165,8 +1183,35 @@ fn apply_supervisor_sideload( )); } } +} + +/// Apply supervisor side-load transforms to an already-built pod template JSON. +/// +/// Depending on the sideload method: +/// - **`ImageVolume`**: mounts the supervisor OCI image directly as a read-only +/// volume (no init container needed, requires K8s >= v1.33). +/// - **`InitContainer`**: injects an emptyDir volume and an init container that +/// copies the supervisor binary from the supervisor image into that volume. +/// +/// In both cases, the agent container gets a command override to run the +/// side-loaded binary as root so it can create network namespaces, set up the +/// proxy, and configure Landlock/seccomp. +#[allow(clippy::similar_names)] +fn apply_supervisor_sideload( + pod_template: &mut serde_json::Value, + supervisor_image: &str, + supervisor_image_pull_policy: &str, + method: SupervisorSideloadMethod, + sandbox_uid: u32, + sandbox_gid: u32, +) { + let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else { + return; + }; + + apply_supervisor_binary_source(spec, supervisor_image, supervisor_image_pull_policy, method); - // 3. Find the agent container and add volume mount + command override + // Find the agent container and add volume mount + command override let Some(containers) = spec.get_mut("containers").and_then(|v| v.as_array_mut()) else { return; }; @@ -1227,6 +1272,398 @@ fn apply_supervisor_sideload( } } +fn sidecar_state_volume_mount() -> serde_json::Value { + serde_json::json!({ + "name": SIDECAR_STATE_VOLUME_NAME, + "mountPath": SIDECAR_STATE_MOUNT_PATH, + }) +} + +fn sidecar_tls_volume_mount() -> serde_json::Value { + serde_json::json!({ + "name": SIDECAR_TLS_VOLUME_NAME, + "mountPath": SIDECAR_TLS_MOUNT_PATH, + }) +} + +fn copy_log_level_env( + env: &mut Vec, + template_environment: &std::collections::HashMap, + spec_environment: &std::collections::HashMap, +) { + if let Some(value) = spec_environment + .get(openshell_core::sandbox_env::LOG_LEVEL) + .or_else(|| template_environment.get(openshell_core::sandbox_env::LOG_LEVEL)) + { + upsert_env(env, openshell_core::sandbox_env::LOG_LEVEL, value); + } +} + +fn supervisor_sidecar_env( + template_environment: &std::collections::HashMap, + spec_environment: &std::collections::HashMap, + params: &SandboxPodParams<'_>, +) -> Vec { + let mut env = Vec::new(); + apply_required_env( + &mut env, + params.sandbox_id, + params.sandbox_name, + params.grpc_endpoint, + "", + !params.client_tls_secret_name.is_empty(), + provider_spiffe_socket_path(params), + ); + if !params.client_tls_secret_name.is_empty() { + upsert_env( + &mut env, + openshell_core::sandbox_env::TLS_CA, + &format!("{SIDECAR_CLIENT_TLS_MOUNT_PATH}/ca.crt"), + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::TLS_CERT, + &format!("{SIDECAR_CLIENT_TLS_MOUNT_PATH}/tls.crt"), + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::TLS_KEY, + &format!("{SIDECAR_CLIENT_TLS_MOUNT_PATH}/tls.key"), + ); + } + copy_log_level_env(&mut env, template_environment, spec_environment); + upsert_env( + &mut env, + openshell_core::sandbox_env::SUPERVISOR_TOPOLOGY, + "sidecar", + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::NETWORK_ENFORCEMENT_MODE, + "sidecar-nftables", + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET, + SIDECAR_CONTROL_SOCKET, + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::SSH_SOCKET_PATH, + SIDECAR_SSH_SOCKET_FILE, + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::PROXY_TLS_DIR, + SIDECAR_TLS_MOUNT_PATH, + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::SANDBOX_UID, + ¶ms.sandbox_uid.to_string(), + ); + upsert_env( + &mut env, + openshell_core::sandbox_env::SANDBOX_GID, + ¶ms.sandbox_gid.to_string(), + ); + if !params.process_binary_aware_network_policy { + upsert_env( + &mut env, + openshell_core::sandbox_env::NETWORK_BINARY_IDENTITY, + "relaxed", + ); + } + env +} + +fn supervisor_sidecar_container( + template_environment: &std::collections::HashMap, + spec_environment: &std::collections::HashMap, + params: &SandboxPodParams<'_>, +) -> serde_json::Value { + let proxy_uid = effective_sidecar_proxy_uid(params); + let capabilities = if params.process_binary_aware_network_policy { + serde_json::json!({ + "drop": ["ALL"], + "add": ["SYS_PTRACE", "DAC_READ_SEARCH"] + }) + } else { + serde_json::json!({ + "drop": ["ALL"] + }) + }; + let mut container = serde_json::json!({ + "name": SUPERVISOR_NETWORK_SIDECAR_NAME, + "image": params.supervisor_image, + "command": [ + SUPERVISOR_IMAGE_BINARY_PATH, + "--mode=network", + ], + "env": supervisor_sidecar_env(template_environment, spec_environment, params), + "securityContext": { + "runAsUser": proxy_uid, + "runAsGroup": params.sandbox_gid, + "runAsNonRoot": proxy_uid != 0, + "allowPrivilegeEscalation": false, + "capabilities": capabilities + }, + "volumeMounts": [ + sidecar_state_volume_mount(), + sidecar_tls_volume_mount(), + { + "name": "openshell-sa-token", + "mountPath": "/var/run/secrets/openshell", + "readOnly": true + } + ] + }); + if !params.supervisor_image_pull_policy.is_empty() { + container["imagePullPolicy"] = serde_json::json!(params.supervisor_image_pull_policy); + } + if params.provider_spiffe_enabled { + container["volumeMounts"] + .as_array_mut() + .expect("volumeMounts is an array") + .push(serde_json::json!({ + "name": SPIFFE_WORKLOAD_API_VOLUME_NAME, + "mountPath": spiffe_socket_mount_path(params.provider_spiffe_workload_api_socket_path), + "readOnly": true, + })); + } + if let Some(profile) = params.app_armor_profile { + container["securityContext"]["appArmorProfile"] = app_armor_profile_to_k8s(profile); + } + container +} + +fn effective_sidecar_proxy_uid(params: &SandboxPodParams<'_>) -> u32 { + if params.process_binary_aware_network_policy { + BINARY_AWARE_SIDECAR_PROXY_UID + } else { + params.proxy_uid + } +} + +fn supervisor_network_init_container(params: &SandboxPodParams<'_>) -> serde_json::Value { + let proxy_uid = effective_sidecar_proxy_uid(params); + let mut container = serde_json::json!({ + "name": SUPERVISOR_NETWORK_INIT_CONTAINER_NAME, + "image": params.supervisor_image, + "command": [ + SUPERVISOR_IMAGE_BINARY_PATH, + "--mode=network-init", + "--proxy-uid", + proxy_uid.to_string(), + "--proxy-gid", + params.sandbox_gid.to_string(), + "--sidecar-state-dir", + SIDECAR_STATE_MOUNT_PATH, + "--sidecar-tls-dir", + SIDECAR_TLS_MOUNT_PATH, + ], + "securityContext": { + "runAsUser": 0, + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"], + "add": ["NET_ADMIN", "NET_RAW", "CHOWN", "FOWNER"] + } + }, + "volumeMounts": [ + sidecar_state_volume_mount(), + sidecar_tls_volume_mount(), + ] + }); + if !params.supervisor_image_pull_policy.is_empty() { + container["imagePullPolicy"] = serde_json::json!(params.supervisor_image_pull_policy); + } + if !params.client_tls_secret_name.is_empty() { + container["volumeMounts"] + .as_array_mut() + .expect("volumeMounts is an array") + .push(serde_json::json!({ + "name": "openshell-client-tls", + "mountPath": "/etc/openshell-tls/client", + "readOnly": true + })); + } + if let Some(profile) = params.app_armor_profile { + container["securityContext"]["appArmorProfile"] = app_armor_profile_to_k8s(profile); + } + container +} + +fn apply_supervisor_sidecar_topology( + pod_template: &mut serde_json::Value, + template_environment: &std::collections::HashMap, + spec_environment: &std::collections::HashMap, + params: &SandboxPodParams<'_>, +) { + let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else { + return; + }; + + let pod_security_context = spec + .entry("securityContext") + .or_insert_with(|| serde_json::json!({})); + if let Some(sc) = pod_security_context.as_object_mut() { + sc.insert("fsGroup".to_string(), serde_json::json!(params.sandbox_gid)); + } + + spec.insert("shareProcessNamespace".to_string(), serde_json::json!(true)); + + apply_supervisor_binary_source( + spec, + params.supervisor_image, + params.supervisor_image_pull_policy, + params.supervisor_sideload_method, + ); + + let volumes = spec + .entry("volumes") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut(); + if let Some(volumes) = volumes { + volumes.push(serde_json::json!({ + "name": SIDECAR_STATE_VOLUME_NAME, + "emptyDir": {} + })); + volumes.push(serde_json::json!({ + "name": SIDECAR_TLS_VOLUME_NAME, + "emptyDir": {} + })); + } + + let init_containers = spec + .entry("initContainers") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut(); + if let Some(init_containers) = init_containers { + init_containers.push(supervisor_network_init_container(params)); + } + + let Some(containers) = spec.get_mut("containers").and_then(|v| v.as_array_mut()) else { + return; + }; + + let target_index = containers + .iter() + .position(|c| c.get("name").and_then(|v| v.as_str()) == Some("agent")) + .unwrap_or(0); + + if let Some(container) = containers + .get_mut(target_index) + .and_then(|v| v.as_object_mut()) + { + container.insert( + "command".to_string(), + serde_json::json!([ + format!("{}/openshell-sandbox", SUPERVISOR_MOUNT_PATH), + "--mode=process" + ]), + ); + + let security_context = container + .entry("securityContext") + .or_insert_with(|| serde_json::json!({})); + if let Some(sc) = security_context.as_object_mut() { + sc.insert( + "runAsUser".to_string(), + serde_json::json!(params.sandbox_uid), + ); + sc.insert( + "runAsGroup".to_string(), + serde_json::json!(params.sandbox_gid), + ); + sc.insert("runAsNonRoot".to_string(), serde_json::json!(true)); + sc.insert( + "allowPrivilegeEscalation".to_string(), + serde_json::json!(false), + ); + sc.insert( + "capabilities".to_string(), + serde_json::json!({ + "drop": ["ALL"] + }), + ); + } + + let volume_mounts = container + .entry("volumeMounts") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut(); + if let Some(volume_mounts) = volume_mounts { + remove_volume_mount(volume_mounts, "openshell-sa-token"); + remove_volume_mount(volume_mounts, "openshell-client-tls"); + remove_volume_mount(volume_mounts, SPIFFE_WORKLOAD_API_VOLUME_NAME); + volume_mounts.push(supervisor_volume_mount()); + volume_mounts.push(sidecar_state_volume_mount()); + volume_mounts.push(sidecar_tls_volume_mount()); + } + + let env = container + .entry("env") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut(); + if let Some(env) = env { + remove_env(env, openshell_core::sandbox_env::ENDPOINT); + remove_env(env, openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); + remove_env(env, openshell_core::sandbox_env::TLS_CA); + remove_env(env, openshell_core::sandbox_env::TLS_CERT); + remove_env(env, openshell_core::sandbox_env::TLS_KEY); + remove_env(env, openshell_core::sandbox_env::SANDBOX_TOKEN); + remove_env(env, openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); + remove_env(env, openshell_core::sandbox_env::K8S_SA_TOKEN_FILE); + remove_env( + env, + openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, + ); + upsert_env( + env, + openshell_core::sandbox_env::SUPERVISOR_TOPOLOGY, + "sidecar", + ); + upsert_env( + env, + openshell_core::sandbox_env::NETWORK_ENFORCEMENT_MODE, + "sidecar-nftables", + ); + upsert_env( + env, + openshell_core::sandbox_env::SSH_SOCKET_PATH, + SIDECAR_SSH_SOCKET_FILE, + ); + upsert_env( + env, + openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET, + SIDECAR_CONTROL_SOCKET, + ); + upsert_env( + env, + openshell_core::sandbox_env::PROXY_TLS_DIR, + SIDECAR_TLS_MOUNT_PATH, + ); + upsert_env( + env, + openshell_core::sandbox_env::SANDBOX_UID, + ¶ms.sandbox_uid.to_string(), + ); + upsert_env( + env, + openshell_core::sandbox_env::SANDBOX_GID, + ¶ms.sandbox_gid.to_string(), + ); + } + } + + containers.push(supervisor_sidecar_container( + template_environment, + spec_environment, + params, + )); +} + /// Apply workspace persistence transforms to an already-built pod template. /// /// This injects: @@ -1242,6 +1679,7 @@ fn apply_supervisor_sideload( /// The init container mounts the PVC at a temporary path so it can still see /// the image's `/sandbox` directory. It checks for a sentinel file and skips /// the copy if the PVC was already initialised. +#[allow(clippy::similar_names)] fn apply_workspace_persistence( pod_template: &mut serde_json::Value, image: &str, @@ -1301,6 +1739,10 @@ fn apply_workspace_persistence( // self-referential symlinks under `/sandbox/.uv`, and GNU cp can // fail while seeding the PVC even though preserving the symlink as-is // is valid. `tar` copies the tree without dereferencing those links. + // Archive only the contents, not the `/sandbox` directory entry + // itself, so extraction never tries to chmod the PVC mount root. + // Extract without restoring owner, mode, or timestamps so the + // non-root init container can seed kubelet-owned PVCs. // // The inner `[ -d ... ]` guard handles custom images that don't have // a /sandbox directory — the copy is skipped but the sentinel is @@ -1308,7 +1750,12 @@ fn apply_workspace_persistence( let copy_cmd = format!( "if [ ! -f {WORKSPACE_INIT_MOUNT_PATH}/{WORKSPACE_SENTINEL} ]; then \ if [ -d {WORKSPACE_MOUNT_PATH} ]; then \ - tar -C {WORKSPACE_MOUNT_PATH} -cf - . | tar -C {WORKSPACE_INIT_MOUNT_PATH} -xpf -; \ + tmp=$(mktemp) && rm -f \"$tmp\" && \ + (cd {WORKSPACE_MOUNT_PATH} && find . -mindepth 1 -maxdepth 1 -exec tar -cf \"$tmp\" {{}} +) && \ + if [ -f \"$tmp\" ]; then \ + tar -C {WORKSPACE_INIT_MOUNT_PATH} --no-same-owner --no-same-permissions --touch -xf \"$tmp\" && \ + rm -f \"$tmp\"; \ + fi; \ fi && \ touch {WORKSPACE_INIT_MOUNT_PATH}/{WORKSPACE_SENTINEL}; \ fi" @@ -1366,6 +1813,9 @@ struct SandboxPodParams<'a> { supervisor_image: &'a str, supervisor_image_pull_policy: &'a str, supervisor_sideload_method: SupervisorSideloadMethod, + topology: SupervisorTopology, + proxy_uid: u32, + process_binary_aware_network_policy: bool, service_account_name: &'a str, sandbox_id: &'a str, sandbox_name: &'a str, @@ -1397,6 +1847,9 @@ impl Default for SandboxPodParams<'_> { supervisor_image: "", supervisor_image_pull_policy: "", supervisor_sideload_method: SupervisorSideloadMethod::default(), + topology: SupervisorTopology::default(), + proxy_uid: DEFAULT_PROXY_UID, + process_binary_aware_network_policy: true, service_account_name: DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, sandbox_id: "", sandbox_name: "", @@ -1417,6 +1870,18 @@ impl Default for SandboxPodParams<'_> { } } +fn validate_sidecar_proxy_identity( + params: &SandboxPodParams<'_>, +) -> Result<(), KubernetesDriverError> { + if params.topology == SupervisorTopology::Sidecar && params.proxy_uid == params.sandbox_uid { + return Err(KubernetesDriverError::Precondition(format!( + "proxy_uid ({}) must not match sandbox_uid ({}) in sidecar topology", + params.proxy_uid, params.sandbox_uid + ))); + } + Ok(()) +} + fn spec_pod_env(spec: Option<&SandboxSpec>) -> std::collections::HashMap { let mut env = spec.map_or_else(Default::default, |s| s.environment.clone()); if let Some(s) = spec.filter(|s| !s.log_level.is_empty()) { @@ -1707,13 +2172,22 @@ fn sandbox_template_to_k8s_with_gpu_requirements( serde_json::Value::Array(vec![serde_json::Value::Object(container)]), ); - // Add TLS secret volume. Mode 0400 (owner-read) prevents the - // unprivileged sandbox user from reading the mTLS private key. + // Add TLS secret volume. Combined mode uses mode 0400 because the + // supervisor starts as root and drops privileges before running workload + // children. Sidecar mode keeps the process supervisor non-root, so it uses + // pod fsGroup + 0440 to preserve gateway session and SSH control behavior. let mut volumes: Vec = Vec::new(); if !params.client_tls_secret_name.is_empty() { + let client_tls_default_mode = match params.topology { + SupervisorTopology::Combined => 0o400, + SupervisorTopology::Sidecar => 0o440, + }; volumes.push(serde_json::json!({ "name": "openshell-client-tls", - "secret": { "secretName": params.client_tls_secret_name, "defaultMode": 256 } + "secret": { + "secretName": params.client_tls_secret_name, + "defaultMode": client_tls_default_mode + } })); } if params.provider_spiffe_enabled { @@ -1728,7 +2202,12 @@ fn sandbox_template_to_k8s_with_gpu_requirements( // Projected ServiceAccountToken volume — kubelet writes a short-lived // audience-bound JWT into /var/run/secrets/openshell/token and rotates // it automatically. The supervisor exchanges this for a gateway-minted - // JWT via `IssueSandboxToken` once at startup. + // JWT via `IssueSandboxToken` once at startup. In sidecar topology both + // supervisor containers run with the sandbox GID and need group-read access. + let sa_token_default_mode = match params.topology { + SupervisorTopology::Combined => 0o400, + SupervisorTopology::Sidecar => 0o440, + }; volumes.push(serde_json::json!({ "name": "openshell-sa-token", "projected": { @@ -1739,7 +2218,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( "path": "token" } }], - "defaultMode": 256 + "defaultMode": sa_token_default_mode } })); spec.insert("volumes".to_string(), serde_json::Value::Array(volumes)); @@ -1763,14 +2242,26 @@ fn sandbox_template_to_k8s_with_gpu_requirements( let mut result = serde_json::Value::Object(template_value); - apply_supervisor_sideload( - &mut result, - params.supervisor_image, - params.supervisor_image_pull_policy, - params.supervisor_sideload_method, - params.sandbox_uid, - params.sandbox_gid, - ); + match params.topology { + SupervisorTopology::Combined => { + apply_supervisor_sideload( + &mut result, + params.supervisor_image, + params.supervisor_image_pull_policy, + params.supervisor_sideload_method, + params.sandbox_uid, + params.sandbox_gid, + ); + } + SupervisorTopology::Sidecar => { + apply_supervisor_sidecar_topology( + &mut result, + &template.environment, + spec_environment, + params, + ); + } + } // Inject workspace persistence (init container + PVC volume mount) so // that /sandbox data survives pod rescheduling. Skipped when the user @@ -2088,6 +2579,14 @@ fn upsert_env(env: &mut Vec, name: &str, value: &str) { env.push(serde_json::json!({"name": name, "value": value})); } +fn remove_env(env: &mut Vec, name: &str) { + env.retain(|item| item.get("name").and_then(|value| value.as_str()) != Some(name)); +} + +fn remove_volume_mount(volume_mounts: &mut Vec, name: &str) { + volume_mounts.retain(|mount| mount.get("name").and_then(|value| value.as_str()) != Some(name)); +} + /// Extract a string value from the template's `platform_config` Struct. fn platform_config_string(template: &SandboxTemplate, key: &str) -> Option { let config = template.platform_config.as_ref()?; @@ -2262,6 +2761,15 @@ mod tests { assert!(!should_try_next_sandbox_api_version(&err)); } + fn rendered_env<'a>(container: &'a serde_json::Value, name: &str) -> Option<&'a str> { + container["env"] + .as_array()? + .iter() + .find(|item| item.get("name").and_then(|value| value.as_str()) == Some(name))? + .get("value")? + .as_str() + } + #[test] fn driver_config_rejects_invalid_shape() { let template = SandboxTemplate { @@ -2601,6 +3109,385 @@ mod tests { ); } + #[test] + fn sidecar_topology_renders_process_agent_and_network_sidecar() { + let params = SandboxPodParams { + topology: SupervisorTopology::Sidecar, + supervisor_sideload_method: SupervisorSideloadMethod::InitContainer, + supervisor_image: "supervisor-image:latest", + supervisor_image_pull_policy: "IfNotPresent", + grpc_endpoint: "https://openshell-gateway.openshell.svc:8080", + client_tls_secret_name: "openshell-client-tls", + proxy_uid: 2200, + sandbox_uid: 1500, + sandbox_gid: 1500, + ..SandboxPodParams::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate { + image: "agent-image:latest".to_string(), + ..SandboxTemplate::default() + }, + false, + &std::collections::HashMap::new(), + false, + ¶ms, + ); + + assert_eq!(pod_template["spec"]["shareProcessNamespace"], true); + assert_eq!(pod_template["spec"]["securityContext"]["fsGroup"], 1500); + let containers = pod_template["spec"]["containers"].as_array().unwrap(); + assert_eq!(containers.len(), 2); + + let agent = containers + .iter() + .find(|container| container["name"] == "agent") + .unwrap(); + assert_eq!( + agent["command"], + serde_json::json!([ + format!("{SUPERVISOR_MOUNT_PATH}/openshell-sandbox"), + "--mode=process" + ]) + ); + assert_eq!(agent["securityContext"]["runAsUser"], 1500); + assert_eq!(agent["securityContext"]["runAsGroup"], 1500); + assert_eq!(agent["securityContext"]["runAsNonRoot"], true); + assert_eq!(agent["securityContext"]["allowPrivilegeEscalation"], false); + assert_eq!( + agent["securityContext"]["capabilities"], + serde_json::json!({ + "drop": ["ALL"] + }) + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::ENDPOINT), + None + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME), + None + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::TLS_CA), + None + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::K8S_SA_TOKEN_FILE), + None + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::SSH_SOCKET_PATH), + Some(SIDECAR_SSH_SOCKET_FILE) + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET), + Some(SIDECAR_CONTROL_SOCKET) + ); + assert_eq!(rendered_env(agent, "OPENSHELL_SUPERVISOR_READY_FILE"), None); + assert_eq!(rendered_env(agent, "OPENSHELL_ENTRYPOINT_PID_FILE"), None); + assert_eq!( + rendered_env(agent, "OPENSHELL_SIDECAR_POLICY_SNAPSHOT_FILE"), + None + ); + assert_eq!( + rendered_env(agent, "OPENSHELL_SIDECAR_PROVIDER_ENV_SNAPSHOT_FILE"), + None + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::PROXY_TLS_DIR), + Some(SIDECAR_TLS_MOUNT_PATH) + ); + assert_eq!( + rendered_env(agent, openshell_core::sandbox_env::SANDBOX_UID), + Some("1500") + ); + + let sidecar = containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_SIDECAR_NAME) + .unwrap(); + assert_eq!(sidecar["image"], "supervisor-image:latest"); + assert_eq!(sidecar["imagePullPolicy"], "IfNotPresent"); + assert_eq!( + sidecar["command"], + serde_json::json!([SUPERVISOR_IMAGE_BINARY_PATH, "--mode=network"]) + ); + assert_eq!(sidecar["securityContext"]["runAsUser"], 0); + assert_eq!(sidecar["securityContext"]["runAsGroup"], 1500); + assert_eq!(sidecar["securityContext"]["runAsNonRoot"], false); + assert_eq!( + sidecar["securityContext"]["allowPrivilegeEscalation"], + false + ); + assert_eq!( + sidecar["securityContext"]["capabilities"], + serde_json::json!({ + "drop": ["ALL"], + "add": ["SYS_PTRACE", "DAC_READ_SEARCH"] + }) + ); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::ENDPOINT), + Some("https://openshell-gateway.openshell.svc:8080") + ); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::SSH_SOCKET_PATH), + Some(SIDECAR_SSH_SOCKET_FILE) + ); + assert!( + SIDECAR_SSH_SOCKET_FILE.starts_with('@'), + "sidecar SSH relay must use a Linux abstract socket" + ); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::SANDBOX_UID), + Some("1500") + ); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::SANDBOX_GID), + Some("1500") + ); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET), + Some(SIDECAR_CONTROL_SOCKET) + ); + assert_eq!( + rendered_env(sidecar, "OPENSHELL_SIDECAR_POLICY_SNAPSHOT_FILE"), + None + ); + assert_eq!( + rendered_env(sidecar, "OPENSHELL_SIDECAR_PROVIDER_ENV_SNAPSHOT_FILE"), + None + ); + assert_eq!( + rendered_env( + sidecar, + openshell_core::sandbox_env::NETWORK_BINARY_IDENTITY + ), + None + ); + assert_eq!(rendered_env(sidecar, "OPENSHELL_ENTRYPOINT_PID_FILE"), None); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::PROXY_TLS_DIR), + Some(SIDECAR_TLS_MOUNT_PATH) + ); + assert_eq!( + rendered_env(sidecar, openshell_core::sandbox_env::TLS_CA), + Some("/etc/openshell-tls/proxy/client/ca.crt") + ); + let sidecar_mounts = sidecar["volumeMounts"].as_array().unwrap(); + assert!( + !sidecar_mounts + .iter() + .any(|mount| mount["name"] == "openshell-client-tls"), + "runtime sidecar should use the init-copied TLS files, not the root-owned Secret mount" + ); + let agent_mounts = agent["volumeMounts"].as_array().unwrap(); + assert!( + !agent_mounts + .iter() + .any(|mount| mount["name"] == "openshell-sa-token"), + "agent container must not mount gateway bootstrap token in sidecar topology" + ); + assert!( + !agent_mounts + .iter() + .any(|mount| mount["name"] == "openshell-client-tls"), + "agent container must not mount gateway client TLS secret in sidecar topology" + ); + let volumes = pod_template["spec"]["volumes"].as_array().unwrap(); + let sa_token = volumes + .iter() + .find(|volume| volume["name"] == "openshell-sa-token") + .unwrap(); + assert_eq!(sa_token["projected"]["defaultMode"], 0o440); + let client_tls = volumes + .iter() + .find(|volume| volume["name"] == "openshell-client-tls") + .unwrap(); + assert_eq!(client_tls["secret"]["defaultMode"], 0o440); + + let init_containers = pod_template["spec"]["initContainers"].as_array().unwrap(); + let network_init = init_containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_INIT_CONTAINER_NAME) + .unwrap(); + assert_eq!(network_init["image"], "supervisor-image:latest"); + assert_eq!(network_init["imagePullPolicy"], "IfNotPresent"); + assert_eq!( + network_init["command"], + serde_json::json!([ + SUPERVISOR_IMAGE_BINARY_PATH, + "--mode=network-init", + "--proxy-uid", + "0", + "--proxy-gid", + "1500", + "--sidecar-state-dir", + SIDECAR_STATE_MOUNT_PATH, + "--sidecar-tls-dir", + SIDECAR_TLS_MOUNT_PATH + ]) + ); + assert_eq!( + network_init["securityContext"]["capabilities"], + serde_json::json!({ + "drop": ["ALL"], + "add": ["NET_ADMIN", "NET_RAW", "CHOWN", "FOWNER"] + }) + ); + let network_init_mounts = network_init["volumeMounts"].as_array().unwrap(); + assert!(network_init_mounts.iter().any(|mount| { + mount["name"] == "openshell-client-tls" + && mount["mountPath"] == "/etc/openshell-tls/client" + })); + } + + #[test] + fn sidecar_topology_can_relax_process_binary_aware_network_policy() { + let params = SandboxPodParams { + topology: SupervisorTopology::Sidecar, + supervisor_sideload_method: SupervisorSideloadMethod::InitContainer, + supervisor_image: "supervisor-image:latest", + proxy_uid: 2200, + sandbox_uid: 1500, + sandbox_gid: 1500, + process_binary_aware_network_policy: false, + ..SandboxPodParams::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate { + image: "agent-image:latest".to_string(), + ..SandboxTemplate::default() + }, + false, + &std::collections::HashMap::new(), + false, + ¶ms, + ); + + let containers = pod_template["spec"]["containers"].as_array().unwrap(); + let sidecar = containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_SIDECAR_NAME) + .unwrap(); + assert_eq!(sidecar["securityContext"]["runAsUser"], 2200); + assert_eq!(sidecar["securityContext"]["runAsGroup"], 1500); + assert_eq!(sidecar["securityContext"]["runAsNonRoot"], true); + assert_eq!( + sidecar["securityContext"]["allowPrivilegeEscalation"], + false + ); + assert_eq!( + sidecar["securityContext"]["capabilities"], + serde_json::json!({ + "drop": ["ALL"] + }) + ); + assert_eq!( + rendered_env( + sidecar, + openshell_core::sandbox_env::NETWORK_BINARY_IDENTITY + ), + Some("relaxed") + ); + let init_containers = pod_template["spec"]["initContainers"].as_array().unwrap(); + let network_init = init_containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_INIT_CONTAINER_NAME) + .unwrap(); + assert_eq!(network_init["command"][3], "2200"); + } + + #[test] + fn sidecar_topology_adds_shared_state_and_tls_volumes() { + let params = SandboxPodParams { + topology: SupervisorTopology::Sidecar, + supervisor_sideload_method: SupervisorSideloadMethod::ImageVolume, + supervisor_image: "supervisor-image:latest", + grpc_endpoint: "http://openshell-gateway.openshell.svc:8080", + ..SandboxPodParams::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + false, + ¶ms, + ); + + let volumes = pod_template["spec"]["volumes"].as_array().unwrap(); + assert!( + volumes + .iter() + .any(|volume| volume["name"] == SIDECAR_STATE_VOLUME_NAME) + ); + assert!( + volumes + .iter() + .any(|volume| volume["name"] == SIDECAR_TLS_VOLUME_NAME) + ); + assert!(volumes.iter().any(|volume| { + volume["name"] == SUPERVISOR_VOLUME_NAME && volume["image"].is_object() + })); + + let containers = pod_template["spec"]["containers"].as_array().unwrap(); + let sidecar = containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_SIDECAR_NAME) + .unwrap(); + assert_eq!( + sidecar["securityContext"]["capabilities"], + serde_json::json!({ + "drop": ["ALL"], + "add": ["SYS_PTRACE", "DAC_READ_SEARCH"] + }) + ); + assert_eq!(sidecar["securityContext"]["runAsUser"], 0); + assert_eq!(sidecar["securityContext"]["runAsGroup"], 1000); + assert_eq!(sidecar["securityContext"]["runAsNonRoot"], false); + assert_eq!( + sidecar["securityContext"]["allowPrivilegeEscalation"], + false + ); + + for container_name in ["agent", SUPERVISOR_NETWORK_SIDECAR_NAME] { + let container = containers + .iter() + .find(|container| container["name"] == container_name) + .unwrap(); + let mounts = container["volumeMounts"].as_array().unwrap(); + assert!(mounts.iter().any(|mount| { + mount["name"] == SIDECAR_STATE_VOLUME_NAME + && mount["mountPath"] == SIDECAR_STATE_MOUNT_PATH + })); + assert!(mounts.iter().any(|mount| { + mount["name"] == SIDECAR_TLS_VOLUME_NAME + && mount["mountPath"] == SIDECAR_TLS_MOUNT_PATH + })); + } + let init_containers = pod_template["spec"]["initContainers"].as_array().unwrap(); + let network_init = init_containers + .iter() + .find(|container| container["name"] == SUPERVISOR_NETWORK_INIT_CONTAINER_NAME) + .unwrap(); + assert_eq!(network_init["command"][3], "0"); + } + + #[test] + fn sidecar_topology_rejects_proxy_uid_matching_sandbox_uid() { + let params = SandboxPodParams { + topology: SupervisorTopology::Sidecar, + proxy_uid: 1500, + sandbox_uid: 1500, + ..SandboxPodParams::default() + }; + + let err = validate_sidecar_proxy_identity(¶ms).unwrap_err(); + assert!(matches!(err, KubernetesDriverError::Precondition(_))); + assert!(err.to_string().contains("proxy_uid")); + } + /// Regression test: TLS mount path must match env var paths. /// The volume is mounted at a specific path and the env vars must point to /// files within that same path, otherwise the sandbox will fail to start @@ -3179,6 +4066,16 @@ mod tests { script.contains("tar -C"), "init script must seed image contents with a tar stream" ); + assert!( + script.contains("find . -mindepth 1 -maxdepth 1"), + "init script must archive sandbox contents without the mount root entry" + ); + assert!( + script.contains("--no-same-owner") + && script.contains("--no-same-permissions") + && script.contains("--touch"), + "init script must avoid restoring metadata onto the PVC root" + ); } #[test] diff --git a/crates/openshell-driver-kubernetes/src/lib.rs b/crates/openshell-driver-kubernetes/src/lib.rs index 953ed4abdf..7c56c8de5b 100644 --- a/crates/openshell-driver-kubernetes/src/lib.rs +++ b/crates/openshell-driver-kubernetes/src/lib.rs @@ -6,8 +6,9 @@ pub mod driver; pub mod grpc; pub use config::{ - AppArmorProfile, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, DEFAULT_WORKSPACE_STORAGE_SIZE, - KubernetesComputeConfig, SupervisorSideloadMethod, SupervisorTopology, + AppArmorProfile, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, + DEFAULT_WORKSPACE_STORAGE_SIZE, KubernetesComputeConfig, KubernetesSidecarConfig, + SupervisorSideloadMethod, SupervisorTopology, }; pub use driver::{KubernetesComputeDriver, KubernetesDriverError}; pub use grpc::ComputeDriverService; diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index a300fe6a07..c733b8a45b 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use clap::Parser; +use clap::{ArgAction, Parser}; use miette::{IntoDiagnostic, Result}; use std::net::SocketAddr; use tracing::info; @@ -10,8 +10,9 @@ use tracing_subscriber::EnvFilter; use openshell_core::VERSION; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; use openshell_driver_kubernetes::{ - AppArmorProfile, ComputeDriverService, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, - KubernetesComputeConfig, KubernetesComputeDriver, SupervisorSideloadMethod, SupervisorTopology, + AppArmorProfile, ComputeDriverService, DEFAULT_PROXY_UID, DEFAULT_SANDBOX_SERVICE_ACCOUNT_NAME, + KubernetesComputeConfig, KubernetesComputeDriver, KubernetesSidecarConfig, + SupervisorSideloadMethod, SupervisorTopology, }; #[derive(Parser, Debug)] @@ -80,12 +81,24 @@ struct Args { )] supervisor_sideload_method: SupervisorSideloadMethod, + #[arg(long, env = "OPENSHELL_K8S_TOPOLOGY", default_value = "combined")] + topology: SupervisorTopology, + #[arg( - long, - env = "OPENSHELL_SUPERVISOR_TOPOLOGY", - default_value = "combined" + long = "sidecar-proxy-uid", + alias = "proxy-uid", + env = "OPENSHELL_K8S_SIDECAR_PROXY_UID", + default_value_t = DEFAULT_PROXY_UID + )] + sidecar_proxy_uid: u32, + + #[arg( + long = "sidecar-process-binary-aware-network-policy", + env = "OPENSHELL_K8S_SIDECAR_PROCESS_BINARY_AWARE_NETWORK_POLICY", + default_value_t = true, + action = ArgAction::Set )] - supervisor_topology: SupervisorTopology, + sidecar_process_binary_aware_network_policy: bool, #[arg(long, env = "OPENSHELL_ENABLE_USER_NAMESPACES")] enable_user_namespaces: bool, @@ -130,7 +143,11 @@ async fn main() -> Result<()> { .unwrap_or_else(openshell_core::config::default_supervisor_image), supervisor_image_pull_policy: args.supervisor_image_pull_policy.unwrap_or_default(), supervisor_sideload_method: args.supervisor_sideload_method, - supervisor_topology: args.supervisor_topology, + topology: args.topology, + sidecar: KubernetesSidecarConfig { + proxy_uid: args.sidecar_proxy_uid, + process_binary_aware_network_policy: args.sidecar_process_binary_aware_network_policy, + }, grpc_endpoint: args.grpc_endpoint.unwrap_or_default(), ssh_socket_path: args.sandbox_ssh_socket_path, client_tls_secret_name: args.client_tls_secret_name.unwrap_or_default(), diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 54501cb8c9..c6a619f3d1 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -130,8 +130,8 @@ sequenceDiagram C->>C: entrypoint: /opt/openshell/bin/openshell-sandbox ``` -The supervisor image from `deploy/docker/Dockerfile.supervisor` copies the static -`openshell-sandbox` binary to `/openshell-sandbox`. +The supervisor image from `deploy/docker/Dockerfile.supervisor` provides the +static `openshell-sandbox` binary at `/openshell-sandbox`. Mounting that image at `/opt/openshell/bin` makes the binary available as `/opt/openshell/bin/openshell-sandbox`. diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 5eb9a5c873..1401f9e7c6 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -888,9 +888,8 @@ pub fn build_container_spec_with_token_and_gpu_devices( // Side-load the supervisor binary from a standalone OCI image. // Podman resolves image_volumes at the libpod layer, mounting the // image's filesystem at the destination path without starting a - // container from it. The supervisor image is FROM scratch with just - // the binary at /openshell-sandbox, so it appears at - // /opt/openshell/bin/openshell-sandbox. + // container from it. The supervisor image exposes the binary at + // /openshell-sandbox, so it appears at /opt/openshell/bin/openshell-sandbox. image_volumes, hostname: format!("sandbox-{}", sandbox.name), // Override the image's ENTRYPOINT so the supervisor binary runs diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 086dbe02c3..0d7ff33925 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -33,11 +33,16 @@ clap = { workspace = true } # Error handling miette = { workspace = true } +# Unix ownership for Kubernetes sidecar init setup +nix = { workspace = true } + # TLS crypto provider install (main.rs) rustls = { workspace = true } # Serialization (serde_json::json! for OCSF unmapped fields) +serde = { workspace = true } serde_json = { workspace = true } +prost = { workspace = true } # Logging tracing = { workspace = true } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index cf193c63ce..7a1085f1bf 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -12,8 +12,9 @@ mod google_cloud_metadata; mod mechanistic_mapper; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod metadata_server; +mod sidecar_control; -use miette::Result; +use miette::{IntoDiagnostic, Result, WrapErr}; use std::future::Future; use std::sync::Arc; use std::sync::atomic::AtomicU32; @@ -64,12 +65,20 @@ use openshell_core::denial::DenialEvent; use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_supervisor_network::opa::OpaEngine; +use openshell_supervisor_process::process::ProcessEnforcementMode; pub use openshell_supervisor_process::process::{ProcessHandle, ProcessStatus}; use openshell_supervisor_process::skills; use tokio::sync::mpsc::UnboundedSender; -#[cfg(target_os = "linux")] +#[cfg(any(test, target_os = "linux"))] use tokio::time::timeout; +const SIDECAR_NETWORK_ENFORCEMENT_MODE: &str = "sidecar-nftables"; +const SIDECAR_TLS_DIR: &str = "/etc/openshell-tls/proxy"; +const SIDECAR_CA_CERT: &str = "openshell-ca.pem"; +const SIDECAR_CA_BUNDLE: &str = "ca-bundle.pem"; +const SIDECAR_PROCESS_PROXY_ADDR: &str = "127.0.0.1:3128"; +const SIDECAR_READY_TIMEOUT_SECS: u64 = 120; + /// Run a command in the sandbox. /// /// # Errors @@ -125,17 +134,45 @@ pub async fn run_sandbox( } } + let sidecar_network_enforcement = sidecar_network_enforcement_enabled(); + let process_enforcement_mode = process_enforcement_mode(); + let process_uses_sidecar_control = + process_enabled && !network_enabled && sidecar_network_enforcement; + let mut process_control_connection = None; + let sidecar_bootstrap = if process_uses_sidecar_control { + let socket = sidecar_control_socket().ok_or_else(|| { + miette::miette!( + "{} is required for process-only sidecar topology", + openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET + ) + })?; + let (bootstrap, connection) = sidecar_control::connect_process_client( + &socket, + Duration::from_secs(SIDECAR_READY_TIMEOUT_SECS), + ) + .await?; + process_control_connection = Some(connection); + Some(bootstrap) + } else { + None + }; + // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let (mut policy, opa_engine, retained_proto, loaded_policy_origin) = load_policy( - sandbox_id.clone(), - sandbox, - openshell_endpoint.clone(), - policy_rules, - policy_data, - ) - .await?; + let (mut policy, opa_engine, retained_proto, loaded_policy_origin) = + if let Some(bootstrap) = sidecar_bootstrap.as_ref() { + load_policy_from_sidecar_bootstrap(bootstrap)? + } else { + load_policy( + sandbox_id.clone(), + sandbox, + openshell_endpoint.clone(), + policy_rules, + policy_data, + ) + .await? + }; // Override the policy's process identity with the driver-resolved UID/GID // from the pod environment. The policy defaults to the name "sandbox" which @@ -170,71 +207,89 @@ pub async fn run_sandbox( policy.process.run_as_group = Some(gid); } - // Fetch provider environment variables from the server. - // This is done after loading the policy so the sandbox can still start - // even if provider env fetch fails (graceful degradation). - let ( - provider_env_revision, - provider_env, - provider_credential_expires_at_ms, - dynamic_credentials, - ) = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { - match openshell_core::grpc_client::fetch_provider_environment(endpoint, id).await { - Ok(result) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .message(format!( - "Fetched provider environment [env_count:{}]", - result.environment.len() - )) - .build() - ); - ( - result.provider_env_revision, - result.environment, - result.credential_expires_at_ms, - result.dynamic_credentials, - ) - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .state(StateId::Other, "degraded") - .message(format!( - "Failed to fetch provider environment, continuing without: {e}" - )) - .build() - ); + #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] + let (provider_credentials, mut provider_env) = + if let Some(bootstrap) = sidecar_bootstrap.as_ref() { + let provider_credentials = ProviderCredentialState::from_child_env_snapshot( + bootstrap.provider_env_revision, + bootstrap.provider_child_env.clone(), + ); + (provider_credentials, bootstrap.provider_child_env.clone()) + } else { + // Fetch provider environment variables from the server. + // This is done after loading the policy so the sandbox can still start + // even if provider env fetch fails (graceful degradation). + let ( + provider_env_revision, + provider_env, + provider_credential_expires_at_ms, + dynamic_credentials, + ) = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { + match openshell_core::grpc_client::fetch_provider_environment(endpoint, id).await { + Ok(result) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .message(format!( + "Fetched provider environment [env_count:{}]", + result.environment.len() + )) + .build() + ); + ( + result.provider_env_revision, + result.environment, + result.credential_expires_at_ms, + result.dynamic_credentials, + ) + } + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Other, "degraded") + .message(format!( + "Failed to fetch provider environment, continuing without: {e}" + )) + .build() + ); + ( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ) + } + } + } else { ( 0, std::collections::HashMap::new(), std::collections::HashMap::new(), std::collections::HashMap::new(), ) - } - } - } else { - ( - 0, - std::collections::HashMap::new(), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ) - }; + }; - let provider_credentials = ProviderCredentialState::from_environment( - provider_env_revision, - provider_env, - provider_credential_expires_at_ms, - dynamic_credentials, - ); - #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] - let mut provider_env = provider_credentials.child_env_with_gcp_resolved(); + let provider_credentials = ProviderCredentialState::from_environment( + provider_env_revision, + provider_env, + provider_credential_expires_at_ms, + dynamic_credentials, + ); + let provider_env = provider_credentials.child_env_with_gcp_resolved(); + (provider_credentials, provider_env) + }; + let process_control_writer = process_control_connection + .as_ref() + .map(|connection| connection.writer.clone()); + let mut process_control_closed = None; + if let Some(connection) = process_control_connection { + process_control_closed = Some(connection.closed); + spawn_sidecar_control_update_watcher(connection.updates, provider_credentials.clone()); + } // Initialize the agent-proposals feature flag. Default false until the // initial settings fetch (or the poll loop) tells us otherwise. The flag @@ -258,7 +313,7 @@ pub async fn run_sandbox( // it via setns(). The RAII handle lives in this frame for the duration // of the sandbox. #[cfg(target_os = "linux")] - let netns = if network_enabled { + let netns = if network_enabled && !sidecar_network_enforcement { openshell_supervisor_process::netns::create_netns_for_proxy(&policy)? } else { None @@ -328,6 +383,80 @@ pub async fn run_sandbox( None }; + #[cfg(target_os = "linux")] + let sidecar_control_server = if network_enabled && sidecar_network_enforcement { + if !matches!(policy.network.mode, NetworkMode::Proxy) { + return Err(miette::miette!( + "sidecar network enforcement requires proxy network mode" + )); + } + let socket = sidecar_control_socket().ok_or_else(|| { + miette::miette!( + "{} is required for sidecar topology", + openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET + ) + })?; + let proto = retained_proto.as_ref().ok_or_else(|| { + miette::miette!( + "sidecar topology requires gateway policy data for the process supervisor" + ) + })?; + let ca_paths = networking.as_ref().and_then(|n| n.ca_file_paths.clone()); + Some(sidecar_control::spawn_server( + &socket, + sidecar_control::BootstrapData { + policy_proto: proto.clone(), + provider_env_revision: provider_credentials.snapshot().revision, + provider_child_env: provider_env.clone(), + proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), + proxy_ca_bundle_path: ca_paths.as_ref().map(|paths| paths.1.clone()), + }, + sidecar_expected_peer()?, + )?) + } else { + None + }; + #[cfg(not(target_os = "linux"))] + let sidecar_control_server: Option = None; + + let sidecar_control_publisher = sidecar_control_server + .as_ref() + .map(sidecar_control::ServerHandle::publisher); + + #[cfg(target_os = "linux")] + let mut sidecar_control_task = None; + + #[cfg(target_os = "linux")] + if network_enabled + && sidecar_network_enforcement + && let Some(server) = sidecar_control_server + { + let trusted_ssh_socket_path = ssh_socket_path.clone().ok_or_else(|| { + miette::miette!( + "{} is required for sidecar network topology", + openshell_core::sandbox_env::SSH_SOCKET_PATH + ) + })?; + let (entrypoint_rx, connection_task) = server.into_runtime_parts(); + sidecar_control_task = Some(connection_task); + spawn_sidecar_entrypoint_handler( + entrypoint_rx, + entrypoint_pid.clone(), + opa_engine.clone(), + retained_proto.clone(), + openshell_endpoint.clone(), + sandbox_id.clone(), + std::path::PathBuf::from(trusted_ssh_socket_path), + ); + } + + #[cfg(not(target_os = "linux"))] + if network_enabled && sidecar_network_enforcement { + return Err(miette::miette!( + "sidecar network enforcement is only supported on Linux" + )); + } + // Spawn the denial-aggregator flush task. The aggregator drains denial // events from the proxy + bypass monitor, batches them, and ships // summaries to the gateway via `SubmitPolicyAnalysis`. @@ -398,11 +527,13 @@ pub async fn run_sandbox( } // Spawn background policy poll task (gRPC mode only). - if let (Some(id), Some(endpoint), Some(engine)) = ( - sandbox_id.as_deref(), - openshell_endpoint.as_deref(), - opa_engine.as_ref(), - ) { + if !process_uses_sidecar_control + && let (Some(id), Some(endpoint), Some(engine)) = ( + sandbox_id.as_deref(), + openshell_endpoint.as_deref(), + opa_engine.as_ref(), + ) + { let poll_id = id.to_string(); let poll_endpoint = endpoint.to_string(); let poll_engine = engine.clone(); @@ -424,6 +555,7 @@ pub async fn run_sandbox( ocsf_enabled: poll_ocsf_enabled, provider_credentials: poll_provider_credentials, policy_local_ctx: poll_policy_local, + sidecar_control_publisher: sidecar_control_publisher.clone(), }; tokio::spawn(async move { @@ -479,10 +611,51 @@ pub async fn run_sandbox( } } + let process_policy = process_policy_for_topology(&policy, sidecar_network_enforcement)?; + let sidecar_bootstrap_ca_file_paths = sidecar_bootstrap.as_ref().and_then(|bootstrap| { + bootstrap + .proxy_ca_cert_path + .clone() + .zip(bootstrap.proxy_ca_bundle_path.clone()) + }); + let exit_code = if process_enabled { - let ca_file_paths = networking.as_ref().and_then(|n| n.ca_file_paths.clone()); + let ca_file_paths = networking + .as_ref() + .and_then(|n| n.ca_file_paths.clone()) + .or_else(|| { + if sidecar_network_enforcement { + sidecar_bootstrap_ca_file_paths + .clone() + .or_else(sidecar_ca_file_paths) + } else { + None + } + }); + + let entrypoint_started_tx = + if process_uses_sidecar_control && let Some(writer) = process_control_writer.clone() { + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + match rx.await { + Ok(pid) => { + if let Err(err) = + sidecar_control::send_entrypoint_started(&writer, pid).await + { + warn!(error = %err, "Failed to send sidecar entrypoint event"); + } + } + Err(_closed) => { + debug!("Entrypoint exited before sidecar entrypoint event was sent"); + } + } + }); + Some(tx) + } else { + None + }; - openshell_supervisor_process::run::run_process( + let process = openshell_supervisor_process::run::run_process( program, args, workdir.as_deref(), @@ -491,8 +664,11 @@ pub async fn run_sandbox( sandbox_id.as_deref(), openshell_endpoint.as_deref(), ssh_socket_path, - &policy, + sidecar_network_enforcement, + &process_policy, + process_enforcement_mode, entrypoint_pid, + entrypoint_started_tx, provider_credentials, provider_env, ca_file_paths, @@ -502,14 +678,54 @@ pub async fn run_sandbox( bypass_denial_tx, #[cfg(target_os = "linux")] bypass_activity_tx, - ) - .await? + ); + + if let Some(control_closed) = process_control_closed.as_mut() { + tokio::select! { + result = process => result?, + _ = control_closed => { + ocsf_emit!( + AppLifecycleBuilder::new(ocsf_ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::High) + .status(StatusId::Failure) + .message( + "Authoritative network-sidecar control channel closed; terminating process container" + ) + .build() + ); + return Err(miette::miette!( + "authoritative network-sidecar control channel closed" + )); + } + } + } else { + process.await? + } } else { // Network-only sidecar mode: keep the proxy and its background - // tasks alive (held via the `networking` value) until SIGINT or - // SIGTERM. Exit 0 on clean shutdown. - wait_for_shutdown_signal().await; - 0 + // tasks alive (held via the `networking` value) until shutdown. If the + // sole authenticated process-supervisor control connection closes, + // exit non-zero so Kubernetes restarts the network sidecar and creates + // a fresh one-client bootstrap listener for the restarted agent. + #[cfg(target_os = "linux")] + if let Some(control_task) = sidecar_control_task { + tokio::select! { + () = wait_for_shutdown_signal() => 0, + result = control_task => { + warn!(?result, "Authoritative sidecar control channel exited; restarting sidecar"); + 1 + } + } + } else { + wait_for_shutdown_signal().await; + 0 + } + #[cfg(not(target_os = "linux"))] + { + wait_for_shutdown_signal().await; + 0 + } }; // Drop networking explicitly so the proxy + bypass monitor RAII @@ -552,6 +768,206 @@ async fn wait_for_shutdown_signal() { } } +fn sidecar_network_enforcement_enabled() -> bool { + std::env::var(openshell_core::sandbox_env::NETWORK_ENFORCEMENT_MODE) + .is_ok_and(|value| value == SIDECAR_NETWORK_ENFORCEMENT_MODE) +} + +fn process_enforcement_mode() -> ProcessEnforcementMode { + match std::env::var(openshell_core::sandbox_env::SUPERVISOR_TOPOLOGY) + .ok() + .as_deref() + { + Some("sidecar") => ProcessEnforcementMode::NetworkOnly, + _ => ProcessEnforcementMode::Full, + } +} + +fn sidecar_control_socket() -> Option { + std::env::var(openshell_core::sandbox_env::SIDECAR_CONTROL_SOCKET) + .ok() + .filter(|path| !path.is_empty()) + .map(std::path::PathBuf::from) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn sidecar_expected_peer() -> Result { + fn required_numeric_env(name: &str) -> Result { + let value = std::env::var(name) + .into_diagnostic() + .wrap_err_with(|| format!("{name} is required for sidecar control authentication"))?; + value.parse::().into_diagnostic().wrap_err_with(|| { + format!("{name} must be a numeric ID for sidecar control authentication") + }) + } + + Ok(sidecar_control::ExpectedPeer { + uid: required_numeric_env(openshell_core::sandbox_env::SANDBOX_UID)?, + gid: required_numeric_env(openshell_core::sandbox_env::SANDBOX_GID)?, + }) +} + +type LoadedPolicyBundle = ( + SandboxPolicy, + Option>, + Option, + LoadedPolicyOrigin, +); + +fn load_policy_from_sidecar_bootstrap( + bootstrap: &sidecar_control::BootstrapData, +) -> Result { + let proto = bootstrap.policy_proto.clone(); + let opa_engine = Some(Arc::new(OpaEngine::from_proto(&proto)?)); + let policy = SandboxPolicy::try_from(proto.clone())?; + info!("Loaded sidecar policy from control socket bootstrap"); + Ok(( + policy, + opa_engine, + Some(proto), + LoadedPolicyOrigin::Gateway { revision: None }, + )) +} + +fn spawn_sidecar_control_update_watcher( + mut updates: tokio::sync::mpsc::UnboundedReceiver, + provider_credentials: ProviderCredentialState, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(update) = updates.recv().await { + match update { + sidecar_control::ControlUpdate::ProviderEnvUpdated { + revision, + provider_child_env, + } => { + if revision <= provider_credentials.snapshot().revision { + continue; + } + let env_count = provider_credentials + .install_child_env_snapshot(revision, provider_child_env); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped("provider_env_revision", serde_json::json!(revision)) + .message(format!( + "Sidecar provider environment refreshed [revision:{revision} env_count:{env_count}]" + )) + .build() + ); + } + sidecar_control::ControlUpdate::PolicyUpdated { + policy_proto, + policy_hash, + config_revision, + } => { + debug!( + version = policy_proto.version, + policy_hash, + config_revision, + "Received sidecar policy update for process supervisor" + ); + } + } + } + }) +} + +#[cfg(target_os = "linux")] +fn spawn_sidecar_entrypoint_handler( + mut entrypoint_rx: tokio::sync::mpsc::Receiver, + entrypoint_pid: Arc, + opa_engine: Option>, + retained_proto: Option, + openshell_endpoint: Option, + sandbox_id: Option, + trusted_ssh_socket_path: std::path::PathBuf, +) { + tokio::spawn(async move { + let mut session_started = false; + let mut trusted_supervisor_pid = None; + while let Some(started) = entrypoint_rx.recv().await { + entrypoint_pid.store(started.pid, std::sync::atomic::Ordering::Release); + if started.start_session { + info!( + pid = started.pid, + ssh_socket = %trusted_ssh_socket_path.display(), + "Sidecar process supervisor reported entrypoint start" + ); + } else { + trusted_supervisor_pid = Some(started.pid); + info!( + pid = started.pid, + "Sidecar process supervisor reported initial process anchor" + ); + } + + if let (Some(engine), Some(proto)) = (opa_engine.as_ref(), retained_proto.as_ref()) { + match engine.reload_from_proto_with_pid(proto, started.pid) { + Ok(()) => info!( + pid = started.pid, + "Policy binary symlink resolution complete for sidecar process anchor" + ), + Err(err) => warn!( + error = %err, + pid = started.pid, + "Failed to rebuild OPA engine with sidecar process anchor PID" + ), + } + } + + if started.start_session + && !session_started + && let (Some(endpoint), Some(id)) = + (openshell_endpoint.as_ref(), sandbox_id.as_ref()) + { + let Some(supervisor_pid) = trusted_supervisor_pid else { + warn!( + pid = started.pid, + "Ignoring sidecar entrypoint event before authenticated supervisor anchor" + ); + continue; + }; + openshell_supervisor_process::supervisor_session::spawn( + endpoint.clone(), + id.clone(), + trusted_ssh_socket_path.clone(), + None, + Some(supervisor_pid), + ); + session_started = true; + info!("sidecar supervisor session task spawned"); + } + } + }); +} + +fn sidecar_ca_file_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> { + let tls_dir = std::env::var(openshell_core::sandbox_env::PROXY_TLS_DIR) + .unwrap_or_else(|_| SIDECAR_TLS_DIR.to_string()); + let cert = std::path::Path::new(&tls_dir).join(SIDECAR_CA_CERT); + let bundle = std::path::Path::new(&tls_dir).join(SIDECAR_CA_BUNDLE); + (cert.exists() && bundle.exists()).then_some((cert, bundle)) +} + +fn process_policy_for_topology( + policy: &SandboxPolicy, + sidecar_network_enforcement: bool, +) -> Result { + let mut process_policy = policy.clone(); + if sidecar_network_enforcement && matches!(process_policy.network.mode, NetworkMode::Proxy) { + let proxy = process_policy + .network + .proxy + .get_or_insert(ProxyPolicy { http_addr: None }); + if proxy.http_addr.is_none() { + proxy.http_addr = Some(SIDECAR_PROCESS_PROXY_ADDR.parse().into_diagnostic()?); + } + } + Ok(process_policy) +} + /// Flush aggregated denial summaries to the gateway via `SubmitPolicyAnalysis`. async fn flush_proposals_to_gateway( endpoint: &str, @@ -1947,6 +2363,7 @@ struct PolicyPollLoopContext { ocsf_enabled: Arc, provider_credentials: ProviderCredentialState, policy_local_ctx: Option>, + sidecar_control_publisher: Option, } async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { @@ -2058,12 +2475,20 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .await { Ok(env_result) => { - let env_count = ctx.provider_credentials.install_environment( + ctx.provider_credentials.install_environment( env_result.provider_env_revision, env_result.environment, env_result.credential_expires_at_ms, env_result.dynamic_credentials, ); + let child_env = ctx.provider_credentials.child_env_with_gcp_resolved(); + let env_count = child_env.len(); + if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { + publisher.publish_provider_env( + env_result.provider_env_revision, + child_env.clone(), + ); + } current_provider_env_revision = env_result.provider_env_revision; ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) @@ -2072,10 +2497,11 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .state(StateId::Enabled, "loaded") .unmapped( "provider_env_revision", - serde_json::json!(current_provider_env_revision) + serde_json::json!(env_result.provider_env_revision) ) .message(format!( - "Provider environment refreshed [revision:{current_provider_env_revision} env_count:{env_count}]" + "Provider environment refreshed [revision:{} env_count:{env_count}]", + env_result.provider_env_revision )) .build() ); @@ -2111,6 +2537,13 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { if let Some(policy_local_ctx) = ctx.policy_local_ctx.as_ref() { policy_local_ctx.set_current_policy(policy.clone()).await; } + if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { + publisher.publish_policy( + policy.clone(), + result.policy_hash.clone(), + result.config_revision, + ); + } if result.global_policy_version > 0 { ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Informational) @@ -2317,8 +2750,24 @@ fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String )] mod tests { use super::*; + use openshell_core::policy::{ + FilesystemPolicy, LandlockPolicy, NetworkMode, NetworkPolicy, ProcessPolicy, ProxyPolicy, + }; use std::sync::atomic::{AtomicBool, Ordering}; + fn proxy_policy(http_addr: Option) -> SandboxPolicy { + SandboxPolicy { + version: 1, + filesystem: FilesystemPolicy::default(), + network: NetworkPolicy { + mode: NetworkMode::Proxy, + proxy: Some(ProxyPolicy { http_addr }), + }, + landlock: LandlockPolicy::default(), + process: ProcessPolicy::default(), + } + } + fn effective_bool(value: bool) -> openshell_core::proto::EffectiveSetting { openshell_core::proto::EffectiveSetting { value: Some(openshell_core::proto::SettingValue { @@ -2330,6 +2779,100 @@ mod tests { } } + #[test] + fn sidecar_process_policy_sets_loopback_proxy_addr() { + let policy = proxy_policy(None); + + let process_policy = process_policy_for_topology(&policy, true).unwrap(); + + let http_addr = process_policy + .network + .proxy + .and_then(|proxy| proxy.http_addr) + .expect("sidecar process policy should set proxy address"); + assert_eq!(http_addr.to_string(), SIDECAR_PROCESS_PROXY_ADDR); + assert!( + policy + .network + .proxy + .as_ref() + .expect("original policy should keep proxy config") + .http_addr + .is_none(), + "process policy normalization must not mutate the network policy" + ); + } + + #[test] + fn non_sidecar_process_policy_preserves_proxy_addr() { + let policy = proxy_policy(None); + + let process_policy = process_policy_for_topology(&policy, false).unwrap(); + + assert!( + process_policy + .network + .proxy + .and_then(|proxy| proxy.http_addr) + .is_none() + ); + } + + #[tokio::test] + async fn sidecar_control_provider_env_update_installs_newer_revision() { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let provider_credentials = ProviderCredentialState::from_child_env_snapshot( + 1, + std::collections::HashMap::from([("TOKEN".to_string(), "old".to_string())]), + ); + let handle = spawn_sidecar_control_update_watcher(rx, provider_credentials.clone()); + + tx.send(sidecar_control::ControlUpdate::ProviderEnvUpdated { + revision: 2, + provider_child_env: std::collections::HashMap::from([( + "TOKEN".to_string(), + "new".to_string(), + )]), + }) + .unwrap(); + + timeout(Duration::from_secs(1), async { + loop { + if provider_credentials.snapshot().revision == 2 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let snapshot = provider_credentials.snapshot(); + assert_eq!(snapshot.revision, 2); + assert_eq!( + snapshot.child_env.get("TOKEN").map(String::as_str), + Some("new") + ); + + tx.send(sidecar_control::ControlUpdate::ProviderEnvUpdated { + revision: 1, + provider_child_env: std::collections::HashMap::from([( + "TOKEN".to_string(), + "stale".to_string(), + )]), + }) + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!( + provider_credentials + .snapshot() + .child_env + .get("TOKEN") + .map(String::as_str), + Some("new") + ); + handle.abort(); + } + #[test] fn apply_ocsf_json_setting_enables_from_initial_settings_snapshot() { let enabled = AtomicBool::new(false); diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 91b145c2e0..71f881f68a 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -35,15 +35,36 @@ const DEBUG_RPC_SUBCOMMAND: &str = "debug-rpc"; /// Default `--mode` value: run both supervisor leaves in a single binary. const DEFAULT_MODE: &str = "network,process"; +const SIDECAR_STATE_DIR: &str = "/run/openshell-sidecar"; +const SIDECAR_TLS_DIR: &str = "/etc/openshell-tls/proxy"; +#[cfg(target_os = "linux")] +const CLIENT_TLS_DIR: &str = "/etc/openshell-tls/client"; +#[cfg(target_os = "linux")] +const SIDECAR_CLIENT_TLS_SUBDIR: &str = "client"; +#[cfg(target_os = "linux")] +const CLIENT_TLS_FILES: [&str; 3] = ["ca.crt", "tls.crt", "tls.key"]; +#[cfg(target_os = "linux")] +const SIDECAR_STATE_DIR_MODE: u32 = 0o2775; +#[cfg(target_os = "linux")] +const SIDECAR_TLS_DIR_MODE: u32 = 0o755; +#[cfg(target_os = "linux")] +const SIDECAR_TLS_STAGING_DIR_MODE: u32 = 0o700; +#[cfg(target_os = "linux")] +const SIDECAR_CLIENT_TLS_DIR_MODE: u32 = 0o750; +#[cfg(target_os = "linux")] +const SIDECAR_CLIENT_TLS_FILE_MODE: u32 = 0o400; /// Which supervisor leaves are enabled in this process. /// /// Parsed from a comma-separated `--mode` value, e.g. `network`, -/// `process`, or `network,process`. At least one must be set. +/// `process`, or `network,process`. `network-init` is a one-shot setup mode +/// used by the Kubernetes sidecar topology and cannot be combined with other +/// mode components. At least one must be set. #[derive(Clone, Copy, Debug)] struct Mode { network: bool, process: bool, + network_init: bool, } impl std::str::FromStr for Mode { @@ -53,20 +74,27 @@ impl std::str::FromStr for Mode { let mut mode = Self { network: false, process: false, + network_init: false, }; for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { match part { "network" => mode.network = true, "process" => mode.process = true, + "network-init" => mode.network_init = true, other => { return Err(format!( - "unknown mode component '{other}' (expected 'network' and/or 'process')" + "unknown mode component '{other}' (expected 'network', 'process', or 'network-init')" )); } } } - if !mode.network && !mode.process { - return Err("--mode must enable at least one of: network, process".into()); + if mode.network_init && (mode.network || mode.process) { + return Err("--mode=network-init cannot be combined with other components".into()); + } + if !mode.network && !mode.process && !mode.network_init { + return Err( + "--mode must enable at least one of: network, process, network-init".into(), + ); } Ok(mode) } @@ -125,7 +153,8 @@ struct Args { #[arg(long, default_value = "warn", env = openshell_core::sandbox_env::LOG_LEVEL)] log_level: String, - /// Filesystem path to the Unix socket the embedded SSH daemon binds. + /// Unix socket the embedded SSH daemon binds. On Linux, a value beginning + /// with `@` selects an abstract socket in the network namespace. /// The supervisor bridges `RelayStream` traffic from the gateway onto /// this socket; nothing else should connect to it. #[arg(long, env = openshell_core::sandbox_env::SSH_SOCKET_PATH)] @@ -149,9 +178,28 @@ struct Args { /// "network" and/or "process". Defaults to both (single-binary /// topology). Use --mode=network for a network-only sidecar, or /// --mode=process for a process-only supervisor when network - /// enforcement runs in another pod. + /// enforcement runs in another pod. Use --mode=network-init only in + /// the Kubernetes init container that prepares sidecar nftables. #[arg(long, default_value = DEFAULT_MODE)] mode: Mode, + + /// UID that the long-running Kubernetes network sidecar will run as. + /// `--mode=network-init` installs nftables rules that exempt this UID. + #[arg(long, env = "OPENSHELL_PROXY_UID", default_value_t = 1337)] + proxy_uid: u32, + + /// GID assigned to shared sidecar state directories. Defaults to + /// `--proxy-uid` when omitted. + #[arg(long, env = "OPENSHELL_PROXY_GID")] + proxy_gid: Option, + + /// Shared state directory between the network init container and sidecar. + #[arg(long, env = "OPENSHELL_SIDECAR_STATE_DIR", default_value = SIDECAR_STATE_DIR)] + sidecar_state_dir: String, + + /// Shared TLS work directory between the network init container and sidecar. + #[arg(long, env = "OPENSHELL_PROXY_TLS_DIR", default_value = SIDECAR_TLS_DIR)] + sidecar_tls_dir: String, } /// Copy the running executable to `dest`, creating parent directories as @@ -194,6 +242,189 @@ fn copy_self(dest: &str) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +fn prepare_sidecar_directory(path: &Path, uid: u32, gid: u32, mode: u32) -> Result<()> { + use miette::Context as _; + use nix::unistd::{Gid, Uid, chown}; + use std::os::unix::fs::PermissionsExt; + + std::fs::create_dir_all(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create sidecar directory {}", path.display()))?; + let mut perms = std::fs::metadata(path).into_diagnostic()?.permissions(); + perms.set_mode(mode); + std::fs::set_permissions(path, perms) + .into_diagnostic() + .wrap_err_with(|| format!("failed to chmod sidecar directory {}", path.display()))?; + chown(path, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to chown sidecar directory {} to {uid}:{gid}", + path.display() + ) + })?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn prepare_sidecar_directory_for_current_user(path: &Path, mode: u32) -> Result<()> { + use miette::Context as _; + use nix::unistd::{Gid, Uid, chown}; + use std::os::unix::fs::PermissionsExt; + + let uid = Uid::current(); + let gid = Gid::current(); + std::fs::create_dir_all(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create sidecar directory {}", path.display()))?; + chown(path, Some(uid), Some(gid)) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to chown sidecar directory {} to {}:{}", + path.display(), + uid.as_raw(), + gid.as_raw() + ) + })?; + let mut perms = std::fs::metadata(path).into_diagnostic()?.permissions(); + perms.set_mode(mode); + std::fs::set_permissions(path, perms) + .into_diagnostic() + .wrap_err_with(|| format!("failed to chmod sidecar directory {}", path.display()))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn copy_sidecar_client_tls_if_present( + source_dir: &Path, + sidecar_tls_dir: &Path, + uid: u32, + gid: u32, +) -> Result<()> { + use miette::Context as _; + use nix::unistd::{Gid, Uid, chown}; + use std::os::unix::fs::PermissionsExt; + + if !source_dir.exists() { + return Ok(()); + } + + let dest_dir = sidecar_tls_dir.join(SIDECAR_CLIENT_TLS_SUBDIR); + prepare_sidecar_directory_for_current_user(&dest_dir, SIDECAR_TLS_STAGING_DIR_MODE)?; + for file_name in CLIENT_TLS_FILES { + let source = source_dir.join(file_name); + if !source.exists() { + return Err(miette::miette!( + "client TLS source file is missing: {}", + source.display() + )); + } + let dest = dest_dir.join(file_name); + if dest.exists() { + std::fs::remove_file(&dest) + .into_diagnostic() + .wrap_err_with(|| { + format!("failed to remove stale client TLS file {}", dest.display()) + })?; + } + std::fs::copy(&source, &dest) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to copy client TLS file {} to {}", + source.display(), + dest.display() + ) + })?; + let mut perms = std::fs::metadata(&dest).into_diagnostic()?.permissions(); + perms.set_mode(SIDECAR_CLIENT_TLS_FILE_MODE); + std::fs::set_permissions(&dest, perms) + .into_diagnostic() + .wrap_err_with(|| { + format!("failed to chmod copied client TLS file {}", dest.display()) + })?; + chown(&dest, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to chown copied client TLS file {} to {uid}:{gid}", + dest.display() + ) + })?; + } + + prepare_sidecar_directory(&dest_dir, uid, gid, SIDECAR_CLIENT_TLS_DIR_MODE)?; + + Ok(()) +} + +#[cfg(target_os = "linux")] +fn run_network_init( + proxy_user_id: u32, + proxy_primary_group_id: u32, + sidecar_state_dir: &str, + sidecar_tls_dir: &str, +) -> Result<()> { + validate_network_init_ids(proxy_user_id, proxy_primary_group_id)?; + + let sidecar_state_dir = Path::new(sidecar_state_dir); + let sidecar_tls_dir = Path::new(sidecar_tls_dir); + prepare_sidecar_directory( + sidecar_state_dir, + proxy_user_id, + proxy_primary_group_id, + SIDECAR_STATE_DIR_MODE, + )?; + // The init container runs as uid 0 with CAP_DAC_OVERRIDE dropped. Keep the + // TLS work directory owned by the init user until the client cert copy is + // complete, then hand it to the long-running proxy UID. + prepare_sidecar_directory_for_current_user(sidecar_tls_dir, SIDECAR_TLS_DIR_MODE)?; + copy_sidecar_client_tls_if_present( + Path::new(CLIENT_TLS_DIR), + sidecar_tls_dir, + proxy_user_id, + proxy_primary_group_id, + )?; + prepare_sidecar_directory( + sidecar_tls_dir, + proxy_user_id, + proxy_primary_group_id, + SIDECAR_TLS_DIR_MODE, + )?; + openshell_supervisor_process::netns::install_sidecar_bypass_rules(proxy_user_id) +} + +#[cfg(target_os = "linux")] +fn validate_network_init_ids(proxy_user_id: u32, proxy_primary_group_id: u32) -> Result<()> { + if proxy_user_id != 0 && proxy_user_id < openshell_policy::MIN_SANDBOX_UID { + return Err(miette::miette!( + "--proxy-uid must be 0 or at least {}", + openshell_policy::MIN_SANDBOX_UID + )); + } + if proxy_primary_group_id < openshell_policy::MIN_SANDBOX_UID { + return Err(miette::miette!( + "--proxy-gid must be at least {}", + openshell_policy::MIN_SANDBOX_UID + )); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn run_network_init( + _proxy_uid: u32, + _proxy_gid: u32, + _sidecar_state_dir: &str, + _sidecar_tls_dir: &str, +) -> Result<()> { + Err(miette::miette!( + "--mode=network-init is only supported on Linux" + )) +} + fn main() -> Result<()> { // Handle `copy-self ` before clap so it works without any of the // sandbox flags. Kubernetes init containers invoke this path to seed an @@ -222,6 +453,16 @@ fn main() -> Result<()> { let args = Args::parse(); + if args.mode.network_init { + let proxy_gid = args.proxy_gid.unwrap_or(args.proxy_uid); + return run_network_init( + args.proxy_uid, + proxy_gid, + &args.sidecar_state_dir, + &args.sidecar_tls_dir, + ); + } + // Try to open a rolling log file; fall back to stderr-only logging if it fails // (e.g., /var/log is not writable in custom workload images). // Rotates daily, keeps the 3 most recent files to bound disk usage. @@ -421,4 +662,50 @@ mod tests { let final_path = dest_dir.join("openshell-sandbox"); assert!(final_path.exists(), "binary should land inside dest dir"); } + + #[test] + fn mode_parses_network_init_standalone() { + let mode = "network-init".parse::().unwrap(); + assert!(mode.network_init); + assert!(!mode.network); + assert!(!mode.process); + } + + #[test] + fn mode_rejects_combined_network_init() { + let err = "network-init,network".parse::().unwrap_err(); + assert!(err.contains("cannot be combined")); + } + + #[test] + fn mode_rejects_empty_value() { + let err = "".parse::().unwrap_err(); + assert!(err.contains("at least one")); + } + + #[cfg(target_os = "linux")] + #[test] + fn sidecar_tls_modes_preserve_proxy_owned_parent_and_private_client_dir() { + assert_eq!(SIDECAR_TLS_DIR_MODE, 0o755); + assert_eq!(SIDECAR_TLS_STAGING_DIR_MODE, 0o700); + assert_eq!(SIDECAR_CLIENT_TLS_DIR_MODE, 0o750); + assert_eq!(SIDECAR_CLIENT_TLS_FILE_MODE, 0o400); + } + + #[cfg(target_os = "linux")] + #[test] + fn network_init_accepts_root_proxy_uid_for_binary_aware_sidecar() { + validate_network_init_ids(0, openshell_policy::MIN_SANDBOX_UID).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn network_init_still_rejects_low_non_root_proxy_ids() { + let uid_err = + validate_network_init_ids(999, openshell_policy::MIN_SANDBOX_UID).unwrap_err(); + assert!(uid_err.to_string().contains("--proxy-uid")); + + let gid_err = validate_network_init_ids(0, 999).unwrap_err(); + assert!(gid_err.to_string().contains("--proxy-gid")); + } } diff --git a/crates/openshell-sandbox/src/sidecar_control.rs b/crates/openshell-sandbox/src/sidecar_control.rs new file mode 100644 index 0000000000..03cfeff886 --- /dev/null +++ b/crates/openshell-sandbox/src/sidecar_control.rs @@ -0,0 +1,783 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Local control channel for Kubernetes sidecar topology. +//! +//! The network sidecar owns gateway credentials. The process supervisor in the +//! agent container connects over this Unix socket to receive policy/provider +//! state without mounting gateway credentials into the agent container. + +use miette::{IntoDiagnostic, Result, WrapErr}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; +use tokio::net::unix::OwnedWriteHalf; +use tokio::sync::{Mutex, broadcast, mpsc}; +use tracing::{debug, info, warn}; + +#[derive(Debug, Clone)] +pub struct BootstrapData { + pub policy_proto: openshell_core::proto::SandboxPolicy, + pub provider_env_revision: u64, + pub provider_child_env: HashMap, + pub proxy_ca_cert_path: Option, + pub proxy_ca_bundle_path: Option, +} + +#[derive(Debug, Clone)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub struct EntrypointStarted { + pub pid: u32, + pub start_session: bool, +} + +#[derive(Debug, Clone, Copy)] +pub struct ExpectedPeer { + pub uid: u32, + pub gid: u32, +} + +#[derive(Debug, Clone)] +pub enum ControlUpdate { + ProviderEnvUpdated { + revision: u64, + provider_child_env: HashMap, + }, + PolicyUpdated { + policy_proto: openshell_core::proto::SandboxPolicy, + policy_hash: String, + config_revision: u64, + }, +} + +#[derive(Clone)] +pub struct Publisher { + state: Arc>, + updates: broadcast::Sender, +} + +impl Publisher { + pub fn publish_provider_env(&self, revision: u64, provider_child_env: HashMap) { + { + let mut state = self.state.write().expect("sidecar control state poisoned"); + if revision <= state.provider_env_revision { + return; + } + state.provider_env_revision = revision; + state.provider_child_env.clone_from(&provider_child_env); + } + + let _ = self.updates.send(WireServerMessage::ProviderEnvUpdated { + revision, + provider_child_env, + }); + } + + pub fn publish_policy( + &self, + policy_proto: openshell_core::proto::SandboxPolicy, + policy_hash: String, + config_revision: u64, + ) { + { + let mut state = self.state.write().expect("sidecar control state poisoned"); + state.policy_proto = policy_proto.clone(); + } + + let _ = self.updates.send(WireServerMessage::PolicyUpdated { + policy_proto: policy_proto.encode_to_vec(), + policy_hash, + config_revision, + }); + } +} + +pub struct ServerHandle { + publisher: Publisher, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + entrypoint_rx: mpsc::Receiver, + connection_task: tokio::task::JoinHandle<()>, +} + +impl ServerHandle { + pub fn publisher(&self) -> Publisher { + self.publisher.clone() + } + + #[cfg(test)] + pub fn into_entrypoint_receiver(self) -> mpsc::Receiver { + self.entrypoint_rx + } + + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub fn into_runtime_parts( + self, + ) -> ( + mpsc::Receiver, + tokio::task::JoinHandle<()>, + ) { + (self.entrypoint_rx, self.connection_task) + } +} + +pub struct ProcessConnection { + pub writer: Arc>, + pub updates: mpsc::UnboundedReceiver, + pub closed: tokio::sync::oneshot::Receiver<()>, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum WireClientMessage { + BootstrapRequest { supervisor_pid: u32 }, + EntrypointStarted { pid: u32 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum WireServerMessage { + BootstrapResponse { + policy_proto: Vec, + provider_env_revision: u64, + provider_child_env: HashMap, + proxy_ca_cert_path: Option, + proxy_ca_bundle_path: Option, + }, + ProviderEnvUpdated { + revision: u64, + provider_child_env: HashMap, + }, + PolicyUpdated { + policy_proto: Vec, + policy_hash: String, + config_revision: u64, + }, +} + +impl BootstrapData { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + fn to_wire(&self) -> WireServerMessage { + WireServerMessage::BootstrapResponse { + policy_proto: self.policy_proto.encode_to_vec(), + provider_env_revision: self.provider_env_revision, + provider_child_env: self.provider_child_env.clone(), + proxy_ca_cert_path: self + .proxy_ca_cert_path + .as_ref() + .map(|path| path.display().to_string()), + proxy_ca_bundle_path: self + .proxy_ca_bundle_path + .as_ref() + .map(|path| path.display().to_string()), + } + } +} + +impl TryFrom for BootstrapData { + type Error = miette::Report; + + fn try_from(message: WireServerMessage) -> Result { + let WireServerMessage::BootstrapResponse { + policy_proto, + provider_env_revision, + provider_child_env, + proxy_ca_cert_path, + proxy_ca_bundle_path, + } = message + else { + return Err(miette::miette!( + "expected sidecar bootstrap response, received update message" + )); + }; + + let policy_proto = openshell_core::proto::SandboxPolicy::decode(policy_proto.as_slice()) + .into_diagnostic() + .wrap_err("failed to decode sidecar bootstrap policy")?; + + Ok(Self { + policy_proto, + provider_env_revision, + provider_child_env, + proxy_ca_cert_path: proxy_ca_cert_path.map(PathBuf::from), + proxy_ca_bundle_path: proxy_ca_bundle_path.map(PathBuf::from), + }) + } +} + +impl TryFrom for ControlUpdate { + type Error = miette::Report; + + fn try_from(message: WireServerMessage) -> Result { + match message { + WireServerMessage::ProviderEnvUpdated { + revision, + provider_child_env, + } => Ok(Self::ProviderEnvUpdated { + revision, + provider_child_env, + }), + WireServerMessage::PolicyUpdated { + policy_proto, + policy_hash, + config_revision, + } => { + let policy_proto = + openshell_core::proto::SandboxPolicy::decode(policy_proto.as_slice()) + .into_diagnostic() + .wrap_err("failed to decode sidecar policy update")?; + Ok(Self::PolicyUpdated { + policy_proto, + policy_hash, + config_revision, + }) + } + WireServerMessage::BootstrapResponse { .. } => Err(miette::miette!( + "unexpected sidecar bootstrap response after initial handshake" + )), + } + } +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub fn spawn_server( + path: &Path, + bootstrap: BootstrapData, + expected_peer: ExpectedPeer, +) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to create sidecar control socket dir {}", + parent.display() + ) + })?; + } + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err).into_diagnostic().wrap_err_with(|| { + format!( + "failed to remove stale sidecar control socket {}", + path.display() + ) + }); + } + } + + let listener = UnixListener::bind(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to bind sidecar control socket {}", path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "failed to set permissions on sidecar control socket {}", + path.display() + ) + })?; + } + + let state = Arc::new(RwLock::new(bootstrap)); + let (updates, _) = broadcast::channel(32); + let (entrypoint_tx, entrypoint_rx) = mpsc::channel(8); + let publisher = Publisher { + state: state.clone(), + updates: updates.clone(), + }; + + let connection_task = tokio::spawn(accept_authoritative_connection( + listener, + path.to_path_buf(), + expected_peer, + state, + updates, + entrypoint_tx, + )); + info!(path = %path.display(), "Sidecar control socket listening"); + + Ok(ServerHandle { + publisher, + entrypoint_rx, + connection_task, + }) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +async fn accept_authoritative_connection( + listener: UnixListener, + socket_path: PathBuf, + expected_peer: ExpectedPeer, + state: Arc>, + updates: broadcast::Sender, + entrypoint_tx: mpsc::Sender, +) { + let stream = match listener.accept().await { + Ok((stream, _addr)) => stream, + Err(err) => { + warn!(error = %err, "Failed to accept authoritative sidecar control connection"); + return; + } + }; + + // The process supervisor connects before it launches the workload. Drop + // the listener and unlink its pathname after that first accept so workload + // processes can neither open a second control channel nor impersonate a + // restarted server at the trusted path. + drop(listener); + if let Err(err) = std::fs::remove_file(&socket_path) + && err.kind() != std::io::ErrorKind::NotFound + { + warn!( + path = %socket_path.display(), + error = %err, + "Failed to unlink accepted sidecar control socket" + ); + } + + if let Err(err) = handle_connection(stream, expected_peer, state, updates, entrypoint_tx).await + { + warn!(error = %err, "Authoritative sidecar control connection closed"); + } +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +async fn handle_connection( + stream: tokio::net::UnixStream, + expected_peer: ExpectedPeer, + state: Arc>, + updates: broadcast::Sender, + entrypoint_tx: mpsc::Sender, +) -> Result<()> { + let credentials = stream + .peer_cred() + .into_diagnostic() + .wrap_err("failed to read sidecar control peer credentials")?; + if credentials.uid() != expected_peer.uid || credentials.gid() != expected_peer.gid { + return Err(miette::miette!( + "sidecar control peer identity mismatch: expected uid:gid {}:{}, got {}:{}", + expected_peer.uid, + expected_peer.gid, + credentials.uid(), + credentials.gid(), + )); + } + let peer_pid = credentials + .pid() + .and_then(|pid| u32::try_from(pid).ok()) + .ok_or_else(|| miette::miette!("sidecar control peer PID is unavailable"))?; + + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + let first_line = + lines.next_line().await.into_diagnostic()?.ok_or_else(|| { + miette::miette!("sidecar control client disconnected before bootstrap") + })?; + match decode_client_message(&first_line)? { + WireClientMessage::BootstrapRequest { supervisor_pid } => { + if supervisor_pid == 0 || supervisor_pid != peer_pid { + return Err(miette::miette!( + "sidecar bootstrap PID mismatch: peer PID {peer_pid}, claimed PID {supervisor_pid}" + )); + } + entrypoint_tx + .send(EntrypointStarted { + pid: supervisor_pid, + start_session: false, + }) + .await + .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; + } + WireClientMessage::EntrypointStarted { .. } => { + return Err(miette::miette!( + "sidecar control client sent entrypoint event before bootstrap" + )); + } + } + + let bootstrap = { + let state = state.read().expect("sidecar control state poisoned"); + state.to_wire() + }; + write_json_line(&mut writer, &bootstrap).await?; + + let mut update_rx = updates.subscribe(); + loop { + tokio::select! { + line = lines.next_line() => { + let Some(line) = line.into_diagnostic()? else { + return Ok(()); + }; + match decode_client_message(&line)? { + WireClientMessage::BootstrapRequest { .. } => { + debug!("Ignoring duplicate sidecar bootstrap request"); + } + WireClientMessage::EntrypointStarted { pid } => { + if pid == 0 { + warn!("Ignoring sidecar entrypoint event with pid=0"); + continue; + } + entrypoint_tx + .send(EntrypointStarted { + pid, + start_session: true, + }) + .await + .map_err(|_| miette::miette!("sidecar entrypoint receiver closed"))?; + } + } + } + update = update_rx.recv() => { + match update { + Ok(message) => write_json_line(&mut writer, &message).await?, + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!(skipped, "Sidecar control client lagged behind updates"); + } + Err(broadcast::error::RecvError::Closed) => return Ok(()), + } + } + } + } +} + +pub async fn connect_process_client( + path: &Path, + timeout: Duration, +) -> Result<(BootstrapData, ProcessConnection)> { + let stream = connect_with_retry(path, timeout).await?; + let (reader, mut writer) = stream.into_split(); + write_json_line( + &mut writer, + &WireClientMessage::BootstrapRequest { + supervisor_pid: std::process::id(), + }, + ) + .await?; + + let mut lines = BufReader::new(reader).lines(); + let first_line = lines + .next_line() + .await + .into_diagnostic()? + .ok_or_else(|| miette::miette!("sidecar control closed before bootstrap response"))?; + let bootstrap = BootstrapData::try_from(decode_server_message(&first_line)?)?; + + let (update_tx, updates) = mpsc::unbounded_channel(); + let (closed_tx, closed) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + while let Ok(Some(line)) = lines.next_line().await { + match decode_server_message(&line).and_then(ControlUpdate::try_from) { + Ok(update) => { + if update_tx.send(update).is_err() { + break; + } + } + Err(err) => { + warn!(error = %err, "Ignoring invalid sidecar control update"); + } + } + } + let _ = closed_tx.send(()); + }); + + Ok(( + bootstrap, + ProcessConnection { + writer: Arc::new(Mutex::new(writer)), + updates, + closed, + }, + )) +} + +async fn connect_with_retry(path: &Path, timeout: Duration) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + loop { + match tokio::net::UnixStream::connect(path).await { + Ok(stream) => return Ok(stream), + Err(err) if tokio::time::Instant::now() < deadline => { + debug!( + path = %path.display(), + error = %err, + "Waiting for sidecar control socket" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(err) => { + return Err(err).into_diagnostic().wrap_err_with(|| { + format!( + "timed out waiting for sidecar control socket {}", + path.display() + ) + }); + } + } + } +} + +pub async fn send_entrypoint_started(writer: &Arc>, pid: u32) -> Result<()> { + let message = WireClientMessage::EntrypointStarted { pid }; + 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, + T: Serialize + Sync, +{ + let bytes = serde_json::to_vec(value).into_diagnostic()?; + writer.write_all(&bytes).await.into_diagnostic()?; + writer.write_all(b"\n").await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + Ok(()) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn decode_client_message(line: &str) -> Result { + serde_json::from_str(line) + .into_diagnostic() + .wrap_err("failed to decode sidecar client message") +} + +fn decode_server_message(line: &str) -> Result { + serde_json::from_str(line) + .into_diagnostic() + .wrap_err("failed to decode sidecar server message") +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::SandboxPolicy; + + fn current_peer() -> ExpectedPeer { + ExpectedPeer { + uid: nix::unistd::Uid::current().as_raw(), + gid: nix::unistd::Gid::current().as_raw(), + } + } + + #[tokio::test] + async fn bootstrap_round_trips_policy_and_provider_env() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let mut env = HashMap::new(); + env.insert("GITHUB_TOKEN".to_string(), "secret".to_string()); + let bootstrap = BootstrapData { + policy_proto: SandboxPolicy { + version: 7, + ..SandboxPolicy::default() + }, + provider_env_revision: 3, + provider_child_env: env.clone(), + proxy_ca_cert_path: Some(PathBuf::from("/tmp/ca.pem")), + proxy_ca_bundle_path: Some(PathBuf::from("/tmp/bundle.pem")), + }; + + let _server = spawn_server(&socket, bootstrap, current_peer()).unwrap(); + let (received, _connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + assert_eq!(received.policy_proto.version, 7); + assert_eq!(received.provider_env_revision, 3); + assert_eq!(received.provider_child_env, env); + assert_eq!( + received.proxy_ca_cert_path, + Some(PathBuf::from("/tmp/ca.pem")) + ); + assert_eq!( + received.proxy_ca_bundle_path, + Some(PathBuf::from("/tmp/bundle.pem")) + ); + } + + #[tokio::test] + async fn entrypoint_started_is_delivered_to_server() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: 0, + provider_child_env: HashMap::new(), + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + let mut entrypoint_rx = server.into_entrypoint_receiver(); + let (_bootstrap, connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + let anchor = tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(anchor.pid, std::process::id()); + assert!(!anchor.start_session); + + send_entrypoint_started(&connection.writer, 4242) + .await + .unwrap(); + + let started = tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(started.pid, 4242); + assert!(started.start_session); + } + + #[tokio::test] + async fn second_control_client_is_rejected_after_authoritative_bootstrap() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let _server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: 0, + provider_child_env: HashMap::new(), + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + + let (_bootstrap, _connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + let err = tokio::net::UnixStream::connect(&socket) + .await + .expect_err("control listener must be removed after the first bootstrap"); + assert!( + matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ), + "unexpected second-client error: {err}" + ); + } + + #[tokio::test] + async fn authoritative_connection_task_ends_when_process_supervisor_disconnects() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: 0, + provider_child_env: HashMap::new(), + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + let (_entrypoint_rx, connection_task) = server.into_runtime_parts(); + let (_bootstrap, connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + drop(connection); + tokio::time::timeout(Duration::from_secs(1), connection_task) + .await + .expect("server must observe authoritative client disconnect") + .expect("control task must not panic"); + } + + #[tokio::test] + async fn process_client_reports_network_sidecar_restart() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: 0, + provider_child_env: HashMap::new(), + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + let (_entrypoint_rx, connection_task) = server.into_runtime_parts(); + let (_bootstrap, connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + connection_task.abort(); + let _ = connection_task.await; + tokio::time::timeout(Duration::from_secs(1), connection.closed) + .await + .expect("process supervisor must observe network sidecar disconnect") + .expect("disconnect notifier must remain live"); + } + + #[tokio::test] + async fn bootstrap_rejects_claimed_pid_that_does_not_match_peer_credentials() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: 0, + provider_child_env: HashMap::new(), + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + let mut entrypoint_rx = server.into_entrypoint_receiver(); + + let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap(); + write_json_line( + &mut stream, + &WireClientMessage::BootstrapRequest { + supervisor_pid: std::process::id().saturating_add(1), + }, + ) + .await + .unwrap(); + + assert!( + tokio::time::timeout(Duration::from_secs(1), entrypoint_rx.recv()) + .await + .unwrap() + .is_none(), + "mismatched bootstrap must not publish a process anchor" + ); + } + + #[test] + fn malformed_client_message_is_rejected() { + let err = decode_client_message("not-json").unwrap_err(); + assert!( + err.to_string() + .contains("failed to decode sidecar client message") + ); + } +} diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index efcdf07320..d70c69b748 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -19,6 +19,10 @@ allow_network if { network_policy_for_request } +binary_identity_required if { + object.get(object.get(data, "runtime", {}), "require_binary_identity", true) +} + # --- Deny reasons (specific diagnostics for debugging policy denials) --- deny_reason := "missing input.network" if { @@ -131,6 +135,12 @@ endpoint_allowed(policy, network) if { endpoint.ports[_] == network.port } +# Binary matching can be relaxed by trusted runtime configuration. In that +# mode, network policies are endpoint/L7 scoped and ignore policy.binaries. +binary_allowed(_, _) if { + not binary_identity_required +} + # Binary matching: exact path. # SHA256 integrity is enforced in Rust via trust-on-first-use (TOFU) cache, # not in Rego. The proxy computes and caches binary hashes at runtime. @@ -161,6 +171,10 @@ binary_allowed(policy, exec) if { glob.match(b.path, ["/"], p) } +user_declared_binary_allowed(_, _) if { + not binary_identity_required +} + user_declared_binary_allowed(policy, exec) if { some b b := policy.binaries[_] diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index fce568f41e..5e89c35031 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -100,23 +100,34 @@ impl BinaryIdentityCache { /// Returns `Ok(hash)` if it matches, `Err` if the hash changed (binary tampered). #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub fn verify_or_cache(&self, path: &Path) -> Result { - self.verify_or_cache_with_hasher(path, procfs::file_sha256) + self.verify_or_cache_with_paths(path, path, procfs::file_sha256) } - fn verify_or_cache_with_hasher(&self, path: &Path, mut hash_file: F) -> Result + #[cfg(target_os = "linux")] + pub fn verify_or_cache_process_exe(&self, display_path: &Path, pid: u32) -> Result { + let proc_exe = PathBuf::from(format!("/proc/{pid}/exe")); + self.verify_or_cache_with_paths(display_path, &proc_exe, procfs::file_sha256) + } + + fn verify_or_cache_with_paths( + &self, + cache_path: &Path, + access_path: &Path, + mut hash_file: F, + ) -> Result where F: FnMut(&Path) -> Result, { let start = std::time::Instant::now(); - let metadata = std::fs::metadata(path) - .map_err(|error| miette::miette!("Failed to stat {}: {error}", path.display()))?; + let metadata = std::fs::metadata(access_path) + .map_err(|error| miette::miette!("Failed to stat {}: {error}", cache_path.display()))?; let fingerprint = FileFingerprint::from_metadata(&metadata); let cached = self .hashes .lock() .map_err(|_| miette::miette!("Binary identity cache lock poisoned"))? - .get(path) + .get(cache_path) .cloned(); if let Some(cached_binary) = &cached @@ -125,7 +136,7 @@ impl BinaryIdentityCache { debug!( " verify_or_cache: {}ms CACHE HIT path={}", start.elapsed().as_millis(), - path.display() + cache_path.display() ); return Ok(cached_binary.hash.clone()); } @@ -133,29 +144,29 @@ impl BinaryIdentityCache { debug!( " verify_or_cache: CACHE MISS size={} path={}", metadata.len(), - path.display() + cache_path.display() ); - let current_hash = hash_file(path)?; + let current_hash = hash_file(access_path)?; let mut hashes = self .hashes .lock() .map_err(|_| miette::miette!("Binary identity cache lock poisoned"))?; - if let Some(existing) = hashes.get(path) + if let Some(existing) = hashes.get(cache_path) && existing.hash != current_hash { return Err(miette::miette!( "Binary integrity violation: {} hash changed (cached: {}, current: {})", - path.display(), + cache_path.display(), existing.hash, current_hash )); } hashes.insert( - path.to_path_buf(), + cache_path.to_path_buf(), CachedBinary { hash: current_hash.clone(), fingerprint, @@ -165,7 +176,7 @@ impl BinaryIdentityCache { debug!( " verify_or_cache TOTAL (cold): {}ms path={}", start.elapsed().as_millis(), - path.display() + cache_path.display() ); Ok(current_hash) @@ -212,13 +223,13 @@ mod tests { let mut hash_calls = 0; let hash1 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { + .verify_or_cache_with_paths(tmp.path(), tmp.path(), |path| { hash_calls += 1; procfs::file_sha256(path) }) .unwrap(); let hash2 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { + .verify_or_cache_with_paths(tmp.path(), tmp.path(), |path| { hash_calls += 1; procfs::file_sha256(path) }) @@ -238,7 +249,7 @@ mod tests { let mut hash_calls = 0; let hash1 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { + .verify_or_cache_with_paths(tmp.path(), tmp.path(), |path| { hash_calls += 1; procfs::file_sha256(path) }) @@ -254,7 +265,7 @@ mod tests { .unwrap(); let hash2 = cache - .verify_or_cache_with_hasher(tmp.path(), |path| { + .verify_or_cache_with_paths(tmp.path(), tmp.path(), |path| { hash_calls += 1; procfs::file_sha256(path) }) @@ -275,7 +286,7 @@ mod tests { let mut hash_calls = 0; cache - .verify_or_cache_with_hasher(&path, |path| { + .verify_or_cache_with_paths(&path, &path, |path| { hash_calls += 1; procfs::file_sha256(path) }) @@ -292,7 +303,7 @@ mod tests { .set_modified(original_mtime) .unwrap(); - let result = cache.verify_or_cache_with_hasher(&path, |path| { + let result = cache.verify_or_cache_with_paths(&path, &path, |path| { hash_calls += 1; procfs::file_sha256(path) }); @@ -301,6 +312,28 @@ mod tests { assert_eq!(hash_calls, 2); } + #[test] + fn display_path_can_differ_from_access_path() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(b"binary content").unwrap(); + tmp.flush().unwrap(); + let display_path = Path::new("/usr/bin/python3"); + + let cache = BinaryIdentityCache::new(); + let hash = cache + .verify_or_cache_with_paths(display_path, tmp.path(), procfs::file_sha256) + .unwrap(); + + assert!(!hash.is_empty()); + assert!( + cache + .hashes + .lock() + .unwrap() + .contains_key(Path::new("/usr/bin/python3")) + ); + } + #[test] fn hash_mismatch_returns_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index aaa79f9281..829c63caa4 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -18,6 +18,7 @@ use std::sync::{ Arc, Mutex, atomic::{AtomicU64, Ordering}, }; +use tracing::info; /// Baked-in rego rules for OPA policy evaluation. /// These rules define the network access decision logic and static config @@ -55,6 +56,49 @@ pub struct NetworkInput { pub cmdline_paths: Vec, } +pub(crate) fn network_binary_identity_required() -> bool { + std::env::var(openshell_core::sandbox_env::NETWORK_BINARY_IDENTITY).map_or(true, |value| { + !matches!( + value.as_str(), + "relaxed" | "disabled" | "endpoint-only" | "false" | "0" + ) + }) +} + +fn inject_runtime_policy_data(data: &mut serde_json::Value, require_binary_identity: bool) { + let Some(obj) = data.as_object_mut() else { + return; + }; + obj.insert( + "runtime".to_string(), + serde_json::json!({ + "require_binary_identity": require_binary_identity, + }), + ); +} + +fn emit_binary_identity_mode(require_binary_identity: bool, source: &str) { + info!( + require_binary_identity, + source, "Configured OPA runtime binary identity mode" + ); + openshell_ocsf::ocsf_emit!( + openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .state(openshell_ocsf::StateId::Enabled, "configured") + .unmapped( + "require_binary_identity", + serde_json::json!(require_binary_identity) + ) + .unmapped("source", serde_json::json!(source)) + .message(format!( + "OPA runtime binary identity mode configured [source:{source} require_binary_identity:{require_binary_identity}]" + )) + .build() + ); +} + /// Sandbox configuration extracted from OPA data at startup. pub struct SandboxConfig { pub filesystem: FilesystemPolicy, @@ -146,7 +190,9 @@ impl OpaEngine { engine .add_policy_from_file(policy_path) .map_err(|e| miette::miette!("{e}"))?; - let data_json = preprocess_yaml_data(&yaml_str)?; + let require_binary_identity = network_binary_identity_required(); + emit_binary_identity_mode(require_binary_identity, "files"); + let data_json = preprocess_yaml_data(&yaml_str, require_binary_identity)?; engine .add_data_json(&data_json) .map_err(|e| miette::miette!("{e}"))?; @@ -160,11 +206,24 @@ impl OpaEngine { /// /// Preprocesses the YAML data to expand access presets and validate L7 config. pub fn from_strings(policy: &str, data_yaml: &str) -> Result { + Self::from_strings_with_binary_identity_required( + policy, + data_yaml, + network_binary_identity_required(), + ) + } + + pub(crate) fn from_strings_with_binary_identity_required( + policy: &str, + data_yaml: &str, + require_binary_identity: bool, + ) -> Result { let mut engine = regorus::Engine::new(); engine .add_policy("policy.rego".into(), policy.into()) .map_err(|e| miette::miette!("{e}"))?; - let data_json = preprocess_yaml_data(data_yaml)?; + emit_binary_identity_mode(require_binary_identity, "strings"); + let data_json = preprocess_yaml_data(data_yaml, require_binary_identity)?; engine .add_data_json(&data_json) .map_err(|e| miette::miette!("{e}"))?; @@ -193,11 +252,25 @@ impl OpaEngine { /// gap between user-specified symlink paths (e.g., `/usr/bin/python3`) and /// kernel-resolved canonical paths (e.g., `/usr/bin/python3.11`). pub fn from_proto_with_pid(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> Result { + Self::from_proto_with_pid_and_binary_identity_required( + proto, + entrypoint_pid, + network_binary_identity_required(), + ) + } + + fn from_proto_with_pid_and_binary_identity_required( + proto: &ProtoSandboxPolicy, + entrypoint_pid: u32, + require_binary_identity: bool, + ) -> Result { + emit_binary_identity_mode(require_binary_identity, "proto"); let data_json_str = proto_to_opa_data_json(proto, entrypoint_pid); // Parse back to Value for preprocessing, then re-serialize let mut data: serde_json::Value = serde_json::from_str(&data_json_str) .map_err(|e| miette::miette!("internal: failed to parse proto JSON: {e}"))?; + inject_runtime_policy_data(&mut data, require_binary_identity); // Validate BEFORE expanding presets let (errors, warnings) = crate::l7::validate_l7_policies(&data); @@ -720,9 +793,10 @@ fn parse_process_policy(val: ®orus::Value) -> ProcessPolicy { } /// Preprocess YAML policy data: parse, normalize, validate, expand access presets, return JSON. -fn preprocess_yaml_data(yaml_str: &str) -> Result { +fn preprocess_yaml_data(yaml_str: &str, require_binary_identity: bool) -> Result { let mut data: serde_json::Value = serde_yml::from_str(yaml_str) .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; + inject_runtime_policy_data(&mut data, require_binary_identity); // Normalize port → ports for all endpoints so Rego always sees "ports" array. normalize_endpoint_ports(&mut data); @@ -2298,6 +2372,88 @@ process: assert!(eval_l7(&engine, &input)); } + #[test] + fn l7_get_allowed_by_rules_when_binary_identity_relaxed() { + let engine = + OpaEngine::from_strings_with_binary_identity_required(TEST_POLICY, L7_TEST_DATA, false) + .expect("Failed to load relaxed L7 test data"); + let mut input = l7_input("api.example.com", 8080, "GET", "/repos/myorg/foo"); + input["exec"]["path"] = "".into(); + assert!(eval_l7(&engine, &input)); + } + + #[test] + fn relaxed_binary_identity_preserves_matched_policy_and_l7_for_proto() { + let mut network_policies = std::collections::HashMap::new(); + network_policies.insert( + "test_l7".to_string(), + NetworkPolicyRule { + name: "test_l7".to_string(), + endpoints: vec![NetworkEndpoint { + host: "host.k3d.internal".to_string(), + port: 56123, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + rules: vec![L7Rule { + allow: Some(L7Allow { + method: "GET".to_string(), + path: "/allowed".to_string(), + command: String::new(), + query: std::collections::HashMap::new(), + operation_type: String::new(), + operation_name: String::new(), + fields: Vec::new(), + params: std::collections::HashMap::new(), + }), + }], + allowed_ips: vec!["192.168.0.0/16".to_string()], + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + ..Default::default() + }], + }, + ); + let proto = ProtoSandboxPolicy { + version: 1, + filesystem: Some(ProtoFs { + include_workdir: true, + read_only: vec![], + read_write: vec![], + }), + landlock: Some(openshell_core::proto::LandlockPolicy { + compatibility: "best_effort".to_string(), + }), + process: Some(ProtoProc { + run_as_user: "sandbox".to_string(), + run_as_group: "sandbox".to_string(), + }), + network_policies, + }; + let engine = OpaEngine::from_proto_with_pid_and_binary_identity_required(&proto, 0, false) + .expect("engine from relaxed proto"); + let network_input = NetworkInput { + host: "host.k3d.internal".into(), + port: 56123, + binary_path: PathBuf::new(), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let action = engine.evaluate_network_action(&network_input).unwrap(); + assert_eq!( + action, + NetworkAction::Allow { + matched_policy: Some("test_l7".to_string()) + } + ); + + let mut input = l7_input("host.k3d.internal", 56123, "GET", "/allowed"); + input["exec"]["path"] = "".into(); + assert!(eval_l7(&engine, &input)); + } + #[test] fn l7_post_allowed_by_rules() { let engine = l7_engine(); @@ -4626,6 +4782,46 @@ process: ); } + #[test] + fn relaxed_binary_identity_allows_declared_endpoint_without_binary_match() { + let engine = OpaEngine::from_strings_with_binary_identity_required( + TEST_POLICY, + INFERENCE_TEST_DATA, + false, + ) + .expect("Failed to load relaxed binary identity test data"); + let input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/tmp/unlisted-agent"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let action = engine.evaluate_network_action(&input).unwrap(); + assert_eq!( + action, + NetworkAction::Allow { + matched_policy: Some("claude_code".to_string()) + }, + ); + assert!( + engine.query_exact_declared_endpoint_host(&input).unwrap(), + "relaxed identity should preserve exact declared endpoint handling" + ); + + let undeclared = NetworkInput { + host: "api.openai.com".into(), + ..input + }; + let action = engine.evaluate_network_action(&undeclared).unwrap(); + assert!( + matches!(action, NetworkAction::Deny { .. }), + "relaxed identity must not allow undeclared endpoints" + ); + } + #[test] fn unknown_endpoint_returns_deny() { let engine = inference_engine(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 6d60536362..c0c33e6fd1 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -42,6 +42,8 @@ const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from_millis(1); const INFERENCE_LOCAL_HOST: &str = "inference.local"; const INFERENCE_LOCAL_PORT: u16 = 443; +#[cfg(target_os = "linux")] +const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar"; /// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF @@ -1426,7 +1428,7 @@ fn resolve_owner_identity( })?; let bin_hash = identity_cache - .verify_or_cache(&bin_path) + .verify_or_cache_process_exe(&bin_path, owner_pid) .map_err(|e| IdentityError { reason: format!("binary integrity check failed: {e}"), binary: Some(bin_path.clone()), @@ -1434,11 +1436,15 @@ fn resolve_owner_identity( ancestors: vec![], })?; - let ancestors = crate::procfs::collect_ancestor_binaries(owner_pid, entrypoint_pid); + let ancestor_identities = collect_ancestor_identities(owner_pid, entrypoint_pid); + let ancestors: Vec = ancestor_identities + .iter() + .map(|(_, path)| path.clone()) + .collect(); - for ancestor in &ancestors { + for (ancestor_pid, ancestor) in &ancestor_identities { identity_cache - .verify_or_cache(ancestor) + .verify_or_cache_process_exe(ancestor, *ancestor_pid) .map_err(|e| IdentityError { reason: format!( "ancestor integrity check failed for {}: {e}", @@ -1463,6 +1469,31 @@ fn resolve_owner_identity( }) } +#[cfg(target_os = "linux")] +fn collect_ancestor_identities(start_pid: u32, stop_pid: u32) -> Vec<(u32, PathBuf)> { + const MAX_DEPTH: usize = 64; + let mut ancestors = Vec::new(); + let mut current = start_pid; + + for _ in 0..MAX_DEPTH { + let parent_pid = match crate::procfs::read_ppid(current) { + Some(parent) if parent > 0 && parent != current => parent, + _ => break, + }; + + if let Ok(path) = crate::procfs::binary_path(parent_pid.cast_signed()) { + ancestors.push((parent_pid, path)); + } + + if parent_pid == stop_pid || parent_pid == 1 { + break; + } + current = parent_pid; + } + + ancestors +} + /// Resolve the identity of the process owning a TCP peer connection. /// /// Walks `/proc//net/tcp` to find the socket inode, locates @@ -1472,10 +1503,10 @@ fn resolve_owner_identity( /// /// This is the identity-resolution block of [`evaluate_opa_tcp`] extracted /// into a standalone helper so it can be exercised by Linux-only regression -/// tests without a full OPA engine. The key invariant under test is that on -/// a hot-swap of the peer binary, the failure mode is -/// `"Binary integrity violation"` (from the identity cache) rather than -/// `"Failed to stat ... (deleted)"` (from the kernel-tainted path). +/// tests without a full OPA engine. The key hot-swap invariant under test is +/// that display paths are stripped for policy/logging, while integrity hashing +/// reads the live executable via `/proc//exe` instead of the replacement +/// file that now exists at the display path. #[cfg(target_os = "linux")] fn resolve_process_identity( entrypoint_pid: u32, @@ -1573,8 +1604,17 @@ fn evaluate_opa_tcp( } }; - let pid = entrypoint_pid.load(Ordering::Acquire); - if pid == 0 { + if !crate::opa::network_binary_identity_required() { + let result = evaluate_endpoint_only_opa(engine, host, port); + debug!( + "evaluate_opa_tcp endpoint-only: host={host} port={port} action={:?}", + result.action + ); + return result; + } + + let entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); + let Some(proc_net_anchor_pid) = proc_net_anchor_pid(entrypoint_pid) else { return deny( "entrypoint process not yet spawned".into(), None, @@ -1582,12 +1622,12 @@ fn evaluate_opa_tcp( vec![], vec![], ); - } + }; let total_start = std::time::Instant::now(); let peer_port = peer_addr.port(); - let identity = match resolve_process_identity(pid, peer_port, identity_cache) { + let identity = match resolve_process_identity(proc_net_anchor_pid, peer_port, identity_cache) { Ok(id) => id, Err(err) => { return deny( @@ -1641,6 +1681,52 @@ fn evaluate_opa_tcp( result } +#[cfg(target_os = "linux")] +fn proc_net_anchor_pid(entrypoint_pid: u32) -> Option { + if entrypoint_pid != 0 { + return Some(entrypoint_pid); + } + sidecar_topology_enabled().then(std::process::id) +} + +#[cfg(target_os = "linux")] +fn sidecar_topology_enabled() -> bool { + std::env::var(openshell_core::sandbox_env::SUPERVISOR_TOPOLOGY) + .is_ok_and(|value| value == SIDECAR_SUPERVISOR_TOPOLOGY) +} + +fn evaluate_endpoint_only_opa(engine: &OpaEngine, host: &str, port: u16) -> ConnectDecision { + let input = crate::opa::NetworkInput { + host: host.to_string(), + port, + binary_path: PathBuf::new(), + binary_sha256: String::new(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + match engine.evaluate_network_action_with_generation(&input) { + Ok((action, generation)) => ConnectDecision { + action, + generation, + binary: None, + binary_pid: None, + ancestors: vec![], + cmdline_paths: vec![], + }, + Err(e) => ConnectDecision { + action: NetworkAction::Deny { + reason: format!("policy evaluation error: {e}"), + }, + generation: engine.current_generation(), + binary: None, + binary_pid: None, + ancestors: vec![], + cmdline_paths: vec![], + }, + } +} + /// Non-Linux stub: OPA identity binding requires /proc. #[cfg(not(target_os = "linux"))] fn evaluate_opa_tcp( @@ -1648,9 +1734,13 @@ fn evaluate_opa_tcp( engine: &OpaEngine, _identity_cache: &BinaryIdentityCache, _entrypoint_pid: &AtomicU32, - _host: &str, - _port: u16, + host: &str, + port: u16, ) -> ConnectDecision { + if !crate::opa::network_binary_identity_required() { + return evaluate_endpoint_only_opa(engine, host, port); + } + ConnectDecision { action: NetworkAction::Deny { reason: "identity binding unavailable on this platform".into(), @@ -2152,14 +2242,24 @@ fn query_l7_route_snapshot( }; match engine.query_endpoint_configs_with_generation(&input) { - Ok((vals, generation)) => Some(L7RouteSnapshot { - configs: vals + Ok((vals, generation)) => { + let configs: Vec<_> = vals .into_iter() .filter_map(|val| crate::l7::parse_l7_config(&val)) .map(|config| L7ConfigSnapshot { config }) - .collect(), - generation, - }), + .collect(); + debug!( + host, + port, + generation, + config_count = configs.len(), + "Forward proxy L7 route lookup complete" + ); + Some(L7RouteSnapshot { + configs, + generation, + }) + } Err(e) => { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) @@ -3337,10 +3437,29 @@ async fn handle_forward_proxy( } }; let policy_str = matched_policy.as_deref().unwrap_or("-"); + debug!( + host = %host_lc, + port, + binary = %binary_str, + binary_pid = %pid_str, + matched_policy = %policy_str, + decision_generation = decision.generation, + current_generation = opa_engine.current_generation(), + action = ?decision.action, + "Forward proxy L4 policy decision" + ); let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let forward_generation_guard = match opa_engine.generation_guard(decision.generation) { Ok(guard) => guard, Err(e) => { + warn!( + host = %host_lc, + port, + decision_generation = decision.generation, + current_generation = opa_engine.current_generation(), + error = %e, + "Forward proxy rejected request because policy generation changed after L4 decision" + ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( @@ -3401,6 +3520,15 @@ async fn handle_forward_proxy( && !route.configs.is_empty() { if route.generation != forward_generation_guard.captured_generation() { + warn!( + host = %host_lc, + port, + decision_generation = decision.generation, + guard_generation = forward_generation_guard.captured_generation(), + route_generation = route.generation, + current_generation = opa_engine.current_generation(), + "Forward proxy rejected request because L7 route lookup used a different policy generation" + ); emit_l7_tunnel_close_after_policy_change( &host_lc, port, @@ -3426,6 +3554,14 @@ async fn handle_forward_proxy( let tunnel_engine = match opa_engine.clone_engine_for_tunnel(route.generation) { Ok(engine) => engine, Err(e) => { + warn!( + host = %host_lc, + port, + route_generation = route.generation, + current_generation = opa_engine.current_generation(), + error = %e, + "Forward proxy rejected request because L7 tunnel engine could not be cloned" + ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( @@ -4105,6 +4241,14 @@ async fn handle_forward_proxy( }; if let Err(e) = forward_generation_guard.ensure_current() { + warn!( + host = %host_lc, + port, + captured_generation = forward_generation_guard.captured_generation(), + current_generation = forward_generation_guard.current_generation(), + error = %e, + "Forward proxy rejected request because policy changed before upstream connect" + ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( @@ -4243,6 +4387,14 @@ async fn handle_forward_proxy( }; if let Err(e) = forward_generation_guard.ensure_current() { + warn!( + host = %host_lc, + port, + captured_generation = forward_generation_guard.captured_generation(), + current_generation = forward_generation_guard.current_generation(), + error = %e, + "Forward proxy rejected request because policy changed before relay" + ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); respond( client, @@ -4379,6 +4531,46 @@ mod tests { use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + #[test] + fn endpoint_only_opa_allows_declared_endpoint_without_process_identity() { + let policy = include_str!("../data/sandbox-policy.rego"); + let data = r#" +version: 1 +network_policies: + test_l7: + name: test_l7 + endpoints: + - host: host.k3d.internal + port: 56123 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: /allowed + binaries: + - path: /usr/bin/curl +"#; + let engine = OpaEngine::from_strings_with_binary_identity_required(policy, data, false) + .expect("relaxed engine"); + + let decision = evaluate_endpoint_only_opa(&engine, "host.k3d.internal", 56123); + assert_eq!( + decision.action, + NetworkAction::Allow { + matched_policy: Some("test_l7".to_string()), + } + ); + assert!(decision.binary.is_none()); + assert!(decision.ancestors.is_empty()); + + let denied = evaluate_endpoint_only_opa(&engine, "api.example.com", 443); + assert!( + matches!(denied.action, NetworkAction::Deny { .. }), + "endpoint-only mode must still deny undeclared endpoints" + ); + } + fn websocket_l7_config( protocol: crate::l7::L7Protocol, websocket_credential_rewrite: bool, @@ -7992,27 +8184,23 @@ network_policies: assert_eq!(resp_str[body_start..].len(), cl); } - /// End-to-end regression for the `docker cp` hot-swap hazard that - /// motivated `binary_path()` stripping the kernel's `" (deleted)"` - /// suffix (PR #844). + /// End-to-end regression for the `docker cp` hot-swap hazard around + /// unlinked process executables. /// - /// Before the strip, the identity-resolution chain inside - /// `evaluate_opa_tcp` failed with `"Failed to stat - /// /opt/openshell/bin/openshell-sandbox (deleted)"` because - /// `BinaryIdentityCache::verify_or_cache()` tried to `metadata()` the - /// tainted path. That masked the real security signal: a live process - /// was now bound to a *different* binary on disk than the one that was - /// TOFU-cached. After the strip, `binary_path()` returns a path that - /// stats fine, the cache rehashes the new bytes, and the hash mismatch - /// surfaces as a `Binary integrity violation` error — the contract this - /// PR is trying to establish. + /// `binary_path()` strips the kernel's `" (deleted)"` suffix so policy + /// identity and logs use a clean display path. Integrity verification must + /// not hash that display path after a hot-swap, because it may now point to + /// unrelated replacement bytes. It hashes `/proc//exe` instead, which + /// resolves to the live executable inode even after the original path was + /// unlinked. /// /// Test shape (from the review comment on the initial PR): /// 1. Start a `TcpListener` in the test process. /// 2. Copy `/bin/bash` to a temp path we control. /// 3. Prime `BinaryIdentityCache` with that temp binary's hash. /// 4. Spawn the temp bash as a child with a `/dev/tcp` one-liner that - /// opens a real TCP connection to the listener and holds it open. + /// opens a real TCP connection to the listener and holds it open + /// inside the bash process. /// 5. Accept the connection on the listener side and capture the peer's /// ephemeral port — that's what `resolve_process_identity` uses to /// walk `/proc/net/tcp` back to the child PID. @@ -8022,13 +8210,12 @@ network_policies: /// now readlink to `" (deleted)"` OR the overwritten file, depending /// on whether the filesystem reused the inode. /// 7. Call `resolve_process_identity` and assert: - /// - the error reason contains `"Binary integrity violation"` (the - /// cache detected the tampered on-disk bytes), and - /// - the error reason does NOT contain `"Failed to stat"` or - /// `"(deleted)"` (the old pre-strip failure mode). + /// - identity resolution succeeds using the live executable hash, and + /// - the returned display path does not contain the kernel-added + /// `"(deleted)"` suffix. #[cfg(target_os = "linux")] #[test] - fn resolve_process_identity_surfaces_binary_integrity_violation_on_hot_swap() { + fn resolve_process_identity_hashes_live_exe_after_hot_swap() { use crate::identity::BinaryIdentityCache; use std::io::Read; use std::net::TcpListener; @@ -8060,9 +8247,12 @@ network_policies: assert!(!v1_hash.is_empty()); // 4. Spawn the temp bash with a /dev/tcp one-liner that opens a real - // connection to the listener and sleeps to keep it open. The - // `read -t` blocks on stdin so the shell stays resident. - let script = format!("exec 3<>/dev/tcp/127.0.0.1/{listener_port}; sleep 30 <&3"); + // connection to the listener and blocks in bash's `read` builtin + // to keep it open. Do not use an external command like `sleep`: + // it inherits the socket fd and intentionally trips the shared + // socket ambiguity guard instead of exercising the hot-swap path. + let script = + format!("exec 3<>/dev/tcp/127.0.0.1/{listener_port}; read -r -t 30 _ <&3 || true"); let mut child = Command::new(&bash_v1) .arg("-c") .arg(&script) @@ -8106,10 +8296,11 @@ network_policies: std::fs::write(&bash_v1, tampered_bytes).expect("write replacement bytes"); // 7. Resolve identity through the real helper and assert the - // contract: we want "Binary integrity violation", not - // "Failed to stat ... (deleted)". + // contract: hash the live executable via /proc//exe while + // returning a clean display path for policy/logging. let test_pid = std::process::id(); let result = resolve_process_identity(test_pid, peer_port, &cache); + let child_pid = child.id(); // Always clean up the child before asserting so a failure doesn't // leak a sleeping process across test runs. @@ -8117,40 +8308,29 @@ network_policies: let _ = child.wait(); match result { - Ok(_) => panic!( - "resolve_process_identity unexpectedly succeeded after hot-swap; \ - the cache should have detected the tampered on-disk bytes" - ), - Err(err) => { - assert!( - err.reason.contains("Binary integrity violation"), - "expected 'Binary integrity violation' error, got: {}", - err.reason + Ok(identity) => { + assert_eq!( + identity.binary_pid, child_pid, + "expected the hot-swapped bash child to own the socket" ); - assert!( - !err.reason.contains("Failed to stat"), - "pre-PR-#844 failure mode leaked: {}", - err.reason + assert_eq!( + identity.bin_path, bash_v1, + "expected stripped display path to remain the original binary path" ); assert!( - !err.reason.contains("(deleted)"), - "resolved path still contains '(deleted)' suffix: {}", - err.reason + !identity.bin_path.to_string_lossy().contains("(deleted)"), + "resolved binary path still tainted: {}", + identity.bin_path.display() ); - // The binary field should be populated — we did resolve a - // path before failing. - assert!( - err.binary.is_some(), - "expected resolved binary path on integrity failure" + assert_eq!( + identity.bin_hash, v1_hash, + "expected integrity hash from the live executable, not replacement bytes" ); - if let Some(path) = &err.binary { - assert!( - !path.to_string_lossy().contains("(deleted)"), - "resolved binary path still tainted: {}", - path.display() - ); - } } + Err(err) => panic!( + "resolve_process_identity failed after hot-swap; expected live-exe identity: {}", + err.reason + ), } } diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 9553e06736..8e17758bd6 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -201,7 +201,9 @@ pub async fn run_networking( let (tls_state, ca_file_paths) = if matches!(policy.network.mode, NetworkMode::Proxy) { match SandboxCa::generate() { Ok(ca) => { - let tls_dir = std::path::Path::new("/etc/openshell-tls"); + let tls_dir = std::env::var(openshell_core::sandbox_env::PROXY_TLS_DIR) + .unwrap_or_else(|_| "/etc/openshell-tls".to_string()); + let tls_dir = std::path::Path::new(&tls_dir); let system_ca_bundle = read_system_ca_bundle(); match write_ca_files(&ca, tls_dir, &system_ca_bundle) { Ok(paths) => { diff --git a/crates/openshell-supervisor-process/src/lib.rs b/crates/openshell-supervisor-process/src/lib.rs index 1a3efd733d..842b62f9df 100644 --- a/crates/openshell-supervisor-process/src/lib.rs +++ b/crates/openshell-supervisor-process/src/lib.rs @@ -19,6 +19,8 @@ pub mod skills; pub mod ssh; pub mod supervisor_session; +mod unix_socket; + #[cfg(target_os = "linux")] pub mod bypass_monitor; #[cfg(target_os = "linux")] diff --git a/crates/openshell-supervisor-process/src/netns/mod.rs b/crates/openshell-supervisor-process/src/netns/mod.rs index cc7b1d84ce..44e9470931 100644 --- a/crates/openshell-supervisor-process/src/netns/mod.rs +++ b/crates/openshell-supervisor-process/src/netns/mod.rs @@ -285,43 +285,22 @@ impl NetworkNamespace { // monitor can see log entries from the sandbox namespace. enable_nf_log_all_netns(); - // Try combined ruleset with log rules first. Log rules must appear - // before reject rules in the chain so packets are logged before being - // rejected. If the kernel lacks nft_log support, fall back to the - // reject-only ruleset. - let ruleset_with_log = - nft_ruleset::generate_bypass_ruleset(&host_ip_str, proxy_port, Some(&log_prefix)); - - if let Err(e) = run_nft_netns(&self.name, &nft_path, &ruleset_with_log) { + let commands = + nft_ruleset::generate_bypass_commands(&host_ip_str, proxy_port, Some(&log_prefix)); + + if let Err(e) = run_nft_commands_netns(&self.name, &nft_path, &commands) { openshell_ocsf::ocsf_emit!( openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Low) + .severity(openshell_ocsf::SeverityId::Medium) .status(openshell_ocsf::StatusId::Failure) - .state(openshell_ocsf::StateId::Other, "degraded") + .state(openshell_ocsf::StateId::Disabled, "failed") .message(format!( - "Failed to install bypass log rules (non-fatal), falling back to reject-only [ns:{}]: {e}", + "Failed to install bypass detection rules [ns:{}]: {e}", self.name )) .build() ); - - let ruleset_no_log = - nft_ruleset::generate_bypass_ruleset(&host_ip_str, proxy_port, None); - - if let Err(e) = run_nft_netns(&self.name, &nft_path, &ruleset_no_log) { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Medium) - .status(openshell_ocsf::StatusId::Failure) - .state(openshell_ocsf::StateId::Disabled, "failed") - .message(format!( - "Failed to install bypass detection rules [ns:{}]: {e}", - self.name - )) - .build() - ); - return Err(e); - } + return Err(e); } openshell_ocsf::ocsf_emit!( @@ -467,6 +446,193 @@ pub fn create_netns_for_proxy( } } +/// Install pod-network bypass enforcement for Kubernetes sidecar topology. +/// +/// This runs in the current network namespace, not in a per-workload netns. +/// The rules allow loopback and the sidecar proxy UID, then reject direct +/// TCP/UDP egress from other UIDs so traffic must use the sidecar's local +/// proxy. +/// +/// # Errors +/// +/// Returns an error when `nft` is unavailable or the ruleset cannot be loaded. +pub fn install_sidecar_bypass_rules(proxy_uid: u32) -> Result<()> { + match install_sidecar_nft_bypass_rules(proxy_uid) { + Ok(()) => Ok(()), + Err(nft_error) => { + warn!( + error = %nft_error, + "Failed to install nftables sidecar rules; trying iptables-legacy fallback" + ); + install_sidecar_iptables_legacy_bypass_rules(proxy_uid).map_err(|iptables_error| { + miette::miette!( + "sidecar nft ruleset load failed: {nft_error}; sidecar iptables-legacy fallback failed: {iptables_error}" + ) + }) + } + } +} + +fn install_sidecar_nft_bypass_rules(proxy_uid: u32) -> Result<()> { + let nft_cmd = find_nft().ok_or_else(|| { + miette::miette!( + "trusted nft helper not found; sidecar network enforcement requires nftables" + ) + })?; + let log_prefix = Some("openshell:sidecar-bypass:"); + let commands = nft_ruleset::generate_sidecar_bypass_commands(proxy_uid, log_prefix); + run_nft_commands_current_namespace(&nft_cmd, &commands) +} + +const SIDECAR_IPTABLES_CHAIN: &str = "OPENSHELL_SIDECAR_BYPASS"; +const PROC_NET_IF_INET6_PATH: &str = "/proc/net/if_inet6"; + +fn install_sidecar_iptables_legacy_bypass_rules(proxy_uid: u32) -> Result<()> { + let ipv4_filter_tool = find_iptables_legacy().ok_or_else(|| { + miette::miette!( + "trusted iptables-legacy helper not found; sidecar network enforcement fallback unavailable" + ) + })?; + + let ipv6_fence_tool = if current_namespace_has_non_loopback_ipv6()? { + Some(find_ip6tables_legacy().ok_or_else(|| { + miette::miette!( + "trusted ip6tables-legacy helper not found; sidecar network enforcement fallback cannot fence IPv6" + ) + })?) + } else { + warn!( + "Skipping IPv6 sidecar iptables-legacy fallback because the current namespace has no non-loopback IPv6 interface" + ); + None + }; + + cleanup_sidecar_iptables_legacy_rule_families(&ipv4_filter_tool, ipv6_fence_tool.as_deref()); + + if let Err(e) = install_sidecar_iptables_legacy_family_rules( + &ipv4_filter_tool, + proxy_uid, + "icmp-port-unreachable", + ) { + cleanup_sidecar_iptables_legacy_rule_families( + &ipv4_filter_tool, + ipv6_fence_tool.as_deref(), + ); + return Err(e); + } + + if let Some(ipv6_fence_tool) = ipv6_fence_tool + && let Err(e) = install_sidecar_iptables_legacy_family_rules( + &ipv6_fence_tool, + proxy_uid, + "icmp6-port-unreachable", + ) + { + cleanup_sidecar_iptables_legacy_rule_families(&ipv4_filter_tool, Some(&ipv6_fence_tool)); + return Err(e); + } + + Ok(()) +} + +fn current_namespace_has_non_loopback_ipv6() -> Result { + match std::fs::read_to_string(PROC_NET_IF_INET6_PATH) { + Ok(content) => Ok(has_non_loopback_ipv6_interface(&content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(miette::miette!( + "failed to inspect {PROC_NET_IF_INET6_PATH} before installing sidecar IPv6 fence: {e}" + )), + } +} + +fn has_non_loopback_ipv6_interface(content: &str) -> bool { + content.lines().any(|line| { + line.split_whitespace() + .nth(5) + .is_some_and(|iface| iface != "lo") + }) +} + +fn install_sidecar_iptables_legacy_family_rules( + cmd: &str, + proxy_uid: u32, + udp_reject_with: &str, +) -> Result<()> { + let proxy_uid_arg = proxy_uid.to_string(); + let commands: Vec> = vec![ + vec!["-N", SIDECAR_IPTABLES_CHAIN], + vec!["-A", SIDECAR_IPTABLES_CHAIN, "-o", "lo", "-j", "ACCEPT"], + vec![ + "-A", + SIDECAR_IPTABLES_CHAIN, + "-m", + "conntrack", + "--ctstate", + "ESTABLISHED,RELATED", + "-j", + "ACCEPT", + ], + vec![ + "-A", + SIDECAR_IPTABLES_CHAIN, + "-m", + "owner", + "--uid-owner", + &proxy_uid_arg, + "-j", + "ACCEPT", + ], + vec![ + "-A", + SIDECAR_IPTABLES_CHAIN, + "-p", + "tcp", + "-j", + "REJECT", + "--reject-with", + "tcp-reset", + ], + vec![ + "-A", + SIDECAR_IPTABLES_CHAIN, + "-p", + "udp", + "-j", + "REJECT", + "--reject-with", + udp_reject_with, + ], + vec!["-A", "OUTPUT", "-j", SIDECAR_IPTABLES_CHAIN], + ]; + + for args in commands { + if let Err(e) = run_iptables_legacy_current_namespace(cmd, &args) { + cleanup_sidecar_iptables_legacy_rules(cmd); + return Err(e); + } + } + + Ok(()) +} + +fn cleanup_sidecar_iptables_legacy_rules(iptables_cmd: &str) { + while run_iptables_legacy_current_namespace( + iptables_cmd, + &["-D", "OUTPUT", "-j", SIDECAR_IPTABLES_CHAIN], + ) + .is_ok() + {} + let _ = run_iptables_legacy_current_namespace(iptables_cmd, &["-F", SIDECAR_IPTABLES_CHAIN]); + let _ = run_iptables_legacy_current_namespace(iptables_cmd, &["-X", SIDECAR_IPTABLES_CHAIN]); +} + +fn cleanup_sidecar_iptables_legacy_rule_families(ipv4_cmd: &str, ipv6_cmd: Option<&str>) { + cleanup_sidecar_iptables_legacy_rules(ipv4_cmd); + if let Some(ipv6_cmd) = ipv6_cmd { + cleanup_sidecar_iptables_legacy_rules(ipv6_cmd); + } +} + /// Run an `ip` command on the host. fn run_ip(args: &[&str]) -> Result<()> { let ip_path = find_trusted_binary("ip", IP_SEARCH_PATHS)?; @@ -490,6 +656,68 @@ fn run_ip(args: &[&str]) -> Result<()> { Ok(()) } +fn run_iptables_legacy_current_namespace(iptables_cmd: &str, args: &[&str]) -> Result<()> { + debug!( + command = %format!("{iptables_cmd} {}", args.join(" ")), + "Running iptables-legacy sidecar command" + ); + + let output = Command::new(iptables_cmd) + .args(args) + .output() + .into_diagnostic()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(miette::miette!( + "{iptables_cmd} {} failed: {}", + args.join(" "), + stderr.trim() + )); + } + + Ok(()) +} + +/// Run a sequence of nft commands in the current network namespace. +/// +/// Each command is executed as a separate `nft` invocation to avoid atomic +/// batch rollback (where one unsupported expression like `ct state` or `log` +/// causes the entire transaction, including table creation, to fail). +/// +/// Commands marked as non-required are allowed to fail with a warning. +/// Required commands that fail abort the sequence immediately. +fn run_nft_commands_current_namespace( + nft_cmd: &str, + commands: &[nft_ruleset::NftCommand], +) -> Result<()> { + for cmd in commands { + let args_str = cmd.args.join(" "); + debug!(command = %format!("{nft_cmd} {args_str}"), "Running nft command"); + + let output = Command::new(nft_cmd) + .args(&cmd.args) + .output() + .into_diagnostic()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if cmd.required { + return Err(miette::miette!( + "{nft_cmd} {args_str} failed: {}", + stderr.trim() + )); + } + warn!( + command = %args_str, + error = %stderr.trim(), + "non-required nft command failed (continuing)" + ); + } + } + Ok(()) +} + /// Run an `ip` command inside a network namespace via `nsenter --net=`. /// /// We use `nsenter` instead of `ip netns exec` because `ip netns exec` @@ -532,47 +760,51 @@ fn run_ip_netns(netns: &str, args: &[&str]) -> Result<()> { Ok(()) } -/// Load an nftables ruleset inside a network namespace via `nsenter --net=`. +/// Run a sequence of nft commands inside a network namespace via `nsenter --net=`. /// -/// Writes the ruleset to a temp file and loads it with `nft -f `. -/// A temp file is used instead of piping to stdin (`nft -f -`) because -/// `nft` resolves `-` to `/dev/stdin`, which may not exist in minimal -/// VM guest environments (e.g. virtiofs rootfs without /proc mounted -/// at nft invocation time). -fn run_nft_netns(netns: &str, nft_cmd: &str, ruleset: &str) -> Result<()> { - use std::io::Write; - let mut tmp = tempfile::Builder::new() - .prefix("openshell-nft-") - .suffix(".conf") - .tempfile() - .into_diagnostic()?; - tmp.write_all(ruleset.as_bytes()).into_diagnostic()?; - let ruleset_path = tmp.path().to_string_lossy().to_string(); - +/// Each command is executed as a separate invocation to avoid atomic batch +/// rollback. See [`run_nft_commands_current_namespace`] for rationale. +fn run_nft_commands_netns( + netns: &str, + nft_cmd: &str, + commands: &[nft_ruleset::NftCommand], +) -> Result<()> { let nsenter_path = find_trusted_binary("nsenter", NSENTER_SEARCH_PATHS)?; let ns_path = format!("/var/run/netns/{netns}"); let net_flag = format!("--net={ns_path}"); - debug!( - command = %format!("{nsenter_path} {net_flag} -- {nft_cmd} -f {ruleset_path}"), - "Loading nftables ruleset in namespace" - ); + for cmd in commands { + let args_str = cmd.args.join(" "); + debug!( + command = %format!("{nsenter_path} {net_flag} -- {nft_cmd} {args_str}"), + "Running nft command in namespace" + ); - let output = Command::new(nsenter_path) - .args([net_flag.as_str(), "--", nft_cmd, "-f", &ruleset_path]) - .output() - .into_diagnostic()?; + let mut full_args = vec![net_flag.as_str(), "--", nft_cmd]; + let arg_refs: Vec<&str> = cmd.args.iter().map(String::as_str).collect(); + full_args.extend(&arg_refs); - drop(tmp); + let output = Command::new(nsenter_path) + .args(&full_args) + .output() + .into_diagnostic()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(miette::miette!( - "nft ruleset load failed in netns {netns}: {}", - stderr.trim() - )); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if cmd.required { + return Err(miette::miette!( + "nft {args_str} failed in netns {netns}: {}", + stderr.trim() + )); + } + warn!( + command = %args_str, + error = %stderr.trim(), + netns = %netns, + "non-required nft command failed in namespace (continuing)" + ); + } } - Ok(()) } @@ -605,6 +837,16 @@ fn enable_nf_log_all_netns() { /// Well-known paths where nft may be installed. const NFT_SEARCH_PATHS: &[&str] = &["/usr/sbin/nft", "/sbin/nft", "/usr/bin/nft"]; +const IPTABLES_LEGACY_SEARCH_PATHS: &[&str] = &[ + "/usr/sbin/iptables-legacy", + "/sbin/iptables-legacy", + "/usr/bin/iptables-legacy", +]; +const IP6TABLES_LEGACY_SEARCH_PATHS: &[&str] = &[ + "/usr/sbin/ip6tables-legacy", + "/sbin/ip6tables-legacy", + "/usr/bin/ip6tables-legacy", +]; fn find_trusted_binary<'a>(name: &str, paths: &'a [&str]) -> Result<&'a str> { paths @@ -629,6 +871,18 @@ fn find_nft() -> Option { .map(String::from) } +fn find_iptables_legacy() -> Option { + find_trusted_binary("iptables-legacy", IPTABLES_LEGACY_SEARCH_PATHS) + .ok() + .map(String::from) +} + +fn find_ip6tables_legacy() -> Option { + find_trusted_binary("ip6tables-legacy", IP6TABLES_LEGACY_SEARCH_PATHS) + .ok() + .map(String::from) +} + #[cfg(test)] mod tests { use super::*; @@ -668,6 +922,49 @@ mod tests { } } + #[test] + fn iptables_legacy_search_paths_are_absolute() { + for path in IPTABLES_LEGACY_SEARCH_PATHS { + assert!( + path.starts_with('/'), + "IPTABLES_LEGACY_SEARCH_PATHS entry must be absolute: {path}" + ); + } + } + + #[test] + fn ip6tables_legacy_search_paths_are_absolute() { + for path in IP6TABLES_LEGACY_SEARCH_PATHS { + assert!( + path.starts_with('/'), + "IP6TABLES_LEGACY_SEARCH_PATHS entry must be absolute: {path}" + ); + } + } + + #[test] + fn non_loopback_ipv6_detector_ignores_empty_input() { + assert!(!has_non_loopback_ipv6_interface("")); + assert!(!has_non_loopback_ipv6_interface("\n\n")); + } + + #[test] + fn non_loopback_ipv6_detector_ignores_loopback() { + let content = "00000000000000000000000000000001 01 80 10 80 lo\n"; + + assert!(!has_non_loopback_ipv6_interface(content)); + } + + #[test] + fn non_loopback_ipv6_detector_detects_pod_interface() { + let content = "\ +00000000000000000000000000000001 01 80 10 80 lo +fe800000000000000000000000000001 02 40 20 80 eth0 +"; + + assert!(has_non_loopback_ipv6_interface(content)); + } + #[test] #[ignore = "requires root privileges"] fn test_create_and_drop_namespace() { diff --git a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs index ba7aeb9368..25b4549ab5 100644 --- a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs +++ b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs @@ -6,85 +6,417 @@ //! This module provides pure functions to generate nftables rulesets that enforce //! the sandbox network policy: all traffic must go through the proxy, with bypass //! attempts logged and rejected. +//! +//! Rulesets are returned as a sequence of individual nft commands rather than a +//! monolithic file. Running each command as a separate `nft` invocation avoids +//! `nft -f` atomic batch semantics, where a single unsupported expression (e.g. +//! `ct state` without `nf_conntrack`, `log` without `nf_log`) rolls back the +//! entire transaction including table/chain creation. + +/// A single nft command with metadata about whether it is required. +pub struct NftCommand { + /// The nft command arguments (e.g. `["add", "table", "inet", "openshell_bypass"]`). + pub args: Vec, + /// When false, failure of this command is non-fatal; the caller should + /// log a warning and continue with the remaining commands. + pub required: bool, +} -/// Generate a complete nftables ruleset for sandbox network bypass enforcement. +/// Generate nft commands for sandbox network bypass enforcement. /// /// Creates an `inet` family table (handles both IPv4 and IPv6) with rules that: /// 1. Accept traffic to the proxy (IPv4 only) /// 2. Accept loopback traffic -/// 3. Accept established/related connections +/// 3. Accept established/related connections (optional; requires `nf_conntrack`) /// 4. Reject TCP and UDP bypass attempts (both IPv4 and IPv6) /// /// If `log_prefix` is provided, log rules are inserted before each reject rule /// so that bypass attempts are recorded in the kernel ring buffer before being -/// rejected. The `log` expression requires kernel `nft_log` module support; -/// pass `None` for `log_prefix` as a fallback when that module is unavailable. -pub fn generate_bypass_ruleset(host_ip: &str, proxy_port: u16, log_prefix: Option<&str>) -> String { - let log_tcp = log_prefix - .map(|p| { - format!( - "\n tcp flags syn limit rate 5/second burst 10 packets log prefix \"{p}\" flags skuid" - ) - }) - .unwrap_or_default(); - let log_udp = log_prefix - .map(|p| { - format!( - "\n meta l4proto udp limit rate 5/second burst 10 packets log prefix \"{p}\" flags skuid" - ) - }) - .unwrap_or_default(); - - format!( - r#"table inet openshell_bypass {{ - chain output {{ - type filter hook output priority 0; policy accept; - - ip daddr {host_ip} tcp dport {proxy_port} accept - oifname "lo" accept - ct state established,related accept{log_tcp} - meta nfproto ipv4 meta l4proto tcp reject with icmp type port-unreachable - meta nfproto ipv6 meta l4proto tcp reject with icmpv6 type port-unreachable{log_udp} - meta nfproto ipv4 meta l4proto udp reject with icmp type port-unreachable - meta nfproto ipv6 meta l4proto udp reject with icmpv6 type port-unreachable - }} -}} -"# - ) +/// rejected. Log rules are always non-required since they need `nf_log` support. +pub fn generate_bypass_commands( + host_ip: &str, + proxy_port: u16, + log_prefix: Option<&str>, +) -> Vec { + let table = "openshell_bypass"; + let mut cmds = vec![ + nft_cmd(true, &["add", "table", "inet", table]), + nft_cmd(true, &["flush", "table", "inet", table]), + nft_cmd( + true, + &[ + "add", + "chain", + "inet", + table, + "output", + "{ type filter hook output priority 0; policy accept; }", + ], + ), + nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "ip", + "daddr", + host_ip, + "tcp", + "dport", + &proxy_port.to_string(), + "accept", + ], + ), + nft_cmd( + true, + &[ + "add", "rule", "inet", table, "output", "oifname", "lo", "accept", + ], + ), + nft_cmd( + false, + &[ + "add", + "rule", + "inet", + table, + "output", + "ct", + "state", + "established,related", + "accept", + ], + ), + ]; + + if let Some(prefix) = log_prefix { + cmds.push(nft_cmd( + false, + &[ + "add", "rule", "inet", table, "output", "tcp", "flags", "syn", "limit", "rate", + "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", + ], + )); + } + + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv4", + "meta", + "l4proto", + "tcp", + "reject", + "with", + "icmp", + "type", + "port-unreachable", + ], + )); + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv6", + "meta", + "l4proto", + "tcp", + "reject", + "with", + "icmpv6", + "type", + "port-unreachable", + ], + )); + + if let Some(prefix) = log_prefix { + cmds.push(nft_cmd( + false, + &[ + "add", "rule", "inet", table, "output", "meta", "l4proto", "udp", "limit", "rate", + "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", + ], + )); + } + + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv4", + "meta", + "l4proto", + "udp", + "reject", + "with", + "icmp", + "type", + "port-unreachable", + ], + )); + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv6", + "meta", + "l4proto", + "udp", + "reject", + "with", + "icmpv6", + "type", + "port-unreachable", + ], + )); + + cmds +} + +/// Generate nft commands for Kubernetes sidecar enforcement. +/// +/// The network sidecar and the process supervisor share a pod network +/// namespace. The sidecar runs as `proxy_uid` and owns external egress; +/// sandbox traffic must use loopback services hosted by that sidecar +/// (gateway forward and HTTP CONNECT proxy). The generated fence rejects +/// TCP/UDP bypass attempts from non-proxy UIDs; other L4 protocols are outside +/// the sidecar policy fence. +pub fn generate_sidecar_bypass_commands( + proxy_uid: u32, + log_prefix: Option<&str>, +) -> Vec { + let table = "openshell_sidecar_bypass"; + let uid_str = proxy_uid.to_string(); + let mut cmds = vec![ + nft_cmd(true, &["add", "table", "inet", table]), + nft_cmd(true, &["flush", "table", "inet", table]), + nft_cmd( + true, + &[ + "add", + "chain", + "inet", + table, + "output", + "{ type filter hook output priority 0; policy accept; }", + ], + ), + nft_cmd( + true, + &[ + "add", "rule", "inet", table, "output", "oifname", "lo", "accept", + ], + ), + nft_cmd( + false, + &[ + "add", + "rule", + "inet", + table, + "output", + "ct", + "state", + "established,related", + "accept", + ], + ), + nft_cmd( + true, + &[ + "add", "rule", "inet", table, "output", "meta", "skuid", &uid_str, "accept", + ], + ), + ]; + + if let Some(prefix) = log_prefix { + cmds.push(nft_cmd( + false, + &[ + "add", "rule", "inet", table, "output", "tcp", "flags", "syn", "limit", "rate", + "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", + ], + )); + } + + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv4", + "meta", + "l4proto", + "tcp", + "reject", + "with", + "icmp", + "type", + "port-unreachable", + ], + )); + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv6", + "meta", + "l4proto", + "tcp", + "reject", + "with", + "icmpv6", + "type", + "port-unreachable", + ], + )); + + if let Some(prefix) = log_prefix { + cmds.push(nft_cmd( + false, + &[ + "add", "rule", "inet", table, "output", "meta", "l4proto", "udp", "limit", "rate", + "5/second", "burst", "10", "packets", "log", "prefix", prefix, "flags", "skuid", + ], + )); + } + + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv4", + "meta", + "l4proto", + "udp", + "reject", + "with", + "icmp", + "type", + "port-unreachable", + ], + )); + cmds.push(nft_cmd( + true, + &[ + "add", + "rule", + "inet", + table, + "output", + "meta", + "nfproto", + "ipv6", + "meta", + "l4proto", + "udp", + "reject", + "with", + "icmpv6", + "type", + "port-unreachable", + ], + )); + + cmds +} + +fn nft_cmd(required: bool, args: &[&str]) -> NftCommand { + NftCommand { + args: args.iter().map(|s| (*s).to_string()).collect(), + required, + } } #[cfg(test)] mod tests { use super::*; + fn cmd_str(cmd: &NftCommand) -> String { + cmd.args.join(" ") + } + + fn all_strs(cmds: &[NftCommand]) -> String { + cmds.iter().map(cmd_str).collect::>().join("\n") + } + #[test] - fn generates_bypass_ruleset_with_proxy_rule() { - let ruleset = generate_bypass_ruleset("10.0.2.2", 8080, None); - assert!(ruleset.contains("table inet openshell_bypass")); - assert!(ruleset.contains("chain output")); - assert!(ruleset.contains("ip daddr 10.0.2.2 tcp dport 8080 accept")); + fn generates_bypass_commands_with_proxy_rule() { + let cmds = generate_bypass_commands("10.0.2.2", 8080, None); + let text = all_strs(&cmds); + assert!(text.contains("add table inet openshell_bypass")); + assert!(text.contains("add chain inet openshell_bypass output")); + assert!(text.contains("ip daddr 10.0.2.2 tcp dport 8080 accept")); } #[test] - fn ruleset_has_inet_family_table_and_output_chain() { - let ruleset = generate_bypass_ruleset("192.168.1.1", 3128, None); - assert!(ruleset.contains("table inet openshell_bypass")); - assert!(ruleset.contains("type filter hook output priority 0; policy accept;")); + fn bypass_commands_have_table_and_chain() { + let cmds = generate_bypass_commands("192.168.1.1", 3128, None); + let text = all_strs(&cmds); + assert!(text.contains("add table inet openshell_bypass")); + assert!(text.contains("type filter hook output priority 0; policy accept;")); } #[test] fn proxy_accept_rule_uses_provided_ip_and_port() { - let ruleset = generate_bypass_ruleset("172.16.0.1", 9999, None); - assert!(ruleset.contains("ip daddr 172.16.0.1 tcp dport 9999 accept")); + let cmds = generate_bypass_commands("172.16.0.1", 9999, None); + let text = all_strs(&cmds); + assert!(text.contains("ip daddr 172.16.0.1 tcp dport 9999 accept")); } #[test] fn rules_are_ordered_accept_then_reject() { - let ruleset = generate_bypass_ruleset("10.0.2.2", 8080, None); - let proxy_pos = ruleset.find("ip daddr").unwrap(); - let lo_pos = ruleset.find("oifname \"lo\"").unwrap(); - let ct_pos = ruleset.find("ct state established,related").unwrap(); - let reject_pos = ruleset.find("reject with icmp type").unwrap(); + let cmds = generate_bypass_commands("10.0.2.2", 8080, None); + let text = all_strs(&cmds); + let proxy_pos = text.find("ip daddr").unwrap(); + let lo_pos = text.find("oifname lo").unwrap(); + let ct_pos = text.find("ct state established,related").unwrap(); + let reject_pos = text.find("reject with icmp type").unwrap(); assert!(proxy_pos < lo_pos); assert!(lo_pos < ct_pos); @@ -93,11 +425,12 @@ mod tests { #[test] fn both_ipv4_and_ipv6_reject_types_are_present() { - let ruleset = generate_bypass_ruleset("10.0.2.2", 8080, None); - let icmp_count = ruleset + let cmds = generate_bypass_commands("10.0.2.2", 8080, None); + let text = all_strs(&cmds); + let icmp_count = text .matches("reject with icmp type port-unreachable") .count(); - let icmpv6_count = ruleset + let icmpv6_count = text .matches("reject with icmpv6 type port-unreachable") .count(); assert_eq!(icmp_count, 2, "need IPv4 ICMP rejects for TCP + UDP"); @@ -105,34 +438,35 @@ mod tests { } #[test] - fn no_log_ruleset_omits_log_rules() { - let ruleset = generate_bypass_ruleset("10.0.2.2", 8080, None); + fn no_log_commands_omit_log_rules() { + let cmds = generate_bypass_commands("10.0.2.2", 8080, None); + let text = all_strs(&cmds); assert!( - !ruleset.contains("log prefix"), - "no-log ruleset must not contain log rules" + !text.contains("log prefix"), + "no-log commands must not contain log rules" ); } #[test] - fn log_ruleset_contains_prefix_for_tcp_and_udp() { - let ruleset = generate_bypass_ruleset("10.0.2.2", 8080, Some("openshell:bypass:test:")); - let count = ruleset - .matches("log prefix \"openshell:bypass:test:\"") - .count(); + fn log_commands_contain_prefix_for_tcp_and_udp() { + let cmds = generate_bypass_commands("10.0.2.2", 8080, Some("openshell:bypass:test:")); + let text = all_strs(&cmds); + let count = text.matches("log prefix openshell:bypass:test:").count(); assert_eq!(count, 2, "need log rules for both TCP and UDP"); - assert!(ruleset.contains("tcp flags syn limit rate 5/second burst 10 packets")); - assert!(ruleset.contains("meta l4proto udp limit rate 5/second burst 10 packets")); + assert!(text.contains("tcp flags syn limit rate 5/second burst 10 packets")); + assert!(text.contains("meta l4proto udp limit rate 5/second burst 10 packets")); } #[test] fn log_rules_appear_before_reject_rules() { - let ruleset = generate_bypass_ruleset("10.0.2.2", 8080, Some("openshell:bypass:test:")); - let tcp_log_pos = ruleset.find("tcp flags syn").unwrap(); - let tcp_reject_pos = ruleset + let cmds = generate_bypass_commands("10.0.2.2", 8080, Some("openshell:bypass:test:")); + let text = all_strs(&cmds); + let tcp_log_pos = text.find("tcp flags syn").unwrap(); + let tcp_reject_pos = text .find("meta nfproto ipv4 meta l4proto tcp reject") .unwrap(); - let udp_log_pos = ruleset.find("meta l4proto udp limit rate").unwrap(); - let udp_reject_pos = ruleset + let udp_log_pos = text.find("meta l4proto udp limit rate").unwrap(); + let udp_reject_pos = text .find("meta nfproto ipv4 meta l4proto udp reject") .unwrap(); @@ -145,4 +479,53 @@ mod tests { "UDP log rule must come before UDP reject rule" ); } + + #[test] + fn ct_state_rule_is_not_required() { + let cmds = generate_bypass_commands("10.0.2.2", 8080, None); + let ct_cmd = cmds + .iter() + .find(|c| cmd_str(c).contains("ct state")) + .unwrap(); + assert!( + !ct_cmd.required, + "ct state rule should be non-required (needs nf_conntrack)" + ); + } + + #[test] + fn log_rules_are_not_required() { + let cmds = generate_bypass_commands("10.0.2.2", 8080, Some("openshell:bypass:test:")); + for cmd in &cmds { + if cmd_str(cmd).contains("log prefix") { + assert!( + !cmd.required, + "log rules should be non-required (needs nf_log)" + ); + } + } + } + + #[test] + fn sidecar_commands_allow_supervisor_uid_and_loopback() { + let cmds = generate_sidecar_bypass_commands(1337, None); + let text = all_strs(&cmds); + assert!(text.contains("add table inet openshell_sidecar_bypass")); + assert!(text.contains("oifname lo accept")); + assert!(text.contains("meta skuid 1337 accept")); + } + + #[test] + fn sidecar_commands_reject_tcp_and_udp_egress() { + let cmds = generate_sidecar_bypass_commands(0, Some("openshell:sidecar:test:")); + let text = all_strs(&cmds); + assert!(text.contains("meta nfproto ipv4 meta l4proto tcp reject")); + assert!(text.contains("meta nfproto ipv6 meta l4proto tcp reject")); + assert!(text.contains("meta nfproto ipv4 meta l4proto udp reject")); + assert!(text.contains("meta nfproto ipv6 meta l4proto udp reject")); + assert_eq!( + text.matches("log prefix openshell:sidecar:test:").count(), + 2 + ); + } } diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index fcd7ae69c1..72effa98f8 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -28,10 +28,55 @@ use std::sync::OnceLock; use tokio::process::{Child, Command}; use tracing::{debug, info}; +/// Process/filesystem enforcement performed by the process supervisor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessEnforcementMode { + /// Preserve the existing supervisor behavior: prepare filesystem policy, + /// drop privileges, and apply Landlock/seccomp to workload processes. + Full, + /// Preserve process launch and SSH/session behavior, but skip controls + /// that require root or extra Linux capabilities. Kubernetes sidecar mode + /// uses this when network policy is enforced by the network sidecar. + NetworkOnly, +} + +impl ProcessEnforcementMode { + #[must_use] + pub const fn uses_privileged_process_setup(self) -> bool { + matches!(self, Self::Full) + } + + #[must_use] + pub const fn enforces_child_sandbox(self) -> bool { + matches!(self, Self::Full | Self::NetworkOnly) + } +} + +#[cfg(target_os = "linux")] +pub(crate) fn prepare_child_sandbox( + policy: &SandboxPolicy, + workdir: Option<&str>, + enforcement_mode: ProcessEnforcementMode, +) -> Result> { + if !enforcement_mode.enforces_child_sandbox() { + return Ok(None); + } + + let prepared = if enforcement_mode.uses_privileged_process_setup() { + sandbox::linux::prepare(policy, workdir) + } else { + sandbox::linux::prepare_current_user(policy, workdir) + }?; + Ok(Some(prepared)) +} + const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ openshell_core::sandbox_env::SANDBOX_TOKEN, openshell_core::sandbox_env::SANDBOX_TOKEN_FILE, openshell_core::sandbox_env::K8S_SA_TOKEN_FILE, + openshell_core::sandbox_env::TLS_CA, + openshell_core::sandbox_env::TLS_CERT, + openshell_core::sandbox_env::TLS_KEY, openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, ]; @@ -443,6 +488,7 @@ impl ProcessHandle { workdir: Option<&str>, interactive: bool, policy: &SandboxPolicy, + enforcement_mode: ProcessEnforcementMode, netns: Option<&NetworkNamespace>, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, @@ -453,6 +499,7 @@ impl ProcessHandle { workdir, interactive, policy, + enforcement_mode, netns.and_then(NetworkNamespace::ns_fd), ca_paths, provider_env, @@ -465,12 +512,14 @@ impl ProcessHandle { /// /// Returns an error if the process fails to start. #[cfg(not(target_os = "linux"))] + #[allow(clippy::too_many_arguments)] pub fn spawn( program: &str, args: &[String], workdir: Option<&str>, interactive: bool, policy: &SandboxPolicy, + enforcement_mode: ProcessEnforcementMode, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, ) -> Result { @@ -480,6 +529,7 @@ impl ProcessHandle { workdir, interactive, policy, + enforcement_mode, ca_paths, provider_env, ) @@ -493,6 +543,7 @@ impl ProcessHandle { workdir: Option<&str>, interactive: bool, policy: &SandboxPolicy, + enforcement_mode: ProcessEnforcementMode, netns_fd: Option, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, @@ -552,18 +603,26 @@ impl ProcessHandle { // process where the tracing subscriber is functional. The child's // pre_exec context cannot reliably emit structured logs. #[cfg(target_os = "linux")] - sandbox::linux::log_sandbox_readiness(policy, workdir); + if enforcement_mode.enforces_child_sandbox() { + sandbox::linux::log_sandbox_readiness(policy, workdir); + } - // Phase 1 (as root): Prepare Landlock ruleset by opening PathFds. - // This MUST happen before drop_privileges() so that root-only paths - // (e.g. mode 700 directories) can be opened. See issue #803. + // Phase 1: Prepare Landlock ruleset by opening PathFds. + // In full mode this runs before drop_privileges() so root-only paths + // can be opened. In sidecar network-only mode the container already + // runs as the sandbox UID, so inaccessible paths are unavailable to + // the workload and best-effort compatibility skips them. #[cfg(target_os = "linux")] - let prepared_sandbox = sandbox::linux::prepare(policy, workdir) + let prepared_sandbox = prepare_child_sandbox(policy, workdir, enforcement_mode) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; #[cfg(target_os = "linux")] - let supervisor_identity_mount = supervisor_identity_mount_from_env().map_err(|err| { - miette::miette!("Failed to prepare supervisor identity isolation: {err}") - })?; + 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 @@ -575,7 +634,7 @@ impl ProcessHandle { // 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_sandbox = Some(prepared_sandbox); + let mut prepared_sandbox = prepared_sandbox; #[allow(unsafe_code)] unsafe { cmd.pre_exec(move || { @@ -600,8 +659,10 @@ impl ProcessHandle { // Drop privileges. initgroups/setgid/setuid need access to // /etc/group and /etc/passwd which would be blocked if // Landlock were already enforced. - drop_privileges(&policy) - .map_err(|err| std::io::Error::other(err.to_string()))?; + if enforcement_mode.uses_privileged_process_setup() { + drop_privileges(&policy) + .map_err(|err| std::io::Error::other(err.to_string()))?; + } harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?; @@ -629,12 +690,14 @@ impl ProcessHandle { } #[cfg(not(target_os = "linux"))] + #[allow(clippy::too_many_arguments)] fn spawn_impl( program: &str, args: &[String], workdir: Option<&str>, interactive: bool, policy: &SandboxPolicy, + enforcement_mode: ProcessEnforcementMode, ca_paths: Option<&(PathBuf, PathBuf)>, provider_env: &HashMap, ) -> Result { @@ -697,13 +760,17 @@ impl ProcessHandle { // Drop privileges before applying sandbox restrictions. // initgroups/setgid/setuid need access to /etc/group and /etc/passwd // which may be blocked by Landlock. - drop_privileges(&policy) - .map_err(|err| std::io::Error::other(err.to_string()))?; + if enforcement_mode.uses_privileged_process_setup() { + drop_privileges(&policy) + .map_err(|err| std::io::Error::other(err.to_string()))?; + } harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?; - sandbox::apply(&policy, workdir.as_deref()) - .map_err(|err| std::io::Error::other(err.to_string()))?; + if enforcement_mode.enforces_child_sandbox() { + sandbox::apply(&policy, workdir.as_deref()) + .map_err(|err| std::io::Error::other(err.to_string()))?; + } Ok(()) }); @@ -1456,6 +1523,18 @@ mod tests { ); } + #[test] + fn full_enforcement_uses_privileged_setup_and_child_sandbox() { + assert!(ProcessEnforcementMode::Full.uses_privileged_process_setup()); + assert!(ProcessEnforcementMode::Full.enforces_child_sandbox()); + } + + #[test] + fn network_only_enforcement_keeps_child_sandbox_without_privileged_setup() { + assert!(!ProcessEnforcementMode::NetworkOnly.uses_privileged_process_setup()); + assert!(ProcessEnforcementMode::NetworkOnly.enforces_child_sandbox()); + } + #[cfg(target_os = "linux")] fn capability_bounding_set_clear_available() -> bool { capctl::caps::CapState::get_current() diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index e16f118922..5a06d97a8f 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -33,7 +33,7 @@ use openshell_core::denial::DenialEvent; #[cfg(target_os = "linux")] use crate::managed_children; -use crate::process::ProcessHandle; +use crate::process::{ProcessEnforcementMode, ProcessHandle}; fn ocsf_ctx() -> &'static openshell_ocsf::SandboxContext { openshell_ocsf::ctx::ctx() @@ -56,8 +56,11 @@ pub async fn run_process( sandbox_id: Option<&str>, openshell_endpoint: Option<&str>, ssh_socket_path: Option, + shared_ssh_socket: bool, policy: &SandboxPolicy, + enforcement_mode: ProcessEnforcementMode, entrypoint_pid: Arc, + entrypoint_started_tx: Option>, provider_credentials: ProviderCredentialState, provider_env: std::collections::HashMap, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, @@ -71,21 +74,26 @@ pub async fn run_process( // /etc/group so the "sandbox" entry matches. Must run before // validate_sandbox_user so passwd lookups see the correct identity. #[cfg(unix)] - crate::process::update_sandbox_passwd_entries()?; + if enforcement_mode.uses_privileged_process_setup() { + crate::process::update_sandbox_passwd_entries()?; + } // Validate that the sandbox user exists in the image. All sandbox images // must include a "sandbox" user for privilege dropping; failing fast here // beats silently running children as root. #[cfg(unix)] - crate::process::validate_sandbox_user(policy)?; - #[cfg(unix)] - crate::process::validate_sandbox_group(policy)?; + if enforcement_mode.uses_privileged_process_setup() { + crate::process::validate_sandbox_user(policy)?; + crate::process::validate_sandbox_group(policy)?; + } // Create read_write directories and chown newly-created ones to the // sandbox user/group. Runs as the supervisor (root) before the child // is forked so the workload sees writable paths it owns. #[cfg(unix)] - crate::process::prepare_filesystem(policy)?; + if enforcement_mode.uses_privileged_process_setup() { + crate::process::prepare_filesystem(policy)?; + } // Eagerly fetch initial settings and install the agent skill if the // proposals flag is on at startup, rather than waiting for the policy @@ -206,31 +214,10 @@ pub async fn run_process( // their env so cooperative tools (curl, npm, Node) route through the // CONNECT proxy. Linux uses the netns host_ip; on other targets fall back // to the policy-declared http_addr directly. - let ssh_proxy_url = if matches!(policy.network.mode, NetworkMode::Proxy) { - #[cfg(target_os = "linux")] - { - netns.map(|ns| { - let port = policy - .network - .proxy - .as_ref() - .and_then(|p| p.http_addr) - .map_or(3128, |addr| addr.port()); - format!("http://{}:{port}", ns.host_ip()) - }) - } - #[cfg(not(target_os = "linux"))] - { - policy - .network - .proxy - .as_ref() - .and_then(|p| p.http_addr) - .map(|addr| format!("http://{addr}")) - } - } else { - None - }; + #[cfg(target_os = "linux")] + let ssh_proxy_url = ssh_proxy_url_for_policy(policy, netns.map(NetworkNamespace::host_ip)); + #[cfg(not(target_os = "linux"))] + let ssh_proxy_url = ssh_proxy_url_for_policy(policy, None); let ssh_socket_path: Option = ssh_socket_path.map(std::path::PathBuf::from); if let Some(listen_path) = ssh_socket_path.clone() { @@ -259,6 +246,8 @@ pub async fn run_process( ca_paths, provider_credentials_clone, user_env_clone, + enforcement_mode, + shared_ssh_socket, ) .await { @@ -314,6 +303,7 @@ pub async fn run_process( id.to_string(), socket.clone(), ssh_netns_fd, + None, ); info!("supervisor session task spawned"); } @@ -325,6 +315,7 @@ pub async fn run_process( workdir, interactive, policy, + enforcement_mode, netns, ca_file_paths.as_ref(), &provider_env, @@ -337,12 +328,16 @@ pub async fn run_process( workdir, interactive, policy, + enforcement_mode, ca_file_paths.as_ref(), &provider_env, )?; // Store the entrypoint PID so the proxy can resolve TCP peer identity entrypoint_pid.store(handle.pid(), Ordering::Release); + if let Some(tx) = entrypoint_started_tx { + let _ = tx.send(handle.pid()); + } ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) .activity(ActivityId::Open) @@ -395,6 +390,23 @@ pub async fn run_process( Ok(status.code()) } +fn ssh_proxy_url_for_policy( + policy: &SandboxPolicy, + netns_proxy_host: Option, +) -> Option { + if !matches!(policy.network.mode, NetworkMode::Proxy) { + return None; + } + + let proxy = policy.network.proxy.as_ref()?; + if let Some(host) = netns_proxy_host { + let port = proxy.http_addr.map_or(3128, |addr| addr.port()); + return Some(format!("http://{host}:{port}")); + } + + proxy.http_addr.map(|addr| format!("http://{addr}")) +} + /// Eagerly fetch initial settings and install the agent-driven policy /// proposal skill if the flag is on at startup. /// @@ -451,3 +463,53 @@ async fn install_initial_agent_skill(sandbox_id: Option<&str>, openshell_endpoin ); } } + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::policy::{ + FilesystemPolicy, LandlockPolicy, NetworkMode, NetworkPolicy, ProcessPolicy, ProxyPolicy, + }; + + fn policy(mode: NetworkMode, http_addr: Option) -> SandboxPolicy { + SandboxPolicy { + version: 1, + filesystem: FilesystemPolicy::default(), + network: NetworkPolicy { + mode, + proxy: http_addr.map(|http_addr| ProxyPolicy { + http_addr: Some(http_addr), + }), + }, + landlock: LandlockPolicy::default(), + process: ProcessPolicy::default(), + } + } + + #[test] + fn ssh_proxy_url_uses_policy_addr_without_netns() { + let policy = policy(NetworkMode::Proxy, Some(([127, 0, 0, 1], 3128).into())); + + assert_eq!( + ssh_proxy_url_for_policy(&policy, None).as_deref(), + Some("http://127.0.0.1:3128") + ); + } + + #[test] + fn ssh_proxy_url_prefers_netns_host_with_policy_port() { + let policy = policy(NetworkMode::Proxy, Some(([127, 0, 0, 1], 8080).into())); + + assert_eq!( + ssh_proxy_url_for_policy(&policy, Some([10, 200, 0, 1].into())).as_deref(), + Some("http://10.200.0.1:8080") + ); + } + + #[test] + fn ssh_proxy_url_skips_non_proxy_mode() { + let policy = policy(NetworkMode::Allow, Some(([127, 0, 0, 1], 3128).into())); + + assert_eq!(ssh_proxy_url_for_policy(&policy, None), None); + } +} diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs b/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs index 8808a1a87a..fb82cf02fd 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs @@ -95,6 +95,12 @@ pub struct PreparedRuleset { compatibility: LandlockCompatibility, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathOpenMode { + Privileged, + CurrentUser, +} + /// Phase 1: Open `PathFds` and build the Landlock ruleset **as root**. /// /// This must run before `drop_privileges()` so that `PathFd::new()` can open @@ -103,6 +109,27 @@ pub struct PreparedRuleset { /// Returns `None` if there are no filesystem paths to restrict (no-op). /// Returns `Some(PreparedRuleset)` on success, or an error. pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result> { + prepare_with_path_open_mode(policy, workdir, PathOpenMode::Privileged) +} + +/// Phase 1 for already-unprivileged workloads. +/// +/// Kubernetes sidecar mode starts the process supervisor as the sandbox UID, so +/// Landlock path FDs are opened as the same UID that will run the workload. +/// Paths this UID cannot open are already unavailable to the workload; omit +/// them from the allowlist and let the resulting ruleset deny everything else. +pub fn prepare_current_user( + policy: &SandboxPolicy, + workdir: Option<&str>, +) -> Result> { + prepare_with_path_open_mode(policy, workdir, PathOpenMode::CurrentUser) +} + +fn prepare_with_path_open_mode( + policy: &SandboxPolicy, + workdir: Option<&str>, + path_open_mode: PathOpenMode, +) -> Result> { let read_only = policy.filesystem.read_only.clone(); let mut read_write = policy.filesystem.read_write.clone(); @@ -188,7 +215,7 @@ pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result) -> Result) -> Result<()> { /// /// In `HardRequirement` mode, any failure is fatal — the caller propagates the /// error, which ultimately aborts sandbox startup. -fn try_open_path(path: &Path, compatibility: &LandlockCompatibility) -> Result> { +fn try_open_path( + path: &Path, + compatibility: &LandlockCompatibility, + path_open_mode: PathOpenMode, +) -> Result> { match PathFd::new(path) { Ok(fd) => Ok(Some(fd)), Err(err) => { @@ -335,6 +366,28 @@ fn try_open_path(path: &Path, compatibility: &LandlockCompatibility) -> Result { // NotFound is expected for stale baseline paths (e.g. @@ -421,6 +474,7 @@ mod tests { let result = try_open_path( &PathBuf::from("/nonexistent/openshell/test/path"), &LandlockCompatibility::BestEffort, + PathOpenMode::Privileged, ); assert!(result.is_ok()); assert!(result.unwrap().is_none()); @@ -431,6 +485,7 @@ mod tests { let result = try_open_path( &PathBuf::from("/nonexistent/openshell/test/path"), &LandlockCompatibility::HardRequirement, + PathOpenMode::Privileged, ); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); @@ -447,11 +502,26 @@ mod tests { #[test] fn try_open_path_succeeds_for_existing_path() { let dir = tempfile::tempdir().unwrap(); - let result = try_open_path(dir.path(), &LandlockCompatibility::BestEffort); + let result = try_open_path( + dir.path(), + &LandlockCompatibility::BestEffort, + PathOpenMode::Privileged, + ); assert!(result.is_ok()); assert!(result.unwrap().is_some()); } + #[test] + fn try_open_path_current_user_skips_missing_path_in_hard_requirement() { + let result = try_open_path( + &PathBuf::from("/nonexistent/openshell/test/path"), + &LandlockCompatibility::HardRequirement, + PathOpenMode::CurrentUser, + ); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + #[test] fn classify_not_found() { let err = std::io::Error::from_raw_os_error(libc::ENOENT); diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs index b5397ef079..107a50e370 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; use std::sync::Once; /// Opaque handle to a prepared-but-not-yet-enforced sandbox. -/// Holds the Landlock ruleset with `PathFds` opened as root. +/// Holds the Landlock ruleset with `PathFds` opened before child exec. pub struct PreparedSandbox { landlock: Option, policy: SandboxPolicy, @@ -30,6 +30,21 @@ pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result, +) -> Result { + let landlock = landlock::prepare_current_user(policy, workdir)?; + Ok(PreparedSandbox { + landlock, + policy: policy.clone(), + }) +} + /// Phase 2: Enforce prepared sandbox restrictions (after `drop_privileges`). /// /// Calls `restrict_self()` for Landlock and applies seccomp filters. diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 62d10f374d..f5a3ee0793 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -6,7 +6,7 @@ use crate::child_env; #[cfg(target_os = "linux")] use crate::managed_children; -use crate::process::{drop_privileges, is_supervisor_only_env_var}; +use crate::process::{ProcessEnforcementMode, drop_privileges, is_supervisor_only_env_var}; use crate::sandbox; use miette::{IntoDiagnostic, Result}; use nix::pty::{Winsize, openpty}; @@ -42,6 +42,8 @@ type SshServerInit = ( fn ssh_server_init( listen_path: &Path, ca_file_paths: &Option<(PathBuf, PathBuf)>, + enforcement_mode: ProcessEnforcementMode, + shared_socket: bool, ) -> Result { let mut rng = OsRng; let host_key = PrivateKey::random(&mut rng, Algorithm::Ed25519).into_diagnostic()?; @@ -55,13 +57,17 @@ fn ssh_server_init( let config = Arc::new(config); let ca_paths = ca_file_paths.as_ref().map(|p| Arc::new(p.clone())); - // Ensure the parent directory exists and is root-owned with 0700 - // permissions. The sandbox entrypoint runs as an unprivileged user; it - // must not be able to enter this directory and connect to the socket. - if let Some(parent) = listen_path.parent() { + // In full enforcement mode the supervisor normally starts as root and can + // isolate the SSH socket in a root-only directory before spawning + // unprivileged children. Sidecar topology is different: the gateway relay + // runs in the network sidecar as a different UID, so the shared sidecar + // state directory must stay group-accessible. Sidecar mode uses a Linux + // abstract socket instead, so the workload cannot unlink the relay target. + let abstract_socket = crate::unix_socket::is_abstract(listen_path); + if !abstract_socket && let Some(parent) = listen_path.parent() { std::fs::create_dir_all(parent).into_diagnostic()?; #[cfg(unix)] - { + if enforcement_mode.uses_privileged_process_setup() && !shared_socket { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o700); std::fs::set_permissions(parent, perms).into_diagnostic()?; @@ -69,19 +75,19 @@ fn ssh_server_init( } // Remove any stale socket from a previous run before binding. - if listen_path.exists() { + if !abstract_socket && listen_path.exists() { std::fs::remove_file(listen_path).into_diagnostic()?; } - let listener = UnixListener::bind(listen_path).into_diagnostic()?; + let runtime_path = crate::unix_socket::runtime_path(listen_path); + let listener = UnixListener::bind(runtime_path.as_ref()).into_diagnostic()?; - // Tighten permissions so only the supervisor (root) can connect. The - // sandbox entrypoint runs as an unprivileged user and must not be able to - // dial the SSH daemon directly — all access goes through the relay from - // the gateway. + // Tighten filesystem-socket permissions. Abstract sockets have no inode; + // sidecar relay connections authenticate the listener with SO_PEERCRED. #[cfg(unix)] - { + if !abstract_socket { use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); + let mode = if shared_socket { 0o660 } else { 0o600 }; + let perms = std::fs::Permissions::from_mode(mode); std::fs::set_permissions(listen_path, perms).into_diagnostic()?; } @@ -108,8 +114,15 @@ pub async fn run_ssh_server( ca_file_paths: Option<(PathBuf, PathBuf)>, provider_credentials: ProviderCredentialState, user_environment: HashMap, + enforcement_mode: ProcessEnforcementMode, + shared_socket: bool, ) -> Result<()> { - let (listener, config, ca_paths) = match ssh_server_init(&listen_path, &ca_file_paths) { + let (listener, config, ca_paths) = match ssh_server_init( + &listen_path, + &ca_file_paths, + enforcement_mode, + shared_socket, + ) { Ok(v) => { // Signal that the SSH server has bound the socket and is ready to // accept connections. The parent task awaits this before spawning @@ -145,6 +158,7 @@ pub async fn run_ssh_server( ca_paths, provider_credentials, user_environment, + enforcement_mode, ) .await { @@ -172,6 +186,7 @@ async fn handle_connection( ca_file_paths: Option>, provider_credentials: ProviderCredentialState, user_environment: HashMap, + enforcement_mode: ProcessEnforcementMode, ) -> Result<()> { // Access is gated by the Unix-socket filesystem permissions (root-only), // not by an application-level preface. The supervisor bridges the @@ -195,6 +210,7 @@ async fn handle_connection( ca_file_paths, provider_credentials, user_environment, + enforcement_mode, ); russh::server::run_stream(config, stream, handler) .await @@ -223,6 +239,7 @@ struct SshHandler { ca_file_paths: Option>, provider_credentials: ProviderCredentialState, user_environment: HashMap, + enforcement_mode: ProcessEnforcementMode, channels: HashMap, } @@ -236,6 +253,7 @@ impl SshHandler { ca_file_paths: Option>, provider_credentials: ProviderCredentialState, user_environment: HashMap, + enforcement_mode: ProcessEnforcementMode, ) -> Self { Self { policy, @@ -245,6 +263,7 @@ impl SshHandler { ca_file_paths, provider_credentials, user_environment, + enforcement_mode, channels: HashMap::new(), } } @@ -468,6 +487,7 @@ impl russh::server::Handler for SshHandler { self.ca_file_paths.clone(), &self.provider_credentials.child_env_with_gcp_resolved(), &self.user_environment, + self.enforcement_mode, )?; let state = self.channels.get_mut(&channel).ok_or_else(|| { anyhow::anyhow!("subsystem_request on unknown channel {channel:?}") @@ -564,6 +584,7 @@ impl SshHandler { self.ca_file_paths.clone(), &provider_env, &self.user_environment, + self.enforcement_mode, )?; state.pty_master = Some(pty_master); state.input_sender = Some(input_sender); @@ -582,6 +603,7 @@ impl SshHandler { self.ca_file_paths.clone(), &provider_env, &self.user_environment, + self.enforcement_mode, )?; state.input_sender = Some(input_sender); } @@ -748,6 +770,7 @@ fn spawn_pty_shell( ca_file_paths: Option>, provider_env: &HashMap, user_environment: &HashMap, + enforcement_mode: ProcessEnforcementMode, ) -> anyhow::Result<(std::fs::File, mpsc::Sender>)> { let winsize = Winsize { ws_row: to_u16(pty.row_height.max(1)), @@ -806,12 +829,15 @@ fn spawn_pty_shell( // Probe Landlock availability from the parent process where tracing works. #[cfg(target_os = "linux")] - sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref()); + if enforcement_mode.enforces_child_sandbox() { + sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref()); + } - // Phase 1 (as root): Prepare Landlock ruleset before drop_privileges. + // Phase 1: Prepare Landlock ruleset before the child applies it. #[cfg(target_os = "linux")] - let prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref()) - .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; + let prepared_sandbox = + crate::process::prepare_child_sandbox(policy, workdir.as_deref(), enforcement_mode) + .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; #[cfg(unix)] { @@ -821,6 +847,7 @@ fn spawn_pty_shell( workdir.clone(), slave_fd, netns_fd, + enforcement_mode, #[cfg(target_os = "linux")] prepared_sandbox, )?; @@ -913,6 +940,7 @@ fn spawn_pipe_exec( ca_file_paths: Option>, provider_env: &HashMap, user_environment: &HashMap, + enforcement_mode: ProcessEnforcementMode, ) -> anyhow::Result>> { let mut cmd = command.map_or_else( || { @@ -955,12 +983,15 @@ fn spawn_pipe_exec( // Probe Landlock availability from the parent process where tracing works. #[cfg(target_os = "linux")] - sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref()); + if enforcement_mode.enforces_child_sandbox() { + sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref()); + } - // Phase 1 (as root): Prepare Landlock ruleset before drop_privileges. + // Phase 1: Prepare Landlock ruleset before the child applies it. #[cfg(target_os = "linux")] - let prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref()) - .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; + let prepared_sandbox = + crate::process::prepare_child_sandbox(policy, workdir.as_deref(), enforcement_mode) + .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?; #[cfg(unix)] { @@ -969,6 +1000,7 @@ fn spawn_pipe_exec( policy.clone(), workdir.clone(), netns_fd, + enforcement_mode, #[cfg(target_os = "linux")] prepared_sandbox, )?; @@ -1068,7 +1100,9 @@ fn spawn_pipe_exec( mod unsafe_pty { #[cfg(not(target_os = "linux"))] use super::sandbox; - use super::{Command, RawFd, SandboxPolicy, Winsize, drop_privileges, setsid}; + use super::{ + Command, ProcessEnforcementMode, RawFd, SandboxPolicy, Winsize, drop_privileges, setsid, + }; #[cfg(unix)] use std::os::unix::process::CommandExt; @@ -1107,17 +1141,21 @@ mod unsafe_pty { _workdir: Option, slave_fd: RawFd, netns_fd: Option, - #[cfg(target_os = "linux")] prepared: crate::sandbox::linux::PreparedSandbox, + 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 = Some(prepared); + let mut prepared = prepared; #[cfg(target_os = "linux")] - let supervisor_identity_mount = crate::process::supervisor_identity_mount_from_env() - .map_err(|err| { + 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()))?; @@ -1126,6 +1164,7 @@ mod unsafe_pty { enter_netns_and_sandbox( netns_fd, &policy, + enforcement_mode, #[cfg(target_os = "linux")] supervisor_identity_mount, #[cfg(target_os = "linux")] @@ -1152,20 +1191,25 @@ mod unsafe_pty { policy: SandboxPolicy, _workdir: Option, netns_fd: Option, - #[cfg(target_os = "linux")] prepared: crate::sandbox::linux::PreparedSandbox, + enforcement_mode: ProcessEnforcementMode, + #[cfg(target_os = "linux")] prepared: Option, ) -> anyhow::Result<()> { #[cfg(target_os = "linux")] - let mut prepared = Some(prepared); + let mut prepared = prepared; #[cfg(target_os = "linux")] - let supervisor_identity_mount = crate::process::supervisor_identity_mount_from_env() - .map_err(|err| { + 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( netns_fd, &policy, + enforcement_mode, #[cfg(target_os = "linux")] supervisor_identity_mount, #[cfg(target_os = "linux")] @@ -1179,6 +1223,7 @@ mod unsafe_pty { fn enter_netns_and_sandbox( netns_fd: Option, policy: &SandboxPolicy, + enforcement_mode: ProcessEnforcementMode, #[cfg(target_os = "linux")] supervisor_identity_mount: Option< &crate::process::SupervisorIdentityMountNamespace, >, @@ -1207,7 +1252,9 @@ mod unsafe_pty { // Drop privileges. initgroups/setgid/setuid need /etc/group and // /etc/passwd which would be blocked if Landlock were already enforced. - drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?; + if enforcement_mode.uses_privileged_process_setup() { + drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?; + } crate::process::harden_child_process() .map_err(|err| std::io::Error::other(err.to_string()))?; @@ -1220,7 +1267,9 @@ mod unsafe_pty { } #[cfg(not(target_os = "linux"))] - sandbox::apply(policy, None).map_err(|err| std::io::Error::other(err.to_string()))?; + if enforcement_mode.enforces_child_sandbox() { + sandbox::apply(policy, None).map_err(|err| std::io::Error::other(err.to_string()))?; + } Ok(()) } @@ -1275,6 +1324,71 @@ mod tests { use super::*; use std::process::Stdio; + #[cfg(unix)] + fn file_mode(path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path).unwrap().permissions().mode() & 0o7777 + } + + #[cfg(unix)] + fn set_file_mode(path: &Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn ssh_server_init_full_enforcement_keeps_private_socket() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("ssh"); + std::fs::create_dir_all(&parent).unwrap(); + set_file_mode(&parent, 0o775); + let socket = parent.join("ssh.sock"); + + let (listener, _, _) = + ssh_server_init(&socket, &None, ProcessEnforcementMode::Full, false).unwrap(); + drop(listener); + + assert_eq!(file_mode(&parent), 0o700); + assert_eq!(file_mode(&socket), 0o600); + } + + #[cfg(unix)] + #[tokio::test] + async fn ssh_server_init_shared_socket_keeps_group_access() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("ssh"); + std::fs::create_dir_all(&parent).unwrap(); + set_file_mode(&parent, 0o775); + let socket = parent.join("ssh.sock"); + + let (listener, _, _) = + ssh_server_init(&socket, &None, ProcessEnforcementMode::Full, true).unwrap(); + drop(listener); + + assert_eq!(file_mode(&parent), 0o775); + assert_eq!(file_mode(&socket), 0o660); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn ssh_server_abstract_socket_cannot_be_replaced_while_bound() { + let socket = PathBuf::from(format!("@openshell-ssh-test-{}", uuid::Uuid::new_v4())); + let (listener, _, _) = + ssh_server_init(&socket, &None, ProcessEnforcementMode::NetworkOnly, true).unwrap(); + + assert!( + !socket.exists(), + "abstract socket must not create a filesystem inode" + ); + let runtime_path = crate::unix_socket::runtime_path(&socket); + let err = UnixListener::bind(runtime_path.as_ref()) + .expect_err("a workload must not be able to replace the bound abstract socket"); + assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse); + + drop(listener); + } + /// Verify that dropping the input sender (the operation `channel_eof` /// performs) causes the stdin writer loop to exit and close the child's /// stdin pipe. Without this, commands like `cat | tar xf -` used by @@ -1681,21 +1795,24 @@ mod tests { policy, None, None, // no netns fd + ProcessEnforcementMode::Full, #[cfg(target_os = "linux")] - sandbox::linux::prepare( - &SandboxPolicy { - version: 0, - filesystem: FilesystemPolicy::default(), - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process: ProcessPolicy { - run_as_user: None, - run_as_group: None, + Some( + sandbox::linux::prepare( + &SandboxPolicy { + version: 0, + filesystem: FilesystemPolicy::default(), + network: NetworkPolicy::default(), + landlock: LandlockPolicy::default(), + process: ProcessPolicy { + run_as_user: None, + run_as_group: None, + }, }, - }, - None, - ) - .expect("prepare should succeed in test environment"), + None, + ) + .expect("prepare should succeed in test environment"), + ), ) .expect("install pre_exec should succeed"); diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index 7c524676c6..594d865599 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -235,12 +235,14 @@ pub fn spawn( sandbox_id: String, ssh_socket_path: std::path::PathBuf, netns_fd: Option, + expected_ssh_peer_pid: Option, ) -> tokio::task::JoinHandle<()> { tokio::spawn(run_session_loop( endpoint, sandbox_id, ssh_socket_path, netns_fd, + expected_ssh_peer_pid, )) } @@ -249,6 +251,7 @@ async fn run_session_loop( sandbox_id: String, ssh_socket_path: std::path::PathBuf, netns_fd: Option, + expected_ssh_peer_pid: Option, ) { let mut backoff = INITIAL_BACKOFF; let mut attempt: u64 = 0; @@ -256,7 +259,15 @@ async fn run_session_loop( loop { attempt += 1; - match run_single_session(&endpoint, &sandbox_id, &ssh_socket_path, netns_fd).await { + match run_single_session( + &endpoint, + &sandbox_id, + &ssh_socket_path, + netns_fd, + expected_ssh_peer_pid, + ) + .await + { Ok(()) => { let event = session_closed_event(openshell_ocsf::ctx::ctx(), &endpoint, &sandbox_id); @@ -283,6 +294,7 @@ async fn run_single_session( sandbox_id: &str, ssh_socket_path: &std::path::Path, netns_fd: Option, + expected_ssh_peer_pid: Option, ) -> 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 @@ -352,6 +364,7 @@ async fn run_single_session( sandbox_id, ssh_socket_path, netns_fd, + expected_ssh_peer_pid, &channel, &tx, ); @@ -375,6 +388,7 @@ fn handle_gateway_message( sandbox_id: &str, ssh_socket_path: &std::path::Path, netns_fd: Option, + expected_ssh_peer_pid: Option, channel: &grpc_client::AuthedChannel, tx: &mpsc::Sender, ) { @@ -395,7 +409,16 @@ fn handle_gateway_message( tokio::spawn(async move { let event_open = relay_open.clone(); - match handle_relay_open(relay_open, &ssh_socket_path, netns_fd, channel, tx).await { + match handle_relay_open( + relay_open, + &ssh_socket_path, + netns_fd, + expected_ssh_peer_pid, + channel, + tx, + ) + .await + { Ok(()) => { let event = relay_closed_event( openshell_ocsf::ctx::ctx(), @@ -446,11 +469,19 @@ async fn handle_relay_open( relay_open: RelayOpen, ssh_socket_path: &std::path::Path, netns_fd: Option, + expected_ssh_peer_pid: Option, channel: grpc_client::AuthedChannel, tx: mpsc::Sender, ) -> Result<(), Box> { let channel_id = relay_open.channel_id.clone(); - let target = match open_target(&relay_open, ssh_socket_path, netns_fd).await { + let target = match open_target( + &relay_open, + ssh_socket_path, + netns_fd, + expected_ssh_peer_pid, + ) + .await + { Ok(target) => target, Err(err) => { send_relay_open_result(&tx, &channel_id, false, err.to_string()).await; @@ -576,11 +607,23 @@ async fn open_target( relay_open: &RelayOpen, ssh_socket_path: &std::path::Path, netns_fd: Option, + expected_ssh_peer_pid: Option, ) -> Result, Box> { match relay_open.target.as_ref() { Some(relay_open::Target::Tcp(target)) => open_tcp_target(target, netns_fd).await, Some(relay_open::Target::Ssh(_)) | None => { - let stream = tokio::net::UnixStream::connect(ssh_socket_path).await?; + let runtime_path = crate::unix_socket::runtime_path(ssh_socket_path); + let stream = tokio::net::UnixStream::connect(runtime_path.as_ref()).await?; + if let Some(expected_pid) = expected_ssh_peer_pid { + let credentials = stream.peer_cred()?; + let actual_pid = credentials.pid().and_then(|pid| u32::try_from(pid).ok()); + if actual_pid != Some(expected_pid) { + return Err(format!( + "SSH relay peer PID mismatch: expected {expected_pid}, got {actual_pid:?}" + ) + .into()); + } + } Ok(Box::new(stream)) } } @@ -895,4 +938,37 @@ mod ocsf_event_tests { .expect_err("eof should force reconnect"); assert_eq!(err.to_string(), "gateway closed stream"); } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn ssh_target_requires_authenticated_supervisor_peer_pid() { + let socket = + std::path::PathBuf::from(format!("@openshell-relay-test-{}", uuid::Uuid::new_v4())); + let runtime_path = crate::unix_socket::runtime_path(&socket); + let listener = tokio::net::UnixListener::bind(runtime_path.as_ref()).unwrap(); + let accept_task = tokio::spawn(async move { + for _ in 0..2 { + let (_stream, _) = listener.accept().await.unwrap(); + } + }); + let relay = ssh_relay_open("peer-check"); + + let trusted = open_target(&relay, &socket, None, Some(std::process::id())) + .await + .expect("matching peer PID should be accepted"); + drop(trusted); + + let Err(err) = open_target( + &relay, + &socket, + None, + Some(std::process::id().saturating_add(1)), + ) + .await + else { + panic!("mismatched peer PID must be rejected"); + }; + assert!(err.to_string().contains("peer PID mismatch")); + accept_task.await.unwrap(); + } } diff --git a/crates/openshell-supervisor-process/src/unix_socket.rs b/crates/openshell-supervisor-process/src/unix_socket.rs new file mode 100644 index 0000000000..ba74e0a6bb --- /dev/null +++ b/crates/openshell-supervisor-process/src/unix_socket.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unix-socket address helpers shared by the SSH server and relay client. + +use std::borrow::Cow; +use std::path::Path; +#[cfg(target_os = "linux")] +use std::path::PathBuf; + +/// Return whether a configured socket name denotes a Linux abstract socket. +/// +/// Environment variables cannot contain the leading NUL byte used by the +/// kernel ABI, so configuration uses the conventional `@name` spelling. +pub fn is_abstract(path: &Path) -> bool { + #[cfg(target_os = "linux")] + { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().starts_with(b"@") + } + #[cfg(not(target_os = "linux"))] + { + let _ = path; + false + } +} + +/// Translate the configured `@name` spelling into Tokio's NUL-prefixed Linux +/// abstract-socket path representation. +pub fn runtime_path(path: &Path) -> Cow<'_, Path> { + #[cfg(target_os = "linux")] + { + use std::ffi::OsString; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let bytes = path.as_os_str().as_bytes(); + if let Some(name) = bytes.strip_prefix(b"@") { + let mut abstract_name = Vec::with_capacity(name.len() + 1); + abstract_name.push(0); + abstract_name.extend_from_slice(name); + return Cow::Owned(PathBuf::from(OsString::from_vec(abstract_name))); + } + } + Cow::Borrowed(path) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + #[test] + fn at_name_maps_to_abstract_socket_path() { + use std::os::unix::ffi::OsStrExt; + + let configured = Path::new("@openshell-test"); + let runtime = runtime_path(configured); + assert!(is_abstract(configured)); + assert_eq!(runtime.as_os_str().as_bytes(), b"\0openshell-test"); + } +} diff --git a/deploy/docker/Dockerfile.supervisor b/deploy/docker/Dockerfile.supervisor index c84cc70e9e..c760bbc890 100644 --- a/deploy/docker/Dockerfile.supervisor +++ b/deploy/docker/Dockerfile.supervisor @@ -5,10 +5,10 @@ # Supervisor image build. # -# The final image is `scratch`: it only carries the static `openshell-sandbox` -# binary used by Docker extraction, Podman image volumes, and the Kubernetes -# init container copy-self path. A static musl binary lets the image stay -# `scratch` while still being executable as an init container. +# 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: # deploy/docker/.build/prebuilt-binaries//openshell-sandbox @@ -19,17 +19,16 @@ # target) and uploads it as an artifact, which is downloaded into the same # staging directory before the image build job runs. -FROM scratch AS supervisor +FROM alpine:3.22 AS supervisor ARG TARGETARCH -# --chmod=0550 drops world-execute and survives the actions/upload-artifact -# + download-artifact roundtrip (which strips exec perms). Ownership is left -# at root (0:0) deliberately: the Podman driver mounts this image as a -# read-only image volume into the sandbox container and drops DAC_OVERRIDE, -# so the container's UID 0 must own the binary to read+exec it. Mode 0550 -# (r-xr-x---) is the security win; the chown to a non-root UID was breaking -# Podman without buying anything since the container is always UID 0. -COPY --chmod=0550 deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-sandbox /openshell-sandbox +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. +COPY --chmod=0555 deploy/docker/.build/prebuilt-binaries/${TARGETARCH}/openshell-sandbox /openshell-sandbox ENTRYPOINT ["/openshell-sandbox"] diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 4a22d640f5..8723535d1a 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -239,8 +239,10 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | supervisor.image.pullPolicy | string | `""` | Supervisor image pull policy. Defaults to the gateway image pull policy when empty. | | supervisor.image.repository | string | `"ghcr.io/nvidia/openshell/supervisor"` | Supervisor image repository. Changing it uses the effective gateway image tag unless tag is also set. | | supervisor.image.tag | string | `""` | Supervisor image tag override. Empty uses the version pinned into the gateway unless repository is changed. | +| supervisor.sidecar.processBinaryAwareNetworkPolicy | bool | `true` | Keep process/binary-aware network policy enabled in sidecar topology. When false, the network sidecar runs as proxyUid, drops the extra /proc inspection capabilities, and enforces endpoint/L7 policy without matching policy.binaries. | +| supervisor.sidecar.proxyUid | int | `1337` | UID for relaxed long-running network sidecars in sidecar topology. Strict process/binary-aware sidecars run as UID 0 so Kubernetes grants the required /proc inspection capabilities into the effective set. The network init container installs nftables rules that exempt the effective sidecar UID. | | supervisor.sideloadMethod | string | `""` | How the supervisor binary is delivered into sandbox pods. Empty (default) = auto-detect from cluster version: K8s >= v1.35 -> "image-volume" (ImageVolume enabled by default; GA in v1.36) K8s < v1.35 -> "init-container" (copies via init container + emptyDir) On K8s v1.33-v1.34 with the ImageVolume feature gate manually enabled, set this to "image-volume" explicitly. | -| supervisor.topology | string | `"combined"` | Supervisor pod topology for Kubernetes sandboxes. "combined" runs networking and process supervision in the agent container. | +| supervisor.topology | string | `"combined"` | Supervisor pod topology for Kubernetes sandboxes. "combined" runs the current single supervisor container in the agent pod. "sidecar" runs network enforcement in a dedicated sidecar and the process supervisor as a low-capability wrapper in the agent container. | | tolerations | list | `[]` | Tolerations for the gateway pod. | | 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. | diff --git a/deploy/helm/openshell/ci/values-sidecar-kata.yaml b/deploy/helm/openshell/ci/values-sidecar-kata.yaml new file mode 100644 index 0000000000..2e23a1009e --- /dev/null +++ b/deploy/helm/openshell/ci/values-sidecar-kata.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# CI/dev overlay for exercising the Kubernetes supervisor sidecar topology under +# a Kata RuntimeClass. +# +# Use with e2e/with-kube-gateway.sh by setting: +# OPENSHELL_E2E_KUBE_EXTRA_VALUES=deploy/helm/openshell/ci/values-sidecar-kata.yaml +# The e2e wrapper supplies the image repository and tag through OPENSHELL_REGISTRY +# and IMAGE_TAG for existing-cluster runs. + +supervisor: + # Use the sidecar topology under Kata so network enforcement runs in the + # sidecar and the sandbox agent container stays low-privilege. + topology: sidecar + sidecar: + # Keep strict process/binary-aware network policy enabled for the Kata + # validation path. Set this false only when intentionally validating the + # documented endpoint/L7-only downgrade mode. + processBinaryAwareNetworkPolicy: true + +# Kata validation clusters normally install this RuntimeClass. +server: + defaultRuntimeClassName: kata-qemu diff --git a/deploy/helm/openshell/ci/values-sidecar.yaml b/deploy/helm/openshell/ci/values-sidecar.yaml new file mode 100644 index 0000000000..ba8dc50ef1 --- /dev/null +++ b/deploy/helm/openshell/ci/values-sidecar.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# CI/dev overlay for exercising the Kubernetes supervisor sidecar topology. +# +# Merge after values.yaml and ci/values-skaffold.yaml: +# helm install ... -f values.yaml -f ci/values-skaffold.yaml -f ci/values-sidecar.yaml +# +# Or set: +# OPENSHELL_E2E_KUBE_EXTRA_VALUES=deploy/helm/openshell/ci/values-sidecar.yaml +# before running `mise run e2e:kubernetes`. +supervisor: + topology: sidecar + sidecar: + # The strict sidecar default requires cross-container /proc identity access. + # CI/dev e2e uses the explicit downgraded mode so endpoint and L7 policy + # coverage remains runnable on local k3d while that path is hardened. + processBinaryAwareNetworkPolicy: false diff --git a/deploy/helm/openshell/skaffold.yaml b/deploy/helm/openshell/skaffold.yaml index d571c3bd9e..119adf086b 100644 --- a/deploy/helm/openshell/skaffold.yaml +++ b/deploy/helm/openshell/skaffold.yaml @@ -119,6 +119,8 @@ deploy: # To enable SPIFFE/SPIRE provider token grants (requires the # spire-crds and spire releases above): #- ci/values-spire.yaml + # To exercise the Kubernetes supervisor sidecar topology: + #- ci/values-sidecar.yaml # To test multi-replica external PostgreSQL behavior: #- ci/values-high-availability.yaml setValueTemplates: @@ -126,3 +128,18 @@ deploy: image.tag: '{{.IMAGE_TAG_openshell_gateway}}' supervisor.image.repository: '{{.IMAGE_REPO_openshell_supervisor}}' supervisor.image.tag: '{{.IMAGE_TAG_openshell_supervisor}}' +profiles: + - name: sidecar + patches: + - op: add + path: /deploy/helm/releases/0/valuesFiles/- + value: ci/values-sidecar.yaml + - name: sidecar-mtls + patches: + - op: add + path: /deploy/helm/releases/0/valuesFiles/- + value: ci/values-sidecar.yaml + - op: add + path: /deploy/helm/releases/0/setValues + value: + server.disableTls: "false" diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 55beac7a19..36a579250b 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -115,7 +115,7 @@ data: grpc_endpoint = {{ include "openshell.grpcEndpoint" . | quote }} service_account_name = {{ include "openshell.sandboxServiceAccountName" . | quote }} supervisor_sideload_method = {{ include "openshell.supervisorSideloadMethod" . | quote }} - supervisor_topology = {{ .Values.supervisor.topology | default "combined" | quote }} + topology = {{ .Values.supervisor.topology | default "combined" | quote }} sa_token_ttl_secs = {{ .Values.server.sandboxJwt.k8sSaTokenTtlSecs | default 3600 }} {{- if .Values.server.providerTokenGrants.spiffe.enabled }} provider_spiffe_workload_api_socket_path = {{ .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} @@ -144,3 +144,7 @@ data: {{- if .Values.supervisor.image.pullPolicy }} supervisor_image_pull_policy = {{ .Values.supervisor.image.pullPolicy | quote }} {{- end }} + + [openshell.drivers.kubernetes.sidecar] + proxy_uid = {{ .Values.supervisor.sidecar.proxyUid | default 1337 }} + process_binary_aware_network_policy = {{ .Values.supervisor.sidecar.processBinaryAwareNetworkPolicy }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 797182558e..aee396c38f 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -88,7 +88,10 @@ tests: asserts: - matchRegex: path: data["gateway.toml"] - pattern: '(?ms)\[openshell\.drivers\.kubernetes\].*?supervisor_topology\s*=\s*"combined"' + pattern: '(?ms)\[openshell\.drivers\.kubernetes\].*?topology\s*=\s*"combined"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'supervisor[_]topology\s*=' - it: uses the gateway built-in supervisor image by default template: templates/gateway-config.yaml @@ -126,14 +129,35 @@ tests: path: data["gateway.toml"] pattern: 'supervisor_image\s*=\s*"registry\.example\.com/openshell/supervisor:supervisor-build"' - - it: renders explicit combined supervisor topology under [openshell.drivers.kubernetes] + - it: renders sidecar supervisor topology under [openshell.drivers.kubernetes] + template: templates/gateway-config.yaml + set: + supervisor.topology: sidecar + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\].*?topology\s*=\s*"sidecar"' + - notMatchRegex: + path: data["gateway.toml"] + pattern: 'supervisor[_]topology\s*=' + + - it: renders proxy uid under [openshell.drivers.kubernetes.sidecar] + template: templates/gateway-config.yaml + set: + supervisor.sidecar.proxyUid: 2200 + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.drivers\.kubernetes\.sidecar\].*?proxy_uid\s*=\s*2200' + + - it: renders process binary aware network policy under [openshell.drivers.kubernetes.sidecar] template: templates/gateway-config.yaml set: - supervisor.topology: combined + supervisor.sidecar.processBinaryAwareNetworkPolicy: false asserts: - matchRegex: path: data["gateway.toml"] - pattern: '(?ms)\[openshell\.drivers\.kubernetes\].*?supervisor_topology\s*=\s*"combined"' + pattern: '(?ms)\[openshell\.drivers\.kubernetes\.sidecar\].*?process_binary_aware_network_policy\s*=\s*false' - it: renders sandbox image pull secrets under [openshell.drivers.kubernetes] template: templates/gateway-config.yaml diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index f36b533d13..e89a234912 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -45,8 +45,22 @@ supervisor: # set this to "image-volume" explicitly. sideloadMethod: "" # -- Supervisor pod topology for Kubernetes sandboxes. - # "combined" runs networking and process supervision in the agent container. + # "combined" runs the current single supervisor container in the agent pod. + # "sidecar" runs network enforcement in a dedicated sidecar and the process + # supervisor as a low-capability wrapper in the agent container. topology: "combined" + sidecar: + # -- UID for relaxed long-running network sidecars in sidecar topology. + # Strict process/binary-aware sidecars run as UID 0 so Kubernetes grants + # the required /proc inspection capabilities into the effective set. The + # network init container installs nftables rules that exempt the effective + # sidecar UID. + proxyUid: 1337 + # -- Keep process/binary-aware network policy enabled in sidecar topology. + # When false, the network sidecar runs as proxyUid, drops the extra /proc + # inspection capabilities, and enforces endpoint/L7 policy without matching + # policy.binaries. + processBinaryAwareNetworkPolicy: true # -- Image pull secrets attached to gateway and helper pods. imagePullSecrets: [] diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 8824b6de1e..5409a4b11d 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -5,7 +5,7 @@ title: "Access Control" sidebar-title: "Access Control" description: "Configure OIDC user authentication or reverse-proxy auth termination for a Kubernetes-deployed OpenShell gateway." keywords: "Generative AI, Cybersecurity, Kubernetes, Authentication, mTLS, OIDC, Keycloak, Entra ID, Okta, Gateway Auth" -position: 4 +position: 5 --- The OpenShell gateway supports two access-control models for human callers on Kubernetes: diff --git a/docs/kubernetes/ingress.mdx b/docs/kubernetes/ingress.mdx index 844167246a..66178bc3a0 100644 --- a/docs/kubernetes/ingress.mdx +++ b/docs/kubernetes/ingress.mdx @@ -5,7 +5,7 @@ title: "Ingress" sidebar-title: "Ingress" description: "Expose the OpenShell gateway externally using the Kubernetes Gateway API and a GRPCRoute." keywords: "Generative AI, Cybersecurity, Kubernetes, Gateway API, Envoy Gateway, GRPCRoute, Ingress, External Access" -position: 3 +position: 4 --- By default, the OpenShell gateway is only reachable inside the cluster. To let CLI clients connect without a `kubectl port-forward`, expose the gateway through an ingress. diff --git a/docs/kubernetes/managing-certificates.mdx b/docs/kubernetes/managing-certificates.mdx index d929463504..b66419b505 100644 --- a/docs/kubernetes/managing-certificates.mdx +++ b/docs/kubernetes/managing-certificates.mdx @@ -5,7 +5,7 @@ title: "Managing Certificates" sidebar-title: "Managing Certificates" description: "Configure the OpenShell Helm chart to use cert-manager for mTLS certificate issuance and automatic renewal." keywords: "Generative AI, Cybersecurity, Kubernetes, cert-manager, PKI, TLS, mTLS, Certificates" -position: 2 +position: 3 --- The OpenShell gateway uses mTLS certificates for transport between the gateway and sandbox supervisors. These certificates are not Kubernetes user authentication; configure OIDC or a trusted access proxy for user access. The Helm chart supports two ways to provision and manage the certificate bundle: diff --git a/docs/kubernetes/openshift.mdx b/docs/kubernetes/openshift.mdx index 355a46d2f6..7512eaa65e 100644 --- a/docs/kubernetes/openshift.mdx +++ b/docs/kubernetes/openshift.mdx @@ -5,7 +5,7 @@ title: "OpenShift" sidebar-title: "OpenShift" description: "Install the OpenShell Helm chart on OpenShift, including the SCC binding and chart overrides required by OpenShift's Security Context Constraints." keywords: "Generative AI, Cybersecurity, Kubernetes, OpenShift, SCC, Security Context Constraints, Helm, Gateway, Installation" -position: 5 +position: 6 --- @@ -14,6 +14,11 @@ The OpenShift install path is experimental. It currently requires running sandbo OpenShift's [Security Context Constraints](https://docs.openshift.com/container-platform/latest/authentication/managing-security-context-constraints.html) reject the chart's default pod security settings. Installing on OpenShift requires precreating the namespace, granting the `privileged` SCC to the sandbox service account, and overriding a few chart values so the cluster admission controller can assign UIDs and FS groups itself. +OpenShell installs sandbox nftables rules as individual commands. On OpenShift +nodes where optional conntrack or packet log expressions are unavailable, those +optional rules can fail without rolling back the required proxy bypass reject +rules. + ## Prerequisites - OpenShift 4.x cluster with `oc` configured diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index f6051b1235..c2fca827f1 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -161,6 +161,7 @@ The most commonly changed values are: | `pkiInitJob.serverDnsNames` / `certManager.serverDnsNames` | Additional gateway server DNS SANs. Wildcard SANs also enable sandbox service URLs under that domain. | | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect based on cluster version: clusters running Kubernetes 1.35 or later use `image-volume` (ImageVolume GA in 1.36); older clusters use `init-container`. Set explicitly to `image-volume` on Kubernetes 1.33 or 1.34 with the ImageVolume feature gate enabled, or to `init-container` to force the legacy path on any version. | | `supervisor.topology` | Sandbox pod topology. Refer to [Topology](/kubernetes/topology). | +| `supervisor.sidecar.proxyUid` | Non-root UID used when sidecar process/binary-aware network policy is disabled. The default binary-aware sidecar runs as UID 0 instead. The configured UID must not match the sandbox UID. | Use a values file for repeatable deployments: @@ -244,7 +245,7 @@ The gateway exposes `/healthz` for process liveness and `/readyz` for dependency ## Next Steps -- To review Kubernetes sandbox topology, refer to [Topology](/kubernetes/topology). +- To choose between combined and sidecar sandbox pods, refer to [Topology](/kubernetes/topology). - To enable automatic certificate rotation with cert-manager, refer to [Managing Certificates](/kubernetes/managing-certificates). - To expose the gateway externally without port-forwarding, refer to [Ingress](/kubernetes/ingress). - To configure OIDC or reverse-proxy authentication, refer to [Access Control](/kubernetes/access-control). diff --git a/docs/kubernetes/topology.mdx b/docs/kubernetes/topology.mdx index f3f69cf6ec..5bbb18e1ef 100644 --- a/docs/kubernetes/topology.mdx +++ b/docs/kubernetes/topology.mdx @@ -3,37 +3,36 @@ # SPDX-License-Identifier: Apache-2.0 title: "Kubernetes Sandbox Topology" sidebar-title: "Topology" -description: "Review the default combined supervisor topology for Kubernetes sandbox pods." -keywords: "Generative AI, Cybersecurity, Kubernetes, Sandboxing, RuntimeClass" +description: "Choose between combined and sidecar supervisor topology for Kubernetes sandbox pods." +keywords: "Generative AI, Cybersecurity, Kubernetes, Sandboxing, Sidecar, Network Policy, RuntimeClass" position: 2 --- -Kubernetes sandbox pods run the OpenShell supervisor in `combined` topology by -default. Combined topology keeps network, filesystem, and process controls in -the agent pod so the supervisor can enforce the complete OpenShell sandbox -contract before launching the workload. +Kubernetes sandbox pods can run the OpenShell supervisor in `combined` or +`sidecar` topology. Choose the topology based on which controls you need inside +the pod and how much privilege your cluster allows on the agent container. ## Choose a Topology The default `combined` topology preserves the full OpenShell enforcement model. -Use it when you need OpenShell to apply all sandbox controls inside the workload -pod and your cluster policy permits the required Linux capabilities. +Use `sidecar` only when you accept network-focused enforcement in exchange for a +lower-privilege agent container. | Topology | Use when | Main tradeoff | |---|---|---| | `combined` | You need OpenShell network, filesystem, and process controls in the sandbox workload. | The agent container carries the Linux capabilities the supervisor needs. | - -Additional Kubernetes sandbox topologies are still being designed. Until they -are documented as supported configuration values, `combined` is the only -supported value for `supervisor.topology`. +| `sidecar` | You need the agent container to run as non-root without added Linux capabilities, and network policy is the primary control. | Privilege-dropping and supervisor mount isolation do not run in the agent container. | ## Privilege Model -The long-running container permissions for `combined` topology are: +The long-running container permissions differ by topology: | Topology | Pod or container | UID/GID | Privilege escalation | Capabilities | Result | |---|---|---|---|---|---| | `combined` | Agent container, which also runs the supervisor | Not forced by topology | Not explicitly disabled by the driver | Adds `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, and `SYSLOG`; adds `SETUID`, `SETGID`, and `DAC_READ_SEARCH` when user namespaces are enabled | Full supervisor controls run in the agent container. | +| `sidecar` | Agent container, process-only supervisor (`network-only`) | `sandbox_uid:sandbox_gid` | `false` | Drops `ALL` | Agent and workload run without added Linux capabilities. | +| `sidecar` | Network supervisor sidecar, binary-aware mode (default) | `0:sandbox_gid` | `false` | Drops `ALL`; adds `SYS_PTRACE` and `DAC_READ_SEARCH` | Root sidecar inspects cross-UID workload `/proc` entries. The nftables fence exempts UID 0, so do not inject other root containers into these pods. | +| `sidecar` | Network supervisor sidecar, endpoint/L7-only mode | `proxyUid:sandbox_gid` | `false` | Drops `ALL` | Non-root sidecar enforces endpoint and L7 policy without matching `policy.binaries`. | Short-lived setup containers still have the permissions needed to prepare the pod: @@ -41,6 +40,7 @@ pod: | Topology | Setup container | UID/GID | Privilege escalation | Capabilities | Purpose | |---|---|---|---|---|---| | `combined` | Supervisor install init container | `0` | Not set | Not set | Copies the supervisor binary into the agent container volume. | +| `sidecar` | Network init container | `0` | `false` | Drops `ALL`; adds `NET_ADMIN`, `NET_RAW`, `CHOWN`, and `FOWNER` | Installs pod-local nftables rules and prepares shared sidecar state. | ## Combined Topology @@ -48,6 +48,26 @@ Combined topology is the original Kubernetes mode and remains the default. The agent container starts the OpenShell supervisor, and the supervisor launches the workload after applying sandbox setup. +```mermaid +flowchart TB + Sandbox["agents.x-k8s.io Sandbox"] + + subgraph Pod["Sandbox pod"] + subgraph Agent["agent container"] + Supervisor["OpenShell supervisor
network + process + filesystem"] + Workload["Agent workload"] + end + end + + Gateway["OpenShell Gateway"] + External["External services"] + + Sandbox --> Pod + Supervisor --> Workload + Supervisor -->|"gateway callback / SSH relay"| Gateway + Supervisor -->|"policy-enforced egress"| External +``` + Combined topology keeps these controls in one supervisor path: - Network endpoint and L7 policy enforcement. @@ -61,12 +81,119 @@ controls from the agent container, Kubernetes grants that container elevated Linux capabilities. Use this mode when you need the complete OpenShell sandbox contract and your cluster policy permits those capabilities. +## Sidecar Topology + +Sidecar topology splits the supervisor into a network sidecar and a +low-privilege process supervisor in the agent container. + +```mermaid +flowchart TB + Sandbox["agents.x-k8s.io Sandbox"] + + subgraph Pod["Sandbox pod"] + Init["network init container
root setup capabilities"] + State["shared state + TLS volumes"] + NetNS["pod network namespace"] + + subgraph Agent["agent container"] + ProcessSupervisor["process supervisor
network-only"] + Workload["Agent workload"] + end + + NetworkSidecar["network supervisor sidecar
UID 0 by default"] + SshEndpoint["abstract SSH relay socket
peer-PID authenticated"] + end + + Gateway["OpenShell Gateway"] + External["External services"] + + Sandbox --> Pod + Init -->|"installs nftables rules"| NetNS + ProcessSupervisor --> Workload + Workload -->|"egress redirected on loopback"| NetworkSidecar + NetworkSidecar -->|"gateway session + relays"| Gateway + NetworkSidecar -->|"policy-enforced egress"| External + NetworkSidecar -->|"control socket + proxy TLS"| State + ProcessSupervisor -->|"bootstrap + updates"| State + ProcessSupervisor --> SshEndpoint + NetworkSidecar -->|"SSH relay"| SshEndpoint + NetworkSidecar --- State +``` + +The pod contains these OpenShell-managed pieces: + +| Component | Runs as | Purpose | +|---|---|---| +| Network init container | Root with setup capabilities | Installs pod-level nftables rules and prepares shared sidecar state. | +| Network sidecar | UID 0 by default; `supervisor.sidecar.proxyUid` when binary-aware policy is disabled | Runs the proxy, enforces network policy, owns gateway authentication and the gateway session, and serves local policy/provider state over the sidecar control socket. | +| Agent container | Resolved sandbox UID/GID | Runs the process supervisor and launches the user workload. | + +In this topology, the agent container defaults to `runAsNonRoot: true`, +`allowPrivilegeEscalation: false`, and `capabilities.drop: ["ALL"]`. The +default binary-aware network sidecar runs as UID 0, drops default Linux +capabilities, and adds `SYS_PTRACE` plus `DAC_READ_SEARCH` for cross-UID workload +process identity resolution. Setting +`supervisor.sidecar.processBinaryAwareNetworkPolicy=false` runs the sidecar as +the configured non-root `proxyUid`, omits both capabilities, and downgrades +network policy to endpoint/L7 enforcement without binary matching. The root +init container keeps the setup capabilities needed to configure pod networking. + +Sidecar mode preserves gateway session behavior, including SSH connectivity, +because the network sidecar owns the gateway session and bridges relay requests +to a Linux abstract SSH socket owned by the process supervisor. The relay +verifies the socket peer PID against the authenticated control connection, so +the workload cannot replace the relay endpoint. The agent container does not get a +gateway endpoint, gateway TLS material, or the sandbox bootstrap token in the +default sidecar path. + + +Sidecar mode runs the process supervisor in `network-only` mode. OpenShell still +enforces network endpoint and L7 policy through the sidecar, and the process +supervisor applies Landlock filesystem policy and child seccomp filters where +the kernel/runtime supports them. The process supervisor does not perform +root-to-sandbox privilege dropping because Kubernetes starts the container as +the sandbox UID/GID, and it does not perform supervisor identity mount +isolation because gateway credentials are not mounted into the agent container. +Sidecar pods use `shareProcessNamespace: true` so the network sidecar can +resolve workload process and binary identity through `/proc/`. + + +## Credential Exposure + +Sidecar topology keeps gateway credentials in the network sidecar. The agent +container does not mount the projected ServiceAccount token used for sandbox +token bootstrap, does not mount the sandbox client TLS secret, and does not get +gateway callback environment variables. + +The network sidecar serves the policy and workload-facing provider environment +over a Unix control socket in the shared sidecar state volume. Before launching +the workload, the process supervisor establishes the only accepted connection. +The sidecar validates its UID, GID, and PID with peer credentials, unlinks the +listener, derives the SSH target from trusted configuration, and rejects later +clients. The connection receives bootstrap state and provider-environment +updates after settings polls. If it closes, the network sidecar exits so +Kubernetes recreates the one-client bootstrap listener, and the process +supervisor exits so Kubernetes terminates the workload and restarts the agent +container. This symmetric failure behavior prevents a surviving workload from +claiming the new control listener after an isolated sidecar restart. Future +child processes can see refreshed provider env without giving the agent +container gateway authentication material. This does not mutate the environment +of the already-running workload entrypoint. Use `combined` topology when you +need the full single-supervisor enforcement path; use additional runtime +isolation when you need a stronger container boundary around sidecar workloads. + ## RuntimeClass Isolation -RuntimeClass isolation can add a stronger container boundary for the sandbox -workload when the cluster supports it. Runtime classes do not replace the -combined topology's supervisor controls; they add another isolation boundary -around the same supervised workload. +Sidecar topology has been validated with Kata Containers. It does not currently +support gVisor because sidecar mode requires pod-local nftables setup, which +gVisor does not provide to the init container. A supported sandboxed runtime +strengthens the container boundary while OpenShell focuses on network policy +enforcement from the sidecar. + +Runtime classes do not re-enable the OpenShell privilege-drop or supervisor +mount-isolation controls that sidecar mode relaxes. Use them as an additional +workload boundary, not as a replacement for the combined topology's full +supervisor controls. You can set a default runtime class in the Kubernetes driver configuration or override it per sandbox with driver config: @@ -77,24 +204,44 @@ openshell sandbox create \ -- claude ``` -## Configure Combined Mode +## Enable Sidecar Mode -For direct gateway TOML configuration, leave `supervisor_topology` unset, or -set it to `combined`, to use the default single-container supervisor path: +For direct gateway TOML configuration, set the Kubernetes driver fields: ```toml [openshell.drivers.kubernetes] -supervisor_topology = "combined" +topology = "sidecar" + +[openshell.drivers.kubernetes.sidecar] +proxy_uid = 1337 ``` -When the Helm chart renders `gateway.toml`, leave `supervisor.topology` unset, -or set it to `combined`, to produce the same driver configuration: +`proxy_uid` configures only the relaxed endpoint/L7-only sidecar. It must be a +non-root UID and must not match the sandbox UID. The default binary-aware mode +runs the sidecar as UID 0 instead. The network init container exempts the +effective sidecar UID from proxy redirection so the sidecar can reach the +gateway. + +When the Helm chart renders `gateway.toml`, set the equivalent chart values: ```yaml supervisor: - topology: combined + topology: sidecar + sidecar: + proxyUid: 1337 + processBinaryAwareNetworkPolicy: true ``` +Leave `topology` unset, or set it to `combined`, to keep the original +single-container supervisor path. For Helm installs, leave +`supervisor.topology` unset or set it to `combined`. + +Set `supervisor.sidecar.processBinaryAwareNetworkPolicy=false` only when you +accept downgrading sidecar network policy to endpoint/L7 enforcement without +matching `policy.binaries`. This changes the sidecar from UID 0 to `proxyUid` +and removes its `SYS_PTRACE` and `DAC_READ_SEARCH` capabilities, which are used +for cross-UID `/proc` inspection. + ## Next Steps - To install OpenShell on Kubernetes, refer to [Setup](/kubernetes/setup). diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 8f840d1776..247774a6c0 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -179,9 +179,10 @@ supervisor_image_pull_policy = "IfNotPresent" # Use the image volume on Kubernetes >= 1.35 (GA in 1.36); switch to "init-container" # on older clusters or where the ImageVolume feature gate is off. supervisor_sideload_method = "image-volume" -# "combined" runs networking and process supervision in the sandbox agent -# container and preserves the existing Kubernetes sandbox behavior. -supervisor_topology = "combined" +# "combined" runs the existing single supervisor container with full process, +# filesystem, and network enforcement in the agent container. "sidecar" moves +# pod-level network enforcement and gateway session handling into a network sidecar. +topology = "combined" grpc_endpoint = "https://openshell-gateway.agents.svc:8080" ssh_socket_path = "/run/openshell/ssh.sock" client_tls_secret_name = "openshell-client-tls" @@ -205,6 +206,18 @@ provider_spiffe_workload_api_socket_path = "/spiffe-workload-api/spire-agent.soc # back to 1000 on non-OpenShift clusters. # sandbox_uid = 1500 # sandbox_gid = 1500 + +[openshell.drivers.kubernetes.sidecar] +# UID used by relaxed long-running network sidecars. Strict process/binary-aware +# sidecars run as UID 0 so Kubernetes grants the required /proc inspection +# capabilities into the effective set. In sidecar topology the network init +# container installs nftables rules that exempt the effective sidecar UID. +proxy_uid = 1337 +# Keep process/binary-aware network policy enabled in sidecar topology. Set +# false to run the sidecar as proxy_uid, drop the sidecar's extra /proc +# inspection capabilities, and enforce endpoint/L7 policy without matching +# policy.binaries. +process_binary_aware_network_policy = true ``` ### Docker diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index e5cb8e254d..dc8bdb2345 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -306,10 +306,38 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA | `supervisor_image` | `supervisor.image.repository` / `supervisor.image.tag` | Override the supervisor image that provides the `openshell-sandbox` binary. The default repository with an empty tag uses the version-pinned image built into the gateway. Changing the repository uses the effective gateway image tag, while setting a tag pins that version explicitly. | | `supervisor_image_pull_policy` | `supervisor.image.pullPolicy` | Set the Kubernetes image pull policy for the supervisor image. | | `supervisor_sideload_method` | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect from cluster version. Set to `image-volume` to mount the supervisor OCI image directly as a volume (requires Kubernetes 1.33+ with the ImageVolume feature gate; GA in 1.36), or `init-container` to copy it through an init container on older clusters. | +| `topology` | `supervisor.topology` | Set `combined` for the default single supervisor path, or `sidecar` to move pod-level network enforcement and the gateway session into a dedicated sidecar. | +| `sidecar.proxy_uid` | `supervisor.sidecar.proxyUid` | Non-root UID used by the relaxed sidecar when process/binary-aware network policy is disabled. The default binary-aware sidecar runs as UID 0. The network init container exempts the effective sidecar UID from proxy redirection. | +| `sidecar.process_binary_aware_network_policy` | `supervisor.sidecar.processBinaryAwareNetworkPolicy` | Keep process/binary-aware network policy enabled in `sidecar` topology. The default runs the sidecar as UID 0 with `SYS_PTRACE` and `DAC_READ_SEARCH`. Set false to run as `proxy_uid`, drop both capabilities, and enforce endpoint/L7 policy without matching `policy.binaries`. | | `app_armor_profile` | `server.appArmorProfile` | Set the sandbox agent container's AppArmor profile. Helm defaults this to `Unconfined` so AppArmor-enabled nodes do not block supervisor network namespace setup. Set the Helm value to an empty string to omit the field, or use `RuntimeDefault` or `Localhost/` for operator-managed profiles. | | `workspace_default_storage_size` | `server.workspaceDefaultStorageSize` | Set the default workspace PVC size for new sandboxes. | | `sa_token_ttl_secs` | `server.sandboxJwt.k8sSaTokenTtlSecs` | Set the projected ServiceAccount token TTL used for the bootstrap token exchange. | +In `combined` topology, the agent container carries the Linux capabilities +needed by the supervisor for network namespace setup, Landlock filesystem +policy, process privilege changes, and network policy enforcement. In `sidecar` +topology, the agent container runs as the resolved sandbox UID/GID with no added +Linux capabilities. A root init container performs the nftables setup, and the +long-running binary-aware sidecar runs as UID 0, drops default capabilities, +and adds `SYS_PTRACE` plus `DAC_READ_SEARCH` for workload process identity +resolution through shared `/proc`. The +`sidecar.process_binary_aware_network_policy = false` setting runs it as the +configured non-root `proxy_uid`, removes both capabilities, and relaxes network +policy to endpoint/L7 matching only. The +network sidecar owns gateway authentication and writes local policy/provider +state to the process supervisor over a local control socket, so the agent +container does not mount the sandbox bootstrap token or client TLS secret in +the default sidecar path. The provider environment is refreshed by the network +sidecar after settings polls and streamed to the process supervisor so future +child processes can see updated provider env without gateway access in the +agent container. +Sidecar mode keeps gateway session and SSH behavior. The process supervisor +applies Landlock filesystem policy and child seccomp filters where supported, +but it does not perform root-to-sandbox privilege dropping or supervisor +identity mount isolation. Network policy still runs in the sidecar, and sidecar +pods set `shareProcessNamespace: true` so the network sidecar can resolve +process/binary identity through `/proc/`. + The Kubernetes driver creates namespaced `agents.x-k8s.io` `Sandbox` resources from the Kubernetes SIG Apps [agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project. It detects the served Sandbox API at runtime, caches the selected API version for the gateway process, and uses `v1beta1` when available before falling back to `v1alpha1`, so supported Agent Sandbox installations work without version-specific operator configuration. The Agent Sandbox controller turns those resources into sandbox pods and related storage. 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. diff --git a/e2e/with-kube-gateway.sh b/e2e/with-kube-gateway.sh index 47b8730dc1..e4f47008c9 100755 --- a/e2e/with-kube-gateway.sh +++ b/e2e/with-kube-gateway.sh @@ -393,6 +393,21 @@ require_cmd() { fi } +configure_fixture_container_engine() { + [ -n "${CONTAINER_ENGINE:-}" ] || return 0 + local selected_engine + selected_engine="$(printf '%s' "${CONTAINER_ENGINE}" | tr '[:upper:]' '[:lower:]')" + case "${selected_engine}" in + docker|podman) + ;; + *) + echo "ERROR: CONTAINER_ENGINE=${CONTAINER_ENGINE} is invalid; expected docker or podman" >&2 + exit 2 + ;; + esac + export CONTAINER_ENGINE="${selected_engine}" +} + require_cmd helm require_cmd kubectl require_cmd curl @@ -423,6 +438,8 @@ else KUBE_CONTEXT="k3d-${CLUSTER_NAME}" fi +configure_fixture_container_engine + if [ -z "${OPENSHELL_E2E_KUBE_BUILD_IMAGES+x}" ]; then if [ "${CLUSTER_CREATED_BY_US}" = "1" ]; then OPENSHELL_E2E_KUBE_BUILD_IMAGES=1 @@ -501,7 +518,7 @@ if [ -z "${HOST_GATEWAY_IP}" ] \ # is unreachable for the typical test-host listener (0.0.0.0 bind). detected="$(docker network inspect "${net}" \ -f '{{range .IPAM.Config}}{{.Gateway}}{{"\n"}}{{end}}' 2>/dev/null \ - | awk '/^[0-9.]+$/ { print; exit }')" + | awk '/^[0-9.]+$/ { print; exit }' || true)" if [ -n "${detected}" ]; then HOST_GATEWAY_IP="${detected}" echo "Detected host gateway IP ${HOST_GATEWAY_IP} from docker network '${net}'." diff --git a/tasks/helm.toml b/tasks/helm.toml index f25dadb09c..24b6667b1d 100644 --- a/tasks/helm.toml +++ b/tasks/helm.toml @@ -55,16 +55,46 @@ description = "Run skaffold dev for deploy/helm/openshell (iterative deploy)" dir = "deploy/helm/openshell" run = "skaffold dev" +["helm:skaffold:dev:sidecar"] +description = "Run skaffold dev with the Kubernetes supervisor sidecar topology" +dir = "deploy/helm/openshell" +run = "skaffold dev -p sidecar" + +["helm:skaffold:dev:sidecar-mtls"] +description = "Run skaffold dev with the Kubernetes supervisor sidecar topology and TLS/mTLS enabled" +dir = "deploy/helm/openshell" +run = "skaffold dev -p sidecar-mtls" + ["helm:skaffold:run"] description = "Run skaffold run for deploy/helm/openshell (one-shot deploy)" dir = "deploy/helm/openshell" run = "skaffold run" +["helm:skaffold:run:sidecar"] +description = "Run skaffold run with the Kubernetes supervisor sidecar topology" +dir = "deploy/helm/openshell" +run = "skaffold run -p sidecar" + +["helm:skaffold:run:sidecar-mtls"] +description = "Run skaffold run with the Kubernetes supervisor sidecar topology and TLS/mTLS enabled" +dir = "deploy/helm/openshell" +run = "skaffold run -p sidecar-mtls" + ["helm:skaffold:delete"] description = "Run skaffold delete for deploy/helm/openshell" dir = "deploy/helm/openshell" run = "skaffold delete" +["helm:skaffold:delete:sidecar"] +description = "Run skaffold delete for the Kubernetes supervisor sidecar topology" +dir = "deploy/helm/openshell" +run = "skaffold delete -p sidecar" + +["helm:skaffold:delete:sidecar-mtls"] +description = "Run skaffold delete for the Kubernetes supervisor sidecar topology with TLS/mTLS enabled" +dir = "deploy/helm/openshell" +run = "skaffold delete -p sidecar-mtls" + ["helm:skaffold:diagnose"] description = "Run skaffold diagnose for deploy/helm/openshell" dir = "deploy/helm/openshell" diff --git a/tasks/test.toml b/tasks/test.toml index db3878f756..c08fcc5a04 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -114,6 +114,11 @@ run = [ "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" + ["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" } From 614c8c164d15024ac44c709c98dcc23bb3350fa5 Mon Sep 17 00:00:00 2001 From: mjamiv <142179942+mjamiv@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:17:37 -0400 Subject: [PATCH 13/17] feat(kubernetes): support PVC subPath driver config (#2034) * feat(kubernetes): support PVC subPath driver config Signed-off-by: mjamiv * test(kubernetes): cover writable PVC driver config Signed-off-by: mjamiv * fix(kubernetes): address PVC subPath review feedback Signed-off-by: mjamiv * fix(kubernetes): address PVC config review follow-up Signed-off-by: mjamiv * fix(kubernetes): address PVC review follow-ups --------- Signed-off-by: mjamiv --- crates/openshell-core/src/driver_mounts.rs | 32 +- crates/openshell-driver-docker/src/tests.rs | 33 + crates/openshell-driver-kubernetes/README.md | 54 + .../openshell-driver-kubernetes/src/driver.rs | 988 +++++++++++++++++- .../openshell-driver-podman/src/container.rs | 34 + docs/reference/sandbox-compute-drivers.mdx | 63 ++ 6 files changed, 1152 insertions(+), 52 deletions(-) diff --git a/crates/openshell-core/src/driver_mounts.rs b/crates/openshell-core/src/driver_mounts.rs index fa43edf533..086d992c37 100644 --- a/crates/openshell-core/src/driver_mounts.rs +++ b/crates/openshell-core/src/driver_mounts.rs @@ -91,6 +91,19 @@ pub fn validate_container_mount_target(target: &str) -> Result<(), String> { if !target.starts_with('/') { return Err("mount target must be an absolute container path".to_string()); } + if target != "/" { + let segments = target.split('/').skip(1).collect::>(); + let has_internal_empty_segment = segments + .iter() + .take(segments.len().saturating_sub(1)) + .any(|segment| segment.is_empty()); + if has_internal_empty_segment || segments.contains(&".") { + return Err( + "mount target must be normalized and must not contain empty path segments or '.'" + .to_string(), + ); + } + } let path = Path::new(target); if path == Path::new("/") { return Err("mount target must not be the container root".to_string()); @@ -122,7 +135,8 @@ pub fn normalize_mount_target(target: &str) -> String { target.trim_end_matches('/').to_string() } -fn path_is_or_under(path: &Path, parent: &Path) -> bool { +/// Return true when `path` is exactly `parent` or is contained below it. +pub fn path_is_or_under(path: &Path, parent: &Path) -> bool { path == parent || path.starts_with(parent) } @@ -185,4 +199,20 @@ mod tests { "mount target must not contain surrounding whitespace" ); } + #[test] + fn mount_target_rejects_internal_empty_or_dot_segments() { + assert_eq!( + validate_container_mount_target("/sandbox/work//tmp").unwrap_err(), + "mount target must be normalized and must not contain empty path segments or '.'" + ); + assert_eq!( + validate_container_mount_target("/sandbox/work/./tmp").unwrap_err(), + "mount target must be normalized and must not contain empty path segments or '.'" + ); + assert_eq!( + validate_container_mount_target("/sandbox/work/../../tmp").unwrap_err(), + "mount target must not contain '..'" + ); + validate_container_mount_target("/sandbox/work/").unwrap(); + } } diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 560d47de7a..594308bce5 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -755,6 +755,39 @@ fn driver_config_allows_explicit_writable_volume_mounts() { assert_eq!(mounts[0].read_only, Some(false)); } +#[test] +fn driver_config_rejects_duplicate_mount_targets() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }, + { + "type": "tmpfs", + "target": "/sandbox/work" + } + ] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message() + .contains("duplicate docker driver_config mount target") + ); +} + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 688660dbb5..96e54ad448 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -117,6 +117,13 @@ nested schema and currently accepts: - `pod.priority_class_name` - `containers.agent.resources.requests` - `containers.agent.resources.limits` +- `containers.agent.volume_mounts[].name` +- `containers.agent.volume_mounts[].mount_path` +- `containers.agent.volume_mounts[].sub_path` +- `containers.agent.volume_mounts[].read_only` +- `volumes[].name` +- `volumes[].persistent_volume_claim.claim_name` +- `volumes[].persistent_volume_claim.read_only` Nested keys inside the `kubernetes` block use snake_case. The top-level `driver_config` envelope is keyed by driver names, so `kubernetes` is not part @@ -139,3 +146,50 @@ driver's configured `default_runtime_class_name`; the typed public public `--gpu` flag for the default GPU request, pass a count to `--gpu` for counted GPU requests, and use `driver_config` only for additional driver-owned resource details. + +Use PVC volumes to mount existing Kubernetes PersistentVolumeClaims into the +agent container. PVC volumes and mounts default to read-only unless +`read_only: false` is set explicitly. Read-write access requires +`read_only: false` on both the PVC volume and each writable mount. The driver +rejects duplicate volume names, invalid DNS-1123 volume labels or PVC claim +subdomain names, mounts that reference unknown volumes, non-normalized or +protected mount paths, and absolute or parent-traversing `sub_path` values. + +Any explicit driver-config mount under `/sandbox` disables the driver's +default `/sandbox` workspace PVC injection for that sandbox. Only the explicit +mount paths persist through the external PVC; other `/sandbox` paths come from +the current sandbox image. + +```shell +openshell sandbox create \ + --driver-config-json '{ + "kubernetes": { + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + } + ] + } + } + } + }' \ + -- claude +``` diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index db7492d27d..5315d30409 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -10,12 +10,15 @@ use crate::config::{ SupervisorTopology, }; use futures::{Stream, StreamExt, TryStreamExt}; -use k8s_openapi::api::core::v1::{Event as KubeEventObj, Namespace, Node}; +use k8s_openapi::api::core::v1::{ + Event as KubeEventObj, Namespace, Node, PersistentVolumeClaimVolumeSource, Volume, VolumeMount, +}; use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams}; use kube::core::gvk::GroupVersionKind; use kube::core::{DynamicObject, ObjectMeta}; use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; +use openshell_core::driver_mounts; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, SUPERVISOR_IMAGE_BINARY_PATH, }; @@ -34,7 +37,8 @@ use openshell_core::proto::compute::v1::{ }; use openshell_core::proto_struct::{struct_to_json_object, value_to_json}; use serde::Deserialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -107,29 +111,39 @@ struct AgentSandboxApi { struct KubernetesSandboxDriverConfig { pod: KubernetesPodDriverConfig, containers: KubernetesDriverContainersConfig, + volumes: Vec, } impl KubernetesSandboxDriverConfig { - fn from_sandbox(sandbox: &Sandbox) -> Result { - let Some(template) = sandbox - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - else { - return Ok(Self::default()); - }; - - Self::from_template(template) - } - fn from_template(template: &SandboxTemplate) -> Result { let Some(config) = template.driver_config.as_ref() else { return Ok(Self::default()); }; let json = serde_json::Value::Object(struct_to_json_object(config)); - serde_json::from_value(json) - .map_err(|err| format!("invalid kubernetes driver_config: {err}")) + let config: Self = serde_json::from_value(json) + .map_err(|err| format!("invalid kubernetes driver_config: {err}"))?; + config + .validate() + .map_err(|err| format!("invalid kubernetes driver_config: {err}"))?; + Ok(config) + } + + fn validate(&self) -> Result<(), String> { + validate_kubernetes_driver_volumes(&self.volumes)?; + validate_kubernetes_driver_volume_mounts( + &self.volumes, + &self.containers.agent.volume_mounts, + ) + } + + fn has_explicit_sandbox_data_mount(&self) -> bool { + self.containers.agent.volume_mounts.iter().any(|mount| { + driver_mounts::path_is_or_under( + Path::new(&mount.mount_path), + Path::new(WORKSPACE_MOUNT_PATH), + ) + }) } } @@ -152,6 +166,7 @@ struct KubernetesDriverContainersConfig { #[serde(default, deny_unknown_fields)] struct KubernetesContainerDriverConfig { resources: KubernetesContainerResourceConfig, + volume_mounts: Vec, } #[derive(Debug, Clone, Default, Deserialize)] @@ -161,6 +176,220 @@ struct KubernetesContainerResourceConfig { limits: BTreeMap, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesDriverVolumeConfig { + name: String, + persistent_volume_claim: KubernetesPersistentVolumeClaimConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesPersistentVolumeClaimConfig { + claim_name: String, + read_only: bool, +} + +impl Default for KubernetesPersistentVolumeClaimConfig { + fn default() -> Self { + Self { + claim_name: String::new(), + read_only: true, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesDriverVolumeMountConfig { + name: String, + mount_path: String, + sub_path: Option, + read_only: bool, +} + +impl Default for KubernetesDriverVolumeMountConfig { + fn default() -> Self { + Self { + name: String::new(), + mount_path: String::new(), + sub_path: None, + read_only: true, + } + } +} + +impl From<&KubernetesDriverVolumeConfig> for Volume { + fn from(volume: &KubernetesDriverVolumeConfig) -> Self { + Self { + name: volume.name.clone(), + persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource { + claim_name: volume.persistent_volume_claim.claim_name.clone(), + read_only: Some(volume.persistent_volume_claim.read_only), + }), + ..Default::default() + } + } +} + +impl From<&KubernetesDriverVolumeMountConfig> for VolumeMount { + fn from(mount: &KubernetesDriverVolumeMountConfig) -> Self { + Self { + name: mount.name.clone(), + mount_path: mount.mount_path.clone(), + read_only: Some(mount.read_only), + sub_path: mount.sub_path.clone(), + ..Default::default() + } + } +} + +const CLIENT_TLS_VOLUME_NAME: &str = "openshell-client-tls"; +const SERVICE_ACCOUNT_TOKEN_VOLUME_NAME: &str = "openshell-sa-token"; +const SERVICE_ACCOUNT_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/openshell"; + +const KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES: &[&str] = &[ + CLIENT_TLS_VOLUME_NAME, + SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + SPIFFE_WORKLOAD_API_VOLUME_NAME, + SUPERVISOR_VOLUME_NAME, + WORKSPACE_VOLUME_NAME, +]; + +const KUBERNETES_DRIVER_PROTECTED_MOUNT_PATHS: &[&str] = &[SERVICE_ACCOUNT_TOKEN_MOUNT_PATH]; + +fn validate_kubernetes_driver_volumes( + volumes: &[KubernetesDriverVolumeConfig], +) -> Result<(), String> { + let mut names = HashSet::new(); + for volume in volumes { + validate_kubernetes_dns1123_label(&volume.name, "volumes[].name")?; + let name = volume.name.as_str(); + if KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES.contains(&name) { + return Err(format!( + "volume name '{name}' is reserved for OpenShell-managed volumes" + )); + } + if !names.insert(name) { + return Err(format!( + "duplicate kubernetes driver_config volume '{name}'" + )); + } + validate_kubernetes_dns1123_subdomain( + &volume.persistent_volume_claim.claim_name, + "volumes[].persistent_volume_claim.claim_name", + )?; + } + Ok(()) +} + +fn validate_kubernetes_driver_volume_mounts( + volumes: &[KubernetesDriverVolumeConfig], + volume_mounts: &[KubernetesDriverVolumeMountConfig], +) -> Result<(), String> { + let mut volume_read_only = BTreeMap::new(); + for volume in volumes { + volume_read_only.insert( + volume.name.as_str(), + volume.persistent_volume_claim.read_only, + ); + } + + let mut mount_paths = HashSet::new(); + for mount in volume_mounts { + validate_kubernetes_dns1123_label(&mount.name, "containers.agent.volume_mounts[].name")?; + let volume_name = mount.name.as_str(); + let Some(volume_is_read_only) = volume_read_only.get(volume_name) else { + return Err(format!( + "volume mount references unknown kubernetes driver_config volume '{volume_name}'" + )); + }; + if *volume_is_read_only && !mount.read_only { + return Err(format!( + "volume mount '{volume_name}' cannot set read_only=false because the PVC volume is read_only=true" + )); + } + + driver_mounts::validate_container_mount_target(&mount.mount_path)?; + let normalized_mount_path = driver_mounts::normalize_mount_target(&mount.mount_path); + if !mount_paths.insert(normalized_mount_path.clone()) { + return Err(format!( + "duplicate kubernetes driver_config mount target '{normalized_mount_path}'" + )); + } + + if let Some(sub_path) = mount.sub_path.as_ref() { + driver_mounts::validate_mount_subpath(sub_path)?; + } + } + Ok(()) +} + +// TODO: replace with an openshell_core Kubernetes-name helper once available. +fn is_dns_label(label: &str) -> bool { + if label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-') { + return false; + } + label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +// TODO: replace with an openshell_core Kubernetes-name helper once available. +fn is_dns_subdomain(value: &str) -> bool { + value.len() <= 253 && value.split('.').all(is_dns_label) +} + +fn validate_kubernetes_dns1123_label(value: &str, field: &str) -> Result<(), String> { + if !is_dns_label(value) { + return Err(format!( + "{field} must be a DNS-1123 label: use lowercase alphanumeric characters or '-', start and end with an alphanumeric character, and use at most 63 characters" + )); + } + Ok(()) +} + +fn validate_kubernetes_dns1123_subdomain(value: &str, field: &str) -> Result<(), String> { + if !is_dns_subdomain(value) { + return Err(format!( + "{field} must be a DNS-1123 subdomain: use lowercase alphanumeric characters, '-' or '.', start and end with an alphanumeric character, and use at most 253 characters" + )); + } + Ok(()) +} + +fn mount_path_conflicts_with_protected_path(mount_path: &str, protected_path: &str) -> bool { + driver_mounts::path_is_or_under(Path::new(mount_path), Path::new(protected_path)) + || driver_mounts::path_is_or_under(Path::new(protected_path), Path::new(mount_path)) +} + +fn validate_kubernetes_protected_path_conflicts( + volume_mounts: &[KubernetesDriverVolumeMountConfig], + protected_paths: &[&str], +) -> Result<(), String> { + for mount in volume_mounts { + let mount_path = mount.mount_path.as_str(); + for protected_path in protected_paths { + if mount_path_conflicts_with_protected_path(mount_path, protected_path) { + return Err(format!( + "mount path '{mount_path}' conflicts with reserved OpenShell path '{protected_path}'" + )); + } + } + } + Ok(()) +} + +fn kubernetes_driver_volume_to_k8s(volume: &KubernetesDriverVolumeConfig) -> serde_json::Value { + serde_json::to_value(Volume::from(volume)).expect("Volume serializes to JSON") +} + +fn kubernetes_driver_volume_mount_to_k8s( + mount: &KubernetesDriverVolumeMountConfig, +) -> serde_json::Value { + serde_json::to_value(VolumeMount::from(mount)).expect("VolumeMount serializes to JSON") +} + // --------------------------------------------------------------------------- // Default workspace persistence (temporary — will be replaced by snapshotting) // --------------------------------------------------------------------------- @@ -274,6 +503,20 @@ impl KubernetesComputeDriver { &self.config.ssh_socket_path } + fn validate_driver_config_for_sandbox( + &self, + sandbox: &Sandbox, + ) -> Result { + kubernetes_driver_config_for_spec( + sandbox.spec.as_ref(), + self.config.provider_spiffe_enabled().then_some( + self.config + .provider_spiffe_workload_api_socket_path + .as_str(), + ), + ) + } + fn agent_sandbox_api(&self, client: Client, sandbox_api_version: &str) -> AgentSandboxApi { let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, sandbox_api_version, SANDBOX_KIND); let resource = ApiResource::from_gvk(&gvk); @@ -421,7 +664,8 @@ impl KubernetesComputeDriver { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), tonic::Status> { - let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) + let _ = self + .validate_driver_config_for_sandbox(sandbox) .map_err(tonic::Status::invalid_argument)?; let gpu_requirements = sandbox .spec @@ -530,8 +774,6 @@ impl KubernetesComputeDriver { #[allow(clippy::similar_names)] pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { - let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) - .map_err(KubernetesDriverError::InvalidArgument)?; let gpu_requirements = sandbox .spec .as_ref() @@ -590,6 +832,8 @@ impl KubernetesComputeDriver { }; validate_sidecar_proxy_identity(¶ms)?; + let data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms) + .map_err(KubernetesDriverError::InvalidArgument)?; let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource); // Copy only the SCC-related annotations onto the Sandbox CR for // traceability. Copying the full namespace annotation map exposes @@ -617,7 +861,7 @@ impl KubernetesComputeDriver { ..Default::default() }; - obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms); + obj.data = data; match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api.api.create(&PostParams::default(), &obj), @@ -1893,27 +2137,55 @@ fn spec_pod_env(spec: Option<&SandboxSpec>) -> std::collections::HashMap KubernetesSandboxDriverConfig { - KubernetesSandboxDriverConfig::from_template(template) - .expect("validated Kubernetes driver_config") +fn kubernetes_driver_config_for_spec( + spec: Option<&SandboxSpec>, + provider_spiffe_workload_api_socket_path: Option<&str>, +) -> Result { + let config = spec + .and_then(|spec| spec.template.as_ref()) + .map(KubernetesSandboxDriverConfig::from_template) + .transpose()? + .unwrap_or_default(); + let mut protected_paths = KUBERNETES_DRIVER_PROTECTED_MOUNT_PATHS.to_vec(); + let provider_spiffe_mount_path; + if let Some(socket_path) = provider_spiffe_workload_api_socket_path { + provider_spiffe_mount_path = spiffe_socket_mount_path(socket_path); + protected_paths.push(&provider_spiffe_mount_path); + } + validate_kubernetes_protected_path_conflicts( + &config.containers.agent.volume_mounts, + &protected_paths, + )?; + Ok(config) } fn sandbox_to_k8s_spec( spec: Option<&SandboxSpec>, params: &SandboxPodParams<'_>, -) -> serde_json::Value { +) -> Result { + let driver_config = + kubernetes_driver_config_for_spec(spec, provider_spiffe_socket_path(params))?; let mut root = serde_json::Map::new(); + // Determine early whether OpenShell should inject its default workspace + // PVC. Explicit Kubernetes driver-config mounts under /sandbox/ take + // ownership of workspace persistence. + // We need this flag before building the podTemplate because the workspace + // persistence transforms are applied inside sandbox_template_to_k8s. + let user_has_explicit_workspace_mount = driver_config.has_explicit_sandbox_data_mount(); + let inject_workspace = !user_has_explicit_workspace_mount; + if let Some(spec) = spec { let pod_env = spec_pod_env(Some(spec)); if let Some(template) = spec.template.as_ref() { root.insert( "podTemplate".to_string(), - sandbox_template_to_k8s_with_gpu_requirements( + sandbox_template_to_k8s_with_validated_config( template, driver_gpu_requirements(spec.resource_requirements.as_ref()), &pod_env, - true, + &driver_config, + inject_workspace, params, ), ); @@ -1926,29 +2198,32 @@ fn sandbox_to_k8s_spec( } } - root.insert( - "volumeClaimTemplates".to_string(), - default_workspace_volume_claim_templates(params.workspace_default_storage_size), - ); + if inject_workspace { + root.insert( + "volumeClaimTemplates".to_string(), + default_workspace_volume_claim_templates(params.workspace_default_storage_size), + ); + } // podTemplate is required by the Kubernetes CRD - ensure it's always present if !root.contains_key("podTemplate") { let pod_env = spec_pod_env(spec); root.insert( "podTemplate".to_string(), - sandbox_template_to_k8s_with_gpu_requirements( + sandbox_template_to_k8s_with_validated_config( &SandboxTemplate::default(), driver_gpu_requirements(spec.and_then(|s| s.resource_requirements.as_ref())), &pod_env, - true, + &driver_config, + inject_workspace, params, ), ); } - serde_json::Value::Object( + Ok(serde_json::Value::Object( std::iter::once(("spec".to_string(), serde_json::Value::Object(root))).collect(), - ) + )) } #[cfg(test)] @@ -1960,15 +2235,19 @@ fn sandbox_template_to_k8s( params: &SandboxPodParams<'_>, ) -> serde_json::Value { let gpu_requirements = gpu.then_some(GpuResourceRequirements { count: None }); - sandbox_template_to_k8s_with_gpu_requirements( + let driver_config = KubernetesSandboxDriverConfig::from_template(template) + .expect("test Kubernetes driver_config should be valid"); + sandbox_template_to_k8s_with_validated_config( template, gpu_requirements.as_ref(), spec_environment, + &driver_config, inject_workspace, params, ) } +#[cfg(test)] fn sandbox_template_to_k8s_with_gpu_requirements( template: &SandboxTemplate, gpu_requirements: Option<&GpuResourceRequirements>, @@ -1976,8 +2255,26 @@ fn sandbox_template_to_k8s_with_gpu_requirements( inject_workspace: bool, params: &SandboxPodParams<'_>, ) -> serde_json::Value { - let driver_config = kubernetes_driver_config(template); + let driver_config = KubernetesSandboxDriverConfig::from_template(template) + .expect("test Kubernetes driver_config should be valid"); + sandbox_template_to_k8s_with_validated_config( + template, + gpu_requirements, + spec_environment, + &driver_config, + inject_workspace, + params, + ) +} +fn sandbox_template_to_k8s_with_validated_config( + template: &SandboxTemplate, + gpu_requirements: Option<&GpuResourceRequirements>, + spec_environment: &std::collections::HashMap, + driver_config: &KubernetesSandboxDriverConfig, + inject_workspace: bool, + params: &SandboxPodParams<'_>, +) -> serde_json::Value { let mut metadata = serde_json::Map::new(); let mut pod_labels = template .labels @@ -2141,7 +2438,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( let mut volume_mounts: Vec = Vec::new(); if !params.client_tls_secret_name.is_empty() { volume_mounts.push(serde_json::json!({ - "name": "openshell-client-tls", + "name": CLIENT_TLS_VOLUME_NAME, "mountPath": "/etc/openshell-tls/client", "readOnly": true })); @@ -2154,10 +2451,18 @@ fn sandbox_template_to_k8s_with_gpu_requirements( })); } volume_mounts.push(serde_json::json!({ - "name": "openshell-sa-token", - "mountPath": "/var/run/secrets/openshell", + "name": SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + "mountPath": SERVICE_ACCOUNT_TOKEN_MOUNT_PATH, "readOnly": true, })); + volume_mounts.extend( + driver_config + .containers + .agent + .volume_mounts + .iter() + .map(kubernetes_driver_volume_mount_to_k8s), + ); container.insert( "volumeMounts".to_string(), serde_json::Value::Array(volume_mounts), @@ -2183,7 +2488,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( SupervisorTopology::Sidecar => 0o440, }; volumes.push(serde_json::json!({ - "name": "openshell-client-tls", + "name": CLIENT_TLS_VOLUME_NAME, "secret": { "secretName": params.client_tls_secret_name, "defaultMode": client_tls_default_mode @@ -2209,7 +2514,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( SupervisorTopology::Sidecar => 0o440, }; volumes.push(serde_json::json!({ - "name": "openshell-sa-token", + "name": SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, "projected": { "sources": [{ "serviceAccountToken": { @@ -2221,6 +2526,12 @@ fn sandbox_template_to_k8s_with_gpu_requirements( "defaultMode": sa_token_default_mode } })); + volumes.extend( + driver_config + .volumes + .iter() + .map(kubernetes_driver_volume_to_k8s), + ); spec.insert("volumes".to_string(), serde_json::Value::Array(volumes)); // Add hostAliases so sandbox pods can reach the Docker host. @@ -2264,8 +2575,8 @@ fn sandbox_template_to_k8s_with_gpu_requirements( } // Inject workspace persistence (init container + PVC volume mount) so - // that /sandbox data survives pod rescheduling. Skipped when the user - // provides custom volumeClaimTemplates to avoid conflicts. + // that /sandbox data survives pod rescheduling. Skipped when the user + // provides custom storage through driver_config. if inject_workspace { apply_workspace_persistence( &mut result, @@ -2559,9 +2870,9 @@ fn provider_spiffe_socket_path<'a>(params: &'a SandboxPodParams<'a>) -> Option<& } fn spiffe_socket_mount_path(socket_path: &str) -> String { - std::path::Path::new(socket_path) + Path::new(socket_path) .parent() - .and_then(std::path::Path::to_str) + .and_then(Path::to_str) .filter(|path| !path.is_empty() && *path != "/") .expect("provider SPIFFE socket path should be validated before pod rendering") .to_string() @@ -2733,6 +3044,13 @@ mod tests { } } + fn sandbox_to_k8s_spec_for_test( + spec: Option<&SandboxSpec>, + params: &SandboxPodParams<'_>, + ) -> serde_json::Value { + sandbox_to_k8s_spec(spec, params).expect("test Kubernetes driver_config should be valid") + } + fn kube_api_error(code: u16, message: &str) -> KubeError { KubeError::Api(kube::core::ErrorResponse { status: if code == 404 { @@ -2799,7 +3117,7 @@ mod tests { } #[test] - fn driver_config_from_sandbox_rejects_unknown_fields() { + fn driver_config_for_spec_rejects_unknown_fields() { let sandbox = Sandbox { id: "sandbox-123".to_string(), spec: Some(SandboxSpec { @@ -2814,11 +3132,579 @@ mod tests { ..Default::default() }; - let err = KubernetesSandboxDriverConfig::from_sandbox(&sandbox).unwrap_err(); + let err = kubernetes_driver_config_for_spec(sandbox.spec.as_ref(), None).unwrap_err(); assert!(err.contains("unknown field")); assert!(err.contains("gpu_device_ids")); } + #[test] + fn driver_config_pvc_subpath_mounts_render_in_pod_template() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory" + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + let spec = SandboxSpec { + template: Some(template), + ..SandboxSpec::default() + }; + + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); + let pod_template = &cr["spec"]["podTemplate"]; + + let volumes = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist"); + let user_volume = volumes + .iter() + .find(|volume| volume["name"] == "user-data") + .expect("user PVC volume should be rendered"); + assert_eq!( + user_volume["persistentVolumeClaim"]["claimName"], + "pvc-user-data-123" + ); + assert_eq!(user_volume["persistentVolumeClaim"]["readOnly"], false); + + let mounts = pod_template["spec"]["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts should exist"); + let workspace_mount = mounts + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/workspace") + .expect("workspace subPath mount should be rendered"); + assert_eq!(workspace_mount["name"], "user-data"); + assert_eq!(workspace_mount["subPath"], "workspace"); + assert_eq!(workspace_mount["readOnly"], false); + + let memory_mount = mounts + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/memory") + .expect("memory subPath mount should be rendered"); + assert_eq!(memory_mount["name"], "user-data"); + assert_eq!(memory_mount["subPath"], "memory"); + assert_eq!(memory_mount["readOnly"], true); + + let spec_obj = cr["spec"].as_object().expect("spec should be an object"); + assert!( + !spec_obj.contains_key("volumeClaimTemplates"), + "explicit /sandbox driver_config mounts should skip the default workspace VCT" + ); + let has_workspace_init = pod_template["spec"]["initContainers"] + .as_array() + .is_some_and(|containers| { + containers + .iter() + .any(|container| container["name"] == WORKSPACE_INIT_CONTAINER_NAME) + }); + assert!( + !has_workspace_init, + "explicit /sandbox driver_config mounts should skip the default workspace init container" + ); + } + + #[test] + fn driver_config_accepts_read_write_pvc_with_multiple_subpath_mounts() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/sessions", + "sub_path": "sessions", + "read_only": false + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + + let config = KubernetesSandboxDriverConfig::from_template(&template) + .expect("read-write PVC with multiple subPath mounts should validate"); + + assert_eq!(config.volumes.len(), 1); + assert_eq!(config.volumes[0].name, "user-data"); + assert_eq!( + config.volumes[0].persistent_volume_claim.claim_name, + "pvc-user-data" + ); + assert!(!config.volumes[0].persistent_volume_claim.read_only); + assert_eq!(config.containers.agent.volume_mounts.len(), 3); + assert!( + config + .containers + .agent + .volume_mounts + .iter() + .all(|mount| !mount.read_only) + ); + assert!(config.has_explicit_sandbox_data_mount()); + } + + #[test] + fn driver_config_rejects_duplicate_pvc_volume_names() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [ + { + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-a"} + }, + { + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-b"} + } + ] + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("duplicate kubernetes driver_config volume")); + } + + #[test] + fn driver_config_rejects_duplicate_pvc_volume_mount_targets() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace" + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace" + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("duplicate kubernetes driver_config mount target")); + } + + #[test] + fn driver_config_accepts_dns1123_subdomain_pvc_claim_name() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc.user-data.123"} + }] + }))), + ..SandboxTemplate::default() + }; + + let config = KubernetesSandboxDriverConfig::from_template(&template) + .expect("DNS-1123 subdomain PVC names should validate"); + + assert_eq!( + config.volumes[0].persistent_volume_claim.claim_name, + "pvc.user-data.123" + ); + } + + #[test] + fn driver_config_rejects_invalid_volume_label_and_claim_name() { + for (field, config) in [ + ( + "volumes[].name", + serde_json::json!({ + "volumes": [{ + "name": "User_Data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }] + }), + ), + ( + "volumes[].persistent_volume_claim.claim_name", + serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "Pvc_User_Data"} + }] + }), + ), + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(config)), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains(field) && err.contains("DNS-1123"), + "expected invalid {field} to fail DNS-1123 validation, got {err}" + ); + } + } + + #[test] + fn driver_config_rejects_mounts_referencing_unknown_volumes() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "known-data", + "persistent_volume_claim": {"claim_name": "pvc-known"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "missing-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("unknown kubernetes driver_config volume 'missing-data'")); + } + + #[test] + fn driver_config_rejects_shared_reserved_mount_targets() { + for mount_path in [ + "/", + "/sandbox", + "/etc/openshell", + "/etc/openshell-tls/client", + "/opt/openshell/bin", + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": mount_path + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("mount path") || err.contains("mount target"), + "expected protected mount target {mount_path:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn driver_config_rejects_kubernetes_static_protected_mount_targets() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/var/run/secrets/openshell" + }] + } + } + }))), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }; + + let err = kubernetes_driver_config_for_spec(Some(&spec), None).unwrap_err(); + + assert!(err.contains("/var/run/secrets/openshell")); + } + + #[test] + fn driver_config_allows_spiffe_workload_path_without_provider_spiffe() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/spiffe-workload-api" + }] + } + } + }))), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }; + + kubernetes_driver_config_for_spec(Some(&spec), None) + .expect("SPIFFE workload path should only be protected when SPIFFE is enabled"); + } + + #[test] + fn driver_config_rejects_invalid_kubernetes_sub_paths() { + for sub_path in ["/workspace", "../workspace"] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": sub_path + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("mount subpath must be relative"), + "expected invalid sub_path {sub_path:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn driver_config_defaults_pvc_mounts_to_read_only() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let pod_template = sandbox_template_to_k8s( + &template, + false, + &std::collections::HashMap::new(), + false, + &SandboxPodParams::default(), + ); + + let volume = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist") + .iter() + .find(|volume| volume["name"] == "user-data") + .expect("user volume should exist"); + assert_eq!(volume["persistentVolumeClaim"]["readOnly"], true); + + let mount = pod_template["spec"]["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts should exist") + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/workspace") + .expect("user mount should exist"); + assert_eq!(mount["readOnly"], true); + } + + #[test] + fn driver_config_rejects_read_write_mount_for_read_only_pvc_volume() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data", + "read_only": true + } + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "read_only": false + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("cannot set read_only=false")); + } + + #[test] + fn driver_config_rejects_reserved_kubernetes_volume_names() { + for volume_name in [ + CLIENT_TLS_VOLUME_NAME, + SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + SPIFFE_WORKLOAD_API_VOLUME_NAME, + SUPERVISOR_VOLUME_NAME, + WORKSPACE_VOLUME_NAME, + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": volume_name, + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }] + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("reserved for OpenShell-managed volumes"), + "expected reserved volume name {volume_name:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn reserved_kubernetes_volume_names_cover_managed_pod_volumes() { + let params = SandboxPodParams { + client_tls_secret_name: "openshell-client-tls-secret", + provider_spiffe_enabled: true, + provider_spiffe_workload_api_socket_path: "/spiffe-workload-api/spire-agent.sock", + ..SandboxPodParams::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + let volume_names = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist") + .iter() + .filter_map(|volume| volume["name"].as_str()) + .collect::>(); + + for volume_name in volume_names { + assert!( + KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES.contains(&volume_name), + "managed volume {volume_name:?} should be reserved" + ); + } + } + + #[test] + fn driver_config_rejects_runtime_provider_spiffe_mount_path() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/custom-spiffe" + }] + } + } + }))), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }; + + let err = + kubernetes_driver_config_for_spec(Some(&spec), Some("/custom-spiffe/spire-agent.sock")) + .unwrap_err(); + + assert!(err.contains("/custom-spiffe")); + } + #[test] fn validate_rejects_zero_gpu_count() { let sandbox = Sandbox { @@ -3942,7 +4828,7 @@ mod tests { .expect("volumes should exist"); let tls_vol = volumes .iter() - .find(|v| v["name"] == "openshell-client-tls") + .find(|v| v["name"] == CLIENT_TLS_VOLUME_NAME) .expect("TLS volume should exist"); assert_eq!( tls_vol["secret"]["defaultMode"], @@ -4461,7 +5347,7 @@ mod tests { && volume["csi"]["driver"] == "csi.spiffe.io" })); assert!(volumes.iter().any(|volume| { - volume["name"] == "openshell-sa-token" + volume["name"] == SERVICE_ACCOUNT_TOKEN_VOLUME_NAME && volume["projected"]["sources"][0]["serviceAccountToken"]["path"] == "token" })); @@ -4514,7 +5400,7 @@ mod tests { log_level: "debug".to_string(), ..SandboxSpec::default() }; - let cr = sandbox_to_k8s_spec(Some(&spec), &SandboxPodParams::default()); + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] .as_array() .unwrap(); @@ -4541,7 +5427,7 @@ mod tests { )]), ..SandboxSpec::default() }; - let cr = sandbox_to_k8s_spec(Some(&spec), &SandboxPodParams::default()); + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] .as_array() .unwrap(); diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 1401f9e7c6..e4a0b79189 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -2002,6 +2002,40 @@ mod tests { })); } + #[test] + fn driver_config_rejects_duplicate_mount_targets() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }, + { + "type": "tmpfs", + "target": "/sandbox/work" + } + ] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!( + err.to_string() + .contains("duplicate podman driver_config mount target") + ); + } + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index dc8bdb2345..03a9b564f0 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -346,6 +346,69 @@ If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the c `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. +### Kubernetes Driver Config PVC Mounts + +Kubernetes driver config can mount existing PersistentVolumeClaims into the +agent container. Use this when storage is provisioned outside OpenShell and a +sandbox should mount selected PVC subpaths instead of using the default +OpenShell-created `/sandbox` workspace PVC. + +```shell +openshell sandbox create \ + --driver-config-json '{ + "kubernetes": { + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + } + ] + } + } + } + }' \ + -- claude +``` + +Kubernetes PVC mount schema: + +| Field | Description | +|---|---| +| `volumes[].name` | Pod volume name. It must be a DNS-1123 label, unique, and not use OpenShell-managed volume names. | +| `volumes[].persistent_volume_claim.claim_name` | Existing PVC name in the sandbox namespace. It must be a DNS-1123 subdomain name. | +| `volumes[].persistent_volume_claim.read_only` | Optional. Defaults to `true`. Set `false` to allow read-write mounts. | +| `containers.agent.volume_mounts[].name` | References a volume declared in `volumes`. | +| `containers.agent.volume_mounts[].mount_path` | Absolute, normalized container path for the agent mount. | +| `containers.agent.volume_mounts[].sub_path` | Optional relative PVC subpath. Absolute paths and `..` are rejected. | +| `containers.agent.volume_mounts[].read_only` | Optional. Defaults to `true`. It cannot be `false` when the PVC volume is read-only. | + +OpenShell rejects duplicate volume names, mounts that reference unknown volumes, +protected mount targets, and mounts that replace OpenShell TLS, supervisor, +ServiceAccount token, or SPIFFE paths. Read-write PVC access requires +`read_only: false` on both the PVC volume and each writable mount. + +Any driver-config mount under `/sandbox` disables the default `/sandbox` +workspace PVC injection for that sandbox. Only the explicit mount paths persist +through the external PVC; other `/sandbox` paths come from the current sandbox +image. + ## Sandbox User Identity OpenShell accepts both the hardcoded username `"sandbox"` and numeric UIDs in `[1000, 2_000_000_000]` for the supervisor's process identity (the policy's `run_as_user` field). The driver resolves the UID at sandbox creation time and passes it to the supervisor via environment variables. From 77a6571e8457c274e59d7811968007ff2b882722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 9 Jul 2026 08:42:11 +0200 Subject: [PATCH 14/17] Add brainstorm: warm pool feasibility study (4 documents) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent brainstorm (01) defines the layered measurement approach for evaluating Agent Sandbox warm pooling on OpenShift. Child documents cover cluster setup (02), measurements (03), and results synthesis (04). Assisted-By: 🤖 Claude Code --- brainstorm/00-overview.md | 32 ++++ brainstorm/01-warm-pool-feasibility.md | 67 +++++++ brainstorm/02-cluster-setup.md | 78 +++++++++ brainstorm/03-warm-pool-measurements.md | 175 +++++++++++++++++++ brainstorm/04-results-and-recommendations.md | 114 ++++++++++++ 5 files changed, 466 insertions(+) create mode 100644 brainstorm/00-overview.md create mode 100644 brainstorm/01-warm-pool-feasibility.md create mode 100644 brainstorm/02-cluster-setup.md create mode 100644 brainstorm/03-warm-pool-measurements.md create mode 100644 brainstorm/04-results-and-recommendations.md diff --git a/brainstorm/00-overview.md b/brainstorm/00-overview.md new file mode 100644 index 0000000000..1c5497fd5e --- /dev/null +++ b/brainstorm/00-overview.md @@ -0,0 +1,32 @@ +# Brainstorm Overview + +Last updated: 2026-07-09 + +## Sessions + +| # | Date | Topic | Status | Spec | Issue | +|---|------|-------|--------|------|-------| +| 01 | 2026-07-09 | warm-pool-feasibility | active | - | - | +| 02 | 2026-07-09 | cluster-setup | active | - | - | +| 03 | 2026-07-09 | warm-pool-measurements | active | - | - | +| 04 | 2026-07-09 | results-and-recommendations | active | - | - | + +## Structure + +01 is the parent document defining the overall feasibility study. 02-04 are execution phases: + +- **02** depends on: nothing (first step) +- **03** depends on: 02 (cluster must be running) +- **04** depends on: 03 (measurements must be complete) + +## Open Threads + +- Does the Red Hat Agent Sandbox operator tech preview include extension CRDs? (from #01, #02) +- Does env var injection at claim time trigger cold start? (from #01, #03) +- Is KEP-753 (native sidecars) available on the target OpenShift version? (from #01, #02) +- How does pool exhaustion behave (cold fallback vs. Pending)? (from #03) +- Should findings be posted to upstream agent-sandbox repo? (from #04) + +## Parked Ideas + +None. diff --git a/brainstorm/01-warm-pool-feasibility.md b/brainstorm/01-warm-pool-feasibility.md new file mode 100644 index 0000000000..057b9135fd --- /dev/null +++ b/brainstorm/01-warm-pool-feasibility.md @@ -0,0 +1,67 @@ +# Brainstorm: Warm Pool Feasibility Study for OpenShell + +**Date:** 2026-07-09 +**Status:** active + +## Problem Framing + +OpenShell's Kubernetes driver creates a fresh `agents.x-k8s.io` Sandbox CR for every sandbox request. This cold-start path pays for pod scheduling, image pull, init container execution, supervisor startup, and gateway registration on every create. Measured latency is 8-12 seconds. For agent harnesses like OpenClaw that create sandboxes per tool call or per sub-agent, this is unusable. + +The upstream Agent Sandbox project (kubernetes-sigs/agent-sandbox, v0.5.0, v1beta1 API) provides extension CRDs for warm pooling: SandboxTemplate, SandboxWarmPool, and SandboxClaim. OpenShell does not use any of these today. The Kubernetes driver has no awareness of the extension API group `extensions.agents.x-k8s.io`. + +This feasibility study measures whether warm pooling can reduce sandbox startup latency to under 2 seconds on OpenShift, and produces a recommendation for how OpenShell should integrate warm pooling into its architecture. + +## Approaches Considered + +### A: Measure Raw Agent Sandbox Warm Pooling (Without OpenShell) + +Deploy the Agent Sandbox extension CRDs on OpenShift, create warm pools with vanilla containers, and measure claim-to-ready latency. This isolates the Kubernetes layer from OpenShell overhead. + +- Pros: Fast to set up, answers the fundamental feasibility question, no code changes required +- Cons: Does not account for OpenShell supervisor, identity binding, or policy injection + +### B: Measure OpenShell End-to-End with Code Changes + +Modify the OpenShell Kubernetes driver to create SandboxClaims instead of direct Sandbox CRs, then measure end-to-end latency. + +- Pros: Realistic numbers that include all OpenShell overhead +- Cons: Requires significant code changes before any measurement, high risk of wasted effort if the raw K8s layer is already too slow + +### C: Layered Approach (A then B) + +Start with raw Agent Sandbox measurements (no OpenShell code changes), then layer on OpenShell-specific concerns (supervisor sidecar, identity injection, readiness patterns) incrementally. + +- Pros: Answers feasibility fast, builds understanding incrementally, each layer adds data +- Cons: More phases to execute + +## Decision + +**Approach C: Layered measurement.** Start with raw Agent Sandbox warm pooling on OpenShift to establish a baseline, then progressively add OpenShell-specific complexity. This avoids wasting effort on code changes if the underlying Kubernetes primitives can't deliver acceptable latency. + +## Key Requirements + +1. **Fresh ROSA HCP cluster** provisioned via the ROSA plugin for consistent, isolated measurements +2. **Red Hat build of Agent Sandbox operator** (tech preview) installed from OperatorHub for the extension CRDs +3. **OpenShell deployed via Krzysztof's chart** (github.com/2000krysztof/Openshell-Openshift-Deploy) for fast setup +4. **Baseline cold-start measurements** (vanilla sandbox creation, no pooling) as the control +5. **Warm pool measurements** with varying configurations (readiness probe intervals, Pod Readiness Gates, sidecar readiness patterns) +6. **Health check optimization experiments** including Knative-style readiness wrapping and KEP-580 Pod Readiness Gates +7. **Results document** with measured data, phase-by-phase breakdown, and architectural recommendations for OpenShell warm pool integration +8. **Scope: Kubernetes only.** Podman/Docker single-player warm pooling is out of scope. + +## Experiment Phases + +This study decomposes into three execution phases, each with its own brainstorm document: + +| Phase | Brainstorm | What it covers | +|-------|------------|----------------| +| 1 | 02-cluster-setup | ROSA cluster provisioning, Agent Sandbox operator, OpenShell deployment | +| 2 | 03-warm-pool-measurements | Cold-start baseline, warm pool experiments, health check optimization | +| 3 | 04-results-and-recommendations | Data synthesis, OpenShell architecture recommendations | + +## Open Questions + +- Does the Red Hat Agent Sandbox operator tech preview include the extension CRDs (SandboxWarmPool, SandboxClaim, SandboxTemplate), or only the core Sandbox CRD? +- Does env var injection at SandboxClaim time trigger a cold start, or can the `envVarsInjectionPolicy` on SandboxTemplate enable true warm adoption with claim-time injection? +- Is KEP-753 (native sidecar containers) available on the target OpenShift version (needs K8s 1.33+ / OpenShift 4.20+)? +- What is the minimum OpenShift version that supports both the Agent Sandbox operator tech preview and native sidecar containers? diff --git a/brainstorm/02-cluster-setup.md b/brainstorm/02-cluster-setup.md new file mode 100644 index 0000000000..7521da60f6 --- /dev/null +++ b/brainstorm/02-cluster-setup.md @@ -0,0 +1,78 @@ +# Brainstorm: Cluster Setup & OpenShell Deployment + +**Date:** 2026-07-09 +**Status:** active +**Parent:** 01-warm-pool-feasibility + +## Problem Framing + +Before any measurements can happen, we need an OpenShift cluster with OpenShell and the Agent Sandbox extension CRDs running. This phase covers provisioning, installation, and validation. + +Three components must work together: +1. A ROSA HCP cluster with a Kubernetes version that supports native sidecar containers (K8s 1.33+) +2. The Agent Sandbox operator with extension CRDs (SandboxTemplate, SandboxWarmPool, SandboxClaim) +3. OpenShell deployed and functional (can create cold-start sandboxes) + +## Approaches Considered + +### A: ROSA HCP with Upstream Agent Sandbox Manifests + +Provision ROSA, install agent-sandbox from upstream manifests (both `manifest.yaml` and `extensions.yaml`), deploy OpenShell with Krzysztof's chart. + +- Pros: Uses upstream directly, most current version, full control over version +- Cons: No operator lifecycle management, manual CRD updates, no OperatorHub integration + +### B: ROSA HCP with Red Hat Agent Sandbox Operator (Tech Preview) + +Provision ROSA, install the Red Hat build of Agent Sandbox from OperatorHub, deploy OpenShell with Krzysztof's chart. + +- Pros: Operator manages CRD lifecycle, OperatorHub integration, downstream supported path +- Cons: Tech preview may not include extension CRDs yet, version may lag upstream + +### C: Both Paths Available + +Install the Red Hat operator for the core Sandbox CRD, then layer upstream extension manifests on top if the operator doesn't include them. + +- Pros: Best of both worlds, flexibility +- Cons: Mixed provenance (downstream operator + upstream extensions), potential version conflicts + +## Decision + +**Approach C: Start with the Red Hat operator, fall back to upstream extensions.** Install the tech preview operator from OperatorHub first. If it includes the extension CRDs, use them. If not, apply upstream `extensions.yaml` on top. This gives us the downstream operator path for the core CRDs while ensuring we have warm pool primitives available. + +## Key Requirements + +1. **ROSA HCP cluster** via `rosa:create` with the AAET profile + - OpenShift version must support K8s 1.33+ for native sidecar containers (KEP-753 GA) + - Region: us-east-2 (matches AAET profile subnets) + - Worker nodes: at least 3 for realistic scheduling behavior + +2. **Agent Sandbox installation** + - Red Hat Agent Sandbox operator from OperatorHub (if available) + - Upstream extensions.yaml as fallback for SandboxTemplate/SandboxWarmPool/SandboxClaim + - Verify all four CRDs are served: `kubectl api-resources | grep agents` + +3. **OpenShell deployment** + - Clone github.com/2000krysztof/Openshell-Openshift-Deploy + - Run `deploy.sh` with default configuration + - Verify: `openshell status` shows Connected + - Verify: `openshell sandbox create --from base` succeeds (cold-start baseline works) + +4. **Image pre-pulling** + - Pre-pull the OpenShell base sandbox image and supervisor image on all worker nodes + - This removes image pull latency from warm pool measurements + - Use a DaemonSet with `initContainers` that pull and exit, or `oc debug node/` to pre-pull + +5. **Validation checklist** + - Gateway pod is Running + - Agent Sandbox controller is Running + - Extension CRDs are registered + - Cold-start sandbox creation works end-to-end + - Images are pre-pulled on all nodes + +## Open Questions + +- What OpenShift version does ROSA HCP currently offer that includes K8s 1.33+? Need to check `rosa list versions`. +- Does the Red Hat Agent Sandbox operator tech preview install from the default OperatorHub catalog, or does it require a custom CatalogSource? +- Does Krzysztof's deploy script handle OpenShift 4.20+ or does it need updates for newer SCC/security changes? +- How much cluster capacity do we need? The warm pool experiments will create 5-20 pre-provisioned pods simultaneously. diff --git a/brainstorm/03-warm-pool-measurements.md b/brainstorm/03-warm-pool-measurements.md new file mode 100644 index 0000000000..42b24cfef3 --- /dev/null +++ b/brainstorm/03-warm-pool-measurements.md @@ -0,0 +1,175 @@ +# Brainstorm: Baseline & Warm Pool Measurements + +**Date:** 2026-07-09 +**Status:** active +**Parent:** 01-warm-pool-feasibility + +## Problem Framing + +With the cluster running, we need a structured measurement plan that answers: how fast can we get a sandbox with warm pooling, and what are the bottlenecks? The measurements must cover cold-start baseline (control), raw Agent Sandbox warm pooling (no OpenShell), and progressively more realistic configurations that approximate what OpenShell would need. + +The key unknowns are: +- What is the actual cold-start latency breakdown on OpenShift? +- Does warm pooling deliver sub-2s claim-to-ready? +- Is the readiness probe interval the dominant bottleneck, and can Pod Readiness Gates (KEP-580) or Knative-style readiness wrapping eliminate it? +- Can env vars be injected at claim time without triggering cold start? + +## Approaches Considered + +### A: Simple Before/After + +Measure cold start, then warm pool, compare. Two data points. + +- Pros: Fastest to execute +- Cons: No insight into what drives the latency, can't identify optimization levers + +### B: Phase Breakdown with Configuration Matrix + +Measure cold start with per-phase timestamps. Then measure warm pooling across a matrix of configurations (probe intervals, readiness gates, sidecar patterns, env var injection). + +- Pros: Identifies specific bottlenecks, tests optimization hypotheses, actionable data +- Cons: More experiments to run, needs a measurement script + +### C: Full Benchmark Suite with Statistical Rigor + +N=50+ runs per configuration, p50/p90/p99, automated benchmark harness, CSV output for analysis. + +- Pros: Publication-quality data, statistically meaningful +- Cons: Overkill for a feasibility study, takes days + +## Decision + +**Approach B: Phase breakdown with configuration matrix.** N=10-20 runs per configuration is enough to see the pattern. We need per-phase timestamps to identify bottlenecks, and we need the configuration matrix to test our optimization hypotheses (readiness gates, sidecar patterns). A simple shell script with `kubectl` timestamps is sufficient. + +## Experiment Design + +### Experiment 1: Cold-Start Baseline (Control) + +Measure OpenShell sandbox creation latency without pooling. Captures the current state. + +**What to measure:** +- Total time: `openshell sandbox create` to sandbox Ready +- Phase breakdown using `kubectl get events` and pod timestamps: + - API call to pod Scheduled + - Scheduled to image pulled (should be 0 with pre-pulled images) + - Image pulled to init containers complete + - Init containers complete to supervisor Ready + - Supervisor Ready to SSH available + +**Runs:** N=10 with pre-pulled images, N=5 without (to quantify image pull impact) + +### Experiment 2: Vanilla Agent Sandbox Warm Pool (No OpenShell) + +Measure raw Agent Sandbox warm pooling without OpenShell. This isolates Kubernetes overhead from OpenShell overhead. + +**Setup:** +```yaml +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxTemplate +metadata: + name: base-template +spec: + sandbox: + spec: + containers: + - name: agent + image: ghcr.io/nvidia/openshell-community/sandboxes/base:latest +--- +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxWarmPool +metadata: + name: base-pool +spec: + templateRef: + name: base-template + replicas: 5 +``` + +**What to measure:** +- Pool fill time: SandboxWarmPool created to all 5 replicas Ready +- Claim-to-ready time: SandboxClaim created to adopted Sandbox transitioning to Ready +- Claim-to-ready with default readiness probe (10s periodSeconds) +- Claim-to-ready with aggressive readiness probe (1s periodSeconds) + +**Runs:** N=10 per configuration + +### Experiment 3: Pod Readiness Gates (KEP-580) + +Test whether Pod Readiness Gates can replace polling-based readiness for faster claim-to-ready. + +**How it works:** +- Add a custom ReadinessGate condition (e.g., `sandbox.openshell.io/claimed`) to the pod template +- Warm-pooled pods start with the condition missing (defaults to False, pod is NotReady) +- On claim, an external controller sets the condition to True via a pod status patch +- Pod transitions to Ready immediately (no probe interval wait) + +**What to measure:** +- Time from condition patch to pod Ready status +- Compare with probe-based readiness at 1s and 10s intervals + +**Runs:** N=10 + +**KEP-580 status:** GA since Kubernetes 1.14. Available on all OpenShift versions. No feature gate needed. + +### Experiment 4: Sidecar Readiness Pattern + +Test the Knative-style pattern where a sidecar controls pod readiness. + +**How it works:** +- Define a supervisor-like sidecar (init container with `restartPolicy: Always`) +- Sidecar starts, runs a readiness HTTP endpoint that returns 503 (not ready) +- On claim, inject a signal (touch a file in a shared emptyDir, or set an env var) +- Sidecar detects the signal, flips readiness to 200 +- Pod transitions to Ready + +**What to measure:** +- Time from signal injection to sidecar readiness flip +- Time from sidecar readiness flip to pod Ready +- End-to-end claim-to-ready with sidecar pattern + +**Runs:** N=10 + +**KEP-753 status (native sidecars):** GA since Kubernetes 1.33 (April 2025). Requires OpenShift 4.20+. Sidecar readiness probes contribute to pod readiness. + +### Experiment 5: Env Var Injection at Claim Time + +Test whether SandboxClaim env var injection works without forcing cold start. + +**Setup:** +- SandboxTemplate with `envVarsInjectionPolicy: Allowed` +- SandboxClaim with env vars simulating OpenShell identity (OPENSHELL_SANDBOX_ID, OPENSHELL_ENDPOINT) + +**What to measure:** +- Does the claim adopt a warm sandbox, or does it trigger cold start? +- If warm adoption works: claim-to-ready latency +- If cold start is triggered: document this as a constraint + +**Runs:** N=5 + +### Experiment 6: Combined (Sidecar + Readiness Gates + Env Var Injection) + +Combine the best-performing readiness pattern with env var injection. This approximates what a real OpenShell integration would look like. + +**What to measure:** +- End-to-end claim-to-ready with the full stack +- Compare with cold-start baseline + +**Runs:** N=10 + +## Measurement Script + +A shell script that: +1. Creates a SandboxClaim with `kubectl apply` +2. Records the creation timestamp +3. Watches the pod status until Ready (or timeout) +4. Records the Ready timestamp +5. Calculates the delta +6. Collects pod events for phase breakdown +7. Outputs CSV: run_number, config, create_ts, ready_ts, delta_ms, phases + +## Open Questions + +- Can we use `kubectl wait --for=condition=Ready` with millisecond precision, or do we need a custom watcher? +- For the sidecar experiment, what's the simplest possible sidecar binary? A Go binary that listens on :8080 and watches for a file in /tmp/signal/? +- How does pool replenishment affect back-to-back claim latency? Should we measure burst patterns (claim 5 sandboxes simultaneously)? +- What happens when the pool is exhausted? Does SandboxClaim fall back to cold start or stay Pending? diff --git a/brainstorm/04-results-and-recommendations.md b/brainstorm/04-results-and-recommendations.md new file mode 100644 index 0000000000..67f74bf160 --- /dev/null +++ b/brainstorm/04-results-and-recommendations.md @@ -0,0 +1,114 @@ +# Brainstorm: Results Document & OpenShell Recommendations + +**Date:** 2026-07-09 +**Status:** active +**Parent:** 01-warm-pool-feasibility + +## Problem Framing + +After the measurements are complete, we need to synthesize findings into a document that serves two audiences: + +1. **The OpenShell core team** (Derek, Murnal, Seth): What should OpenShell change to support warm pooling? Which approach from Issue #2157 is backed by data? What are the architectural constraints? +2. **Our Red Hat team**: Is the upstream Agent Sandbox warm pooling viable for enterprise OpenShift deployments? What gaps exist in the Red Hat tech preview? What do we recommend for the beta timeline? + +The document should be concrete enough to inform Issue #2157's design decisions and the Peter Steinberger demo conversation (July 21st). + +## Approaches Considered + +### A: Internal Technical Report + +A detailed technical document with raw data, analysis, and recommendations. Shared internally and referenced in GitHub issue comments. + +- Pros: Complete record, referenceable, can share selectively +- Cons: May be too dense for quick consumption + +### B: GitHub Issue Comment + Summary Doc + +Post key findings as a comment on Issue #2157 with a link to a detailed report. The comment is the "executive summary," the report is the appendix. + +- Pros: Directly visible to the upstream community, invites discussion, keeps the issue alive +- Cons: GitHub comments are hard to update as understanding evolves + +### C: Both (Report + Issue Comment) + +Write the full report as a document, then distill key findings into a GitHub comment on #2157. + +- Pros: Best of both, full technical depth plus community visibility +- Cons: Two artifacts to maintain + +## Decision + +**Approach C: Full report plus GitHub comment.** The report lives in our Obsidian vault as a reference. A distilled comment on #2157 shares our findings with the upstream community and positions our work as a concrete contribution to the warm pooling discussion. + +## Report Structure + +### 1. Executive Summary +- One paragraph: can warm pooling hit sub-2s on OpenShift? +- Key finding: what is the dominant bottleneck? +- Recommendation: which integration path for OpenShell? + +### 2. Experiment Setup +- Cluster configuration (OpenShift version, K8s version, node count, instance type) +- Agent Sandbox version and CRDs installed +- OpenShell version and deployment method +- Image pre-pull status + +### 3. Results + +#### Cold-Start Baseline +- Table: phase breakdown with p50/p90 latencies +- Chart: latency distribution + +#### Warm Pool Results +- Table: configuration matrix with claim-to-ready latencies +- Comparison: default probes vs. aggressive probes vs. readiness gates vs. sidecar pattern +- Finding: which configuration achieves the target? + +#### Health Check Analysis +- Readiness probe interval impact (10s vs. 1s) +- Pod Readiness Gates (KEP-580) performance +- Sidecar readiness pattern performance +- Knative-style comparison + +#### Env Var Injection +- Does claim-time injection work without cold start? +- Which injection policy is needed? +- What can be injected (env vars) vs. what needs another mechanism (TLS certs, files)? + +### 4. OpenShell Architecture Recommendations + +Map findings to OpenShell's architecture: + +- **Kubernetes driver changes:** What needs to change in `driver.rs` to support SandboxClaim creation? +- **Supervisor changes:** Should the supervisor become a native sidecar with late-bind identity? +- **Gateway store changes:** How should the gateway handle sandbox records for warm-pooled sandboxes? +- **Identity binding:** Which mechanism works (env var injection, volume projection, API call)? +- **Configuration surface:** Where should warm pool configuration live (driver config, workspace admin, operator)? +- **Issue #2157 recommendation:** Which of the four alternatives from the issue is best supported by data? +- **Issue #1447 comparison:** Native pool vs. upstream CRDs, with data backing + +### 5. Gaps and Risks +- Missing features in the Agent Sandbox extension API (e.g., volumeClaimTemplates, Issue #453) +- Red Hat tech preview coverage gaps +- KEP-753 availability on the target OpenShift version +- Pool replenishment under burst load +- Identity isolation between warm slot reuse + +### 6. Next Steps +- Concrete list of upstream contributions (issues, PRs, RFCs) +- Internal work items for the 60-day beta sprint +- Demo plan for the Peter Steinberger meeting + +## Key Requirements + +1. **Report saved to Obsidian vault** at `~/Obsidian/ro14nd/09-Meeting-Notes/` with date prefix +2. **GitHub comment on #2157** with distilled findings (use prose:check before posting) +3. **No Google Drive links** in the GitHub comment (public repo rule from CLAUDE.md) +4. **Data tables with raw numbers**, not just qualitative assessments +5. **Clear recommendation** for the OpenShell core team, not just "it depends" + +## Open Questions + +- Should we also post findings to the upstream Agent Sandbox repo (e.g., as a discussion or issue comment on Issue #390)? +- Should the report include a proposed RFC outline for OpenShell warm pooling, or is that a separate step? +- How much of this should feed into Derek's demo prep for Peter Steinberger? From 6e11634ff7bb8de249793c7a1187ab5654228bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 10 Jul 2026 17:12:31 +0200 Subject: [PATCH 15/17] docs: add brainstorm for K8s watch stream crash fix (#2211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-By: 🤖 Claude Code Signed-off-by: Roland Huß --- brainstorm/05-k8s-watch-crash-fix.md | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 brainstorm/05-k8s-watch-crash-fix.md diff --git a/brainstorm/05-k8s-watch-crash-fix.md b/brainstorm/05-k8s-watch-crash-fix.md new file mode 100644 index 0000000000..e5f6561114 --- /dev/null +++ b/brainstorm/05-k8s-watch-crash-fix.md @@ -0,0 +1,49 @@ +# Brainstorm: K8s Watch Stream Crash Fix + +**Date:** 2026-07-10 +**Status:** active +**Issue:** [#2211](https://github.com/NVIDIA/OpenShell/issues/2211) + +## Problem Framing + +The Kubernetes driver's `watch_sandboxes()` crashes with a file descriptor leak when non-gateway `Sandbox` resources exist in the namespace. This happens when the Agent Sandbox operator's `SandboxWarmPool` controller creates `Sandbox` objects that the gateway did not create and does not expect. + +The gateway watch stream has no label selector, so it receives events for all `Sandbox` objects. Warm pool sandboxes lack the `openshell.ai/sandbox-id` label and the `sandbox-` name prefix, causing `sandbox_id_from_object()` to return an error. This error propagates as a stream failure, triggering a 2-second reconnect loop that leaks HTTP/2 connections until FD exhaustion crashes the process (13+ restarts observed). + +The same issue affects `list_sandboxes()`, which also uses unfiltered `ListParams::default()`. + +## Approaches Considered + +### A: Minimal targeted fix (chosen) +Add a label selector (`openshell.ai/managed-by=openshell`) to both `watch_sandboxes()` and `list_sandboxes()` to filter at the API server level. Additionally, change the watch loop to skip (with `debug!` log) objects that fail `sandbox_from_object()` / `sandbox_id_from_object()` instead of sending errors to the channel. + +- Pros: Minimal blast radius (one file, two functions). Reduces API server traffic. Defense-in-depth against future unknown object types. +- Cons: None significant. + +### B: Extract shared helper +Same as A, but extract the label selector construction into a helper function to avoid duplication between `watch_sandboxes()` and `list_sandboxes()`. + +- Pros: DRY for selector construction. +- Cons: Over-engineering for a one-liner format string used in two places. + +### C: Change sandbox_from_object signature +Make `sandbox_from_object` return `Option` instead of `Result`, changing all callers to skip `None`. + +- Pros: Treats unknown objects as normal at the type level. +- Cons: Larger change surface. Doesn't reduce API traffic. Loses error information for genuinely malformed objects. + +## Decision + +Approach A: minimal targeted fix with both label selector and defensive skip. + +## Key Requirements + +- Add `openshell.ai/managed-by=openshell` label selector to the `watcher::Config` in `watch_sandboxes()` (`driver.rs:717`) +- Add the same label selector to `ListParams` in `list_sandboxes()` (`driver.rs:488`) +- In the watch loop, all three event branches (Applied at line 741, Deleted at line 761, Restarted at line 782) must `debug!` log and continue instead of sending `Err` to the channel +- PR targets `origin` (fork) first, then submitted upstream to `NVIDIA/OpenShell` +- Unit tests for the skip behavior + +## Open Questions + +- Should the defensive skip use `debug!` or `warn!` level? (`debug!` seems right since the label filter should prevent this from happening in normal operation; it's only a fallback) From 99801e3f58696a9be5e86a9c39fddbde24190dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Fri, 10 Jul 2026 17:13:05 +0200 Subject: [PATCH 16/17] docs: update brainstorm overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-By: 🤖 Claude Code Signed-off-by: Roland Huß --- brainstorm/00-overview.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/brainstorm/00-overview.md b/brainstorm/00-overview.md index 1c5497fd5e..f2aeaa2785 100644 --- a/brainstorm/00-overview.md +++ b/brainstorm/00-overview.md @@ -1,6 +1,6 @@ # Brainstorm Overview -Last updated: 2026-07-09 +Last updated: 2026-07-10 ## Sessions @@ -10,6 +10,7 @@ Last updated: 2026-07-09 | 02 | 2026-07-09 | cluster-setup | active | - | - | | 03 | 2026-07-09 | warm-pool-measurements | active | - | - | | 04 | 2026-07-09 | results-and-recommendations | active | - | - | +| 05 | 2026-07-10 | k8s-watch-crash-fix | active | - | [#2211](https://github.com/NVIDIA/OpenShell/issues/2211) | ## Structure @@ -19,6 +20,8 @@ Last updated: 2026-07-09 - **03** depends on: 02 (cluster must be running) - **04** depends on: 03 (measurements must be complete) +05 is a standalone bug fix discovered during the warm pool feasibility study. + ## Open Threads - Does the Red Hat Agent Sandbox operator tech preview include extension CRDs? (from #01, #02) @@ -26,6 +29,7 @@ Last updated: 2026-07-09 - Is KEP-753 (native sidecars) available on the target OpenShift version? (from #01, #02) - How does pool exhaustion behave (cold fallback vs. Pending)? (from #03) - Should findings be posted to upstream agent-sandbox repo? (from #04) +- Should the defensive skip use `debug!` or `warn!` level? (from #05) ## Parked Ideas From 61496f8bf027689a296d5c4bd013575e5d8f8783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 11 Jul 2026 12:32:10 +0200 Subject: [PATCH 17/17] feat(sdk): add Go SDK for OpenShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Go SDK that provides typed clients for the OpenShell gateway and edge APIs. The SDK wraps the gRPC transport layer and exposes idiomatic Go types with functional option configuration. Includes: - Gateway and Edge API clients with full CRUD operations - In-memory fakes for testing (matching real client validation) - OIDC authentication with automatic token refresh - Proto generation automation (mise task + CI check) - Fern documentation (getting-started, architecture, auth, errors) Resolves: #2044 Assisted-By: 🤖 Claude Code Signed-off-by: Roland Huß --- .github/workflows/branch-checks.yml | 28 + docs/index.yml | 14 + docs/sdks/go/architecture.mdx | 104 + docs/sdks/go/authentication.mdx | 98 + docs/sdks/go/error-handling.mdx | 93 + docs/sdks/go/getting-started.mdx | 100 + sdk/go/Makefile | 45 + sdk/go/go.mod | 21 + sdk/go/go.sum | 52 + sdk/go/mise.toml | 242 + sdk/go/openshell/v1/auth.go | 48 + sdk/go/openshell/v1/auth_extra.go | 80 + sdk/go/openshell/v1/auth_extra_test.go | 177 + sdk/go/openshell/v1/auth_refresh.go | 127 + sdk/go/openshell/v1/auth_refresh_test.go | 373 + sdk/go/openshell/v1/auth_test.go | 36 + sdk/go/openshell/v1/client.go | 143 + sdk/go/openshell/v1/client_test.go | 61 + sdk/go/openshell/v1/config.go | 76 + sdk/go/openshell/v1/config_client.go | 67 + sdk/go/openshell/v1/config_client_test.go | 560 + sdk/go/openshell/v1/doc.go | 358 + sdk/go/openshell/v1/edge/cloudflare.go | 29 + sdk/go/openshell/v1/edge/cloudflare_test.go | 88 + sdk/go/openshell/v1/edge/doc.go | 88 + sdk/go/openshell/v1/edge/tunnel.go | 284 + sdk/go/openshell/v1/edge/tunnel_test.go | 494 + sdk/go/openshell/v1/errors.go | 56 + sdk/go/openshell/v1/errors_test.go | 125 + sdk/go/openshell/v1/example_fake_test.go | 155 + sdk/go/openshell/v1/example_test.go | 191 + sdk/go/openshell/v1/exec.go | 40 + sdk/go/openshell/v1/exec_client.go | 306 + sdk/go/openshell/v1/exec_client_test.go | 621 + sdk/go/openshell/v1/fake/broadcaster.go | 121 + sdk/go/openshell/v1/fake/broadcaster_test.go | 176 + sdk/go/openshell/v1/fake/config.go | 53 + sdk/go/openshell/v1/fake/config_test.go | 78 + sdk/go/openshell/v1/fake/doc.go | 37 + sdk/go/openshell/v1/fake/exec.go | 48 + sdk/go/openshell/v1/fake/exec_test.go | 70 + sdk/go/openshell/v1/fake/fake.go | 142 + sdk/go/openshell/v1/fake/fake_test.go | 231 + sdk/go/openshell/v1/fake/file.go | 40 + sdk/go/openshell/v1/fake/file_test.go | 52 + sdk/go/openshell/v1/fake/health.go | 45 + sdk/go/openshell/v1/fake/health_test.go | 49 + sdk/go/openshell/v1/fake/policy.go | 105 + sdk/go/openshell/v1/fake/policy_test.go | 109 + sdk/go/openshell/v1/fake/profile.go | 73 + sdk/go/openshell/v1/fake/profile_test.go | 100 + sdk/go/openshell/v1/fake/provider.go | 159 + sdk/go/openshell/v1/fake/provider_test.go | 276 + sdk/go/openshell/v1/fake/refresh.go | 57 + sdk/go/openshell/v1/fake/refresh_test.go | 78 + sdk/go/openshell/v1/fake/sandbox.go | 504 + sdk/go/openshell/v1/fake/sandbox_test.go | 814 + sdk/go/openshell/v1/fake/service.go | 57 + sdk/go/openshell/v1/fake/service_test.go | 72 + sdk/go/openshell/v1/fake/ssh.go | 58 + sdk/go/openshell/v1/fake/ssh_test.go | 87 + sdk/go/openshell/v1/fake/store.go | 137 + sdk/go/openshell/v1/fake/store_test.go | 248 + sdk/go/openshell/v1/fake/tcp.go | 59 + sdk/go/openshell/v1/fake/tcp_test.go | 104 + sdk/go/openshell/v1/file.go | 13 + sdk/go/openshell/v1/file_client.go | 116 + sdk/go/openshell/v1/file_client_test.go | 320 + sdk/go/openshell/v1/gateway/config.go | 157 + sdk/go/openshell/v1/gateway/config_test.go | 209 + sdk/go/openshell/v1/gateway/doc.go | 75 + sdk/go/openshell/v1/gateway/errors.go | 36 + sdk/go/openshell/v1/gateway/errors_test.go | 88 + sdk/go/openshell/v1/gateway/gateway.go | 182 + sdk/go/openshell/v1/gateway/gateway_test.go | 477 + sdk/go/openshell/v1/gateway/options.go | 63 + sdk/go/openshell/v1/gateway/paths.go | 168 + sdk/go/openshell/v1/gateway/paths_test.go | 217 + sdk/go/openshell/v1/gateway/token.go | 171 + sdk/go/openshell/v1/gateway/token_test.go | 252 + sdk/go/openshell/v1/grpc_errors.go | 6 + sdk/go/openshell/v1/health.go | 18 + sdk/go/openshell/v1/health_client.go | 32 + sdk/go/openshell/v1/health_client_test.go | 110 + sdk/go/openshell/v1/integration_test.go | 74 + .../openshell/v1/internal/converter/copy.go | 59 + .../openshell/v1/internal/converter/errors.go | 51 + .../v1/internal/converter/errors_test.go | 122 + .../openshell/v1/internal/converter/exec.go | 96 + .../v1/internal/converter/exec_test.go | 194 + sdk/go/openshell/v1/internal/converter/log.go | 47 + .../v1/internal/converter/log_test.go | 97 + .../v1/internal/converter/network_policy.go | 283 + .../internal/converter/network_policy_test.go | 272 + .../openshell/v1/internal/converter/policy.go | 304 + .../v1/internal/converter/policy_test.go | 608 + .../v1/internal/converter/profile.go | 288 + .../v1/internal/converter/profile_test.go | 411 + .../v1/internal/converter/provider.go | 71 + .../v1/internal/converter/provider_test.go | 132 + .../v1/internal/converter/refresh.go | 92 + .../v1/internal/converter/refresh_test.go | 166 + .../v1/internal/converter/sandbox.go | 181 + .../v1/internal/converter/sandbox_test.go | 365 + .../v1/internal/converter/service.go | 57 + .../v1/internal/converter/service_test.go | 131 + .../v1/internal/converter/setting.go | 299 + .../v1/internal/converter/setting_test.go | 825 + sdk/go/openshell/v1/internal/converter/ssh.go | 42 + .../v1/internal/converter/ssh_test.go | 113 + .../openshell/v1/internal/converter/time.go | 24 + .../v1/internal/converter/time_test.go | 42 + sdk/go/openshell/v1/internal/grpc/conn.go | 82 + sdk/go/openshell/v1/logger.go | 12 + sdk/go/openshell/v1/oidc/authcode.go | 228 + sdk/go/openshell/v1/oidc/authcode_test.go | 372 + sdk/go/openshell/v1/oidc/browser.go | 47 + sdk/go/openshell/v1/oidc/browser_test.go | 48 + sdk/go/openshell/v1/oidc/credentials.go | 142 + sdk/go/openshell/v1/oidc/credentials_test.go | 277 + sdk/go/openshell/v1/oidc/device.go | 285 + sdk/go/openshell/v1/oidc/device_test.go | 682 + sdk/go/openshell/v1/oidc/discovery.go | 119 + sdk/go/openshell/v1/oidc/discovery_test.go | 249 + sdk/go/openshell/v1/oidc/doc.go | 78 + sdk/go/openshell/v1/oidc/errors.go | 43 + sdk/go/openshell/v1/oidc/errors_test.go | 107 + sdk/go/openshell/v1/oidc/example_test.go | 160 + sdk/go/openshell/v1/oidc/keyboard.go | 64 + sdk/go/openshell/v1/oidc/keyboard_test.go | 138 + sdk/go/openshell/v1/oidc/oidc.go | 228 + sdk/go/openshell/v1/oidc/oidc_test.go | 496 + sdk/go/openshell/v1/oidc/options.go | 170 + sdk/go/openshell/v1/oidc/options_test.go | 155 + sdk/go/openshell/v1/oidc/token.go | 117 + sdk/go/openshell/v1/oidc/token_test.go | 182 + sdk/go/openshell/v1/options.go | 32 + sdk/go/openshell/v1/policy.go | 173 + sdk/go/openshell/v1/policy_client.go | 156 + sdk/go/openshell/v1/policy_client_test.go | 816 + sdk/go/openshell/v1/profile.go | 70 + sdk/go/openshell/v1/profile_client.go | 147 + sdk/go/openshell/v1/profile_client_test.go | 570 + sdk/go/openshell/v1/provider.go | 29 + sdk/go/openshell/v1/provider_client.go | 119 + sdk/go/openshell/v1/provider_client_test.go | 312 + sdk/go/openshell/v1/refresh.go | 41 + sdk/go/openshell/v1/refresh_client.go | 67 + sdk/go/openshell/v1/refresh_client_test.go | 426 + sdk/go/openshell/v1/sandbox.go | 73 + sdk/go/openshell/v1/sandbox_client.go | 270 + sdk/go/openshell/v1/sandbox_client_test.go | 971 ++ sdk/go/openshell/v1/service.go | 25 + sdk/go/openshell/v1/service_client.go | 82 + sdk/go/openshell/v1/service_client_test.go | 322 + sdk/go/openshell/v1/ssh.go | 58 + sdk/go/openshell/v1/ssh_client.go | 152 + sdk/go/openshell/v1/ssh_client_test.go | 609 + sdk/go/openshell/v1/tcp.go | 97 + sdk/go/openshell/v1/tcp_client.go | 376 + sdk/go/openshell/v1/tcp_client_test.go | 1286 ++ sdk/go/openshell/v1/types.go | 46 + sdk/go/openshell/v1/types/auth.go | 13 + sdk/go/openshell/v1/types/config.go | 16 + sdk/go/openshell/v1/types/doc.go | 10 + sdk/go/openshell/v1/types/errors.go | 122 + sdk/go/openshell/v1/types/exec.go | 17 + sdk/go/openshell/v1/types/health.go | 10 + sdk/go/openshell/v1/types/log.go | 98 + sdk/go/openshell/v1/types/logger.go | 12 + sdk/go/openshell/v1/types/network_policy.go | 157 + sdk/go/openshell/v1/types/options.go | 45 + sdk/go/openshell/v1/types/policy.go | 341 + sdk/go/openshell/v1/types/profile.go | 93 + sdk/go/openshell/v1/types/provider.go | 24 + sdk/go/openshell/v1/types/refresh.go | 43 + sdk/go/openshell/v1/types/sandbox.go | 71 + sdk/go/openshell/v1/types/service.go | 15 + sdk/go/openshell/v1/types/setting.go | 115 + sdk/go/openshell/v1/types/ssh.go | 34 + sdk/go/openshell/v1/types/types.go | 53 + sdk/go/openshell/v1/types/watch.go | 17 + sdk/go/openshell/v1/types_reexport.go | 57 + sdk/go/openshell/v1/watch.go | 46 + sdk/go/openshell/v1/watch_test.go | 134 + sdk/go/proto/UPSTREAM_VERSION | 1 + sdk/go/proto/datamodel.proto | 44 + sdk/go/proto/datamodelv1/datamodel.pb.go | 284 + sdk/go/proto/openshell.proto | 1899 +++ sdk/go/proto/openshellv1/openshell.pb.go | 12469 ++++++++++++++++ sdk/go/proto/openshellv1/openshell_grpc.pb.go | 2345 +++ sdk/go/proto/sandbox.proto | 275 + sdk/go/proto/sandboxv1/sandbox.pb.go | 1753 +++ tasks/go.toml | 27 + 194 files changed, 50221 insertions(+) create mode 100644 docs/sdks/go/architecture.mdx create mode 100644 docs/sdks/go/authentication.mdx create mode 100644 docs/sdks/go/error-handling.mdx create mode 100644 docs/sdks/go/getting-started.mdx create mode 100644 sdk/go/Makefile create mode 100644 sdk/go/go.mod create mode 100644 sdk/go/go.sum create mode 100644 sdk/go/mise.toml create mode 100644 sdk/go/openshell/v1/auth.go create mode 100644 sdk/go/openshell/v1/auth_extra.go create mode 100644 sdk/go/openshell/v1/auth_extra_test.go create mode 100644 sdk/go/openshell/v1/auth_refresh.go create mode 100644 sdk/go/openshell/v1/auth_refresh_test.go create mode 100644 sdk/go/openshell/v1/auth_test.go create mode 100644 sdk/go/openshell/v1/client.go create mode 100644 sdk/go/openshell/v1/client_test.go create mode 100644 sdk/go/openshell/v1/config.go create mode 100644 sdk/go/openshell/v1/config_client.go create mode 100644 sdk/go/openshell/v1/config_client_test.go create mode 100644 sdk/go/openshell/v1/doc.go create mode 100644 sdk/go/openshell/v1/edge/cloudflare.go create mode 100644 sdk/go/openshell/v1/edge/cloudflare_test.go create mode 100644 sdk/go/openshell/v1/edge/doc.go create mode 100644 sdk/go/openshell/v1/edge/tunnel.go create mode 100644 sdk/go/openshell/v1/edge/tunnel_test.go create mode 100644 sdk/go/openshell/v1/errors.go create mode 100644 sdk/go/openshell/v1/errors_test.go create mode 100644 sdk/go/openshell/v1/example_fake_test.go create mode 100644 sdk/go/openshell/v1/example_test.go create mode 100644 sdk/go/openshell/v1/exec.go create mode 100644 sdk/go/openshell/v1/exec_client.go create mode 100644 sdk/go/openshell/v1/exec_client_test.go create mode 100644 sdk/go/openshell/v1/fake/broadcaster.go create mode 100644 sdk/go/openshell/v1/fake/broadcaster_test.go create mode 100644 sdk/go/openshell/v1/fake/config.go create mode 100644 sdk/go/openshell/v1/fake/config_test.go create mode 100644 sdk/go/openshell/v1/fake/doc.go create mode 100644 sdk/go/openshell/v1/fake/exec.go create mode 100644 sdk/go/openshell/v1/fake/exec_test.go create mode 100644 sdk/go/openshell/v1/fake/fake.go create mode 100644 sdk/go/openshell/v1/fake/fake_test.go create mode 100644 sdk/go/openshell/v1/fake/file.go create mode 100644 sdk/go/openshell/v1/fake/file_test.go create mode 100644 sdk/go/openshell/v1/fake/health.go create mode 100644 sdk/go/openshell/v1/fake/health_test.go create mode 100644 sdk/go/openshell/v1/fake/policy.go create mode 100644 sdk/go/openshell/v1/fake/policy_test.go create mode 100644 sdk/go/openshell/v1/fake/profile.go create mode 100644 sdk/go/openshell/v1/fake/profile_test.go create mode 100644 sdk/go/openshell/v1/fake/provider.go create mode 100644 sdk/go/openshell/v1/fake/provider_test.go create mode 100644 sdk/go/openshell/v1/fake/refresh.go create mode 100644 sdk/go/openshell/v1/fake/refresh_test.go create mode 100644 sdk/go/openshell/v1/fake/sandbox.go create mode 100644 sdk/go/openshell/v1/fake/sandbox_test.go create mode 100644 sdk/go/openshell/v1/fake/service.go create mode 100644 sdk/go/openshell/v1/fake/service_test.go create mode 100644 sdk/go/openshell/v1/fake/ssh.go create mode 100644 sdk/go/openshell/v1/fake/ssh_test.go create mode 100644 sdk/go/openshell/v1/fake/store.go create mode 100644 sdk/go/openshell/v1/fake/store_test.go create mode 100644 sdk/go/openshell/v1/fake/tcp.go create mode 100644 sdk/go/openshell/v1/fake/tcp_test.go create mode 100644 sdk/go/openshell/v1/file.go create mode 100644 sdk/go/openshell/v1/file_client.go create mode 100644 sdk/go/openshell/v1/file_client_test.go create mode 100644 sdk/go/openshell/v1/gateway/config.go create mode 100644 sdk/go/openshell/v1/gateway/config_test.go create mode 100644 sdk/go/openshell/v1/gateway/doc.go create mode 100644 sdk/go/openshell/v1/gateway/errors.go create mode 100644 sdk/go/openshell/v1/gateway/errors_test.go create mode 100644 sdk/go/openshell/v1/gateway/gateway.go create mode 100644 sdk/go/openshell/v1/gateway/gateway_test.go create mode 100644 sdk/go/openshell/v1/gateway/options.go create mode 100644 sdk/go/openshell/v1/gateway/paths.go create mode 100644 sdk/go/openshell/v1/gateway/paths_test.go create mode 100644 sdk/go/openshell/v1/gateway/token.go create mode 100644 sdk/go/openshell/v1/gateway/token_test.go create mode 100644 sdk/go/openshell/v1/grpc_errors.go create mode 100644 sdk/go/openshell/v1/health.go create mode 100644 sdk/go/openshell/v1/health_client.go create mode 100644 sdk/go/openshell/v1/health_client_test.go create mode 100644 sdk/go/openshell/v1/integration_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/copy.go create mode 100644 sdk/go/openshell/v1/internal/converter/errors.go create mode 100644 sdk/go/openshell/v1/internal/converter/errors_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/exec.go create mode 100644 sdk/go/openshell/v1/internal/converter/exec_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/log.go create mode 100644 sdk/go/openshell/v1/internal/converter/log_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/network_policy.go create mode 100644 sdk/go/openshell/v1/internal/converter/network_policy_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/policy.go create mode 100644 sdk/go/openshell/v1/internal/converter/policy_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/profile.go create mode 100644 sdk/go/openshell/v1/internal/converter/profile_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/provider.go create mode 100644 sdk/go/openshell/v1/internal/converter/provider_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/refresh.go create mode 100644 sdk/go/openshell/v1/internal/converter/refresh_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/sandbox.go create mode 100644 sdk/go/openshell/v1/internal/converter/sandbox_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/service.go create mode 100644 sdk/go/openshell/v1/internal/converter/service_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/setting.go create mode 100644 sdk/go/openshell/v1/internal/converter/setting_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/ssh.go create mode 100644 sdk/go/openshell/v1/internal/converter/ssh_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/time.go create mode 100644 sdk/go/openshell/v1/internal/converter/time_test.go create mode 100644 sdk/go/openshell/v1/internal/grpc/conn.go create mode 100644 sdk/go/openshell/v1/logger.go create mode 100644 sdk/go/openshell/v1/oidc/authcode.go create mode 100644 sdk/go/openshell/v1/oidc/authcode_test.go create mode 100644 sdk/go/openshell/v1/oidc/browser.go create mode 100644 sdk/go/openshell/v1/oidc/browser_test.go create mode 100644 sdk/go/openshell/v1/oidc/credentials.go create mode 100644 sdk/go/openshell/v1/oidc/credentials_test.go create mode 100644 sdk/go/openshell/v1/oidc/device.go create mode 100644 sdk/go/openshell/v1/oidc/device_test.go create mode 100644 sdk/go/openshell/v1/oidc/discovery.go create mode 100644 sdk/go/openshell/v1/oidc/discovery_test.go create mode 100644 sdk/go/openshell/v1/oidc/doc.go create mode 100644 sdk/go/openshell/v1/oidc/errors.go create mode 100644 sdk/go/openshell/v1/oidc/errors_test.go create mode 100644 sdk/go/openshell/v1/oidc/example_test.go create mode 100644 sdk/go/openshell/v1/oidc/keyboard.go create mode 100644 sdk/go/openshell/v1/oidc/keyboard_test.go create mode 100644 sdk/go/openshell/v1/oidc/oidc.go create mode 100644 sdk/go/openshell/v1/oidc/oidc_test.go create mode 100644 sdk/go/openshell/v1/oidc/options.go create mode 100644 sdk/go/openshell/v1/oidc/options_test.go create mode 100644 sdk/go/openshell/v1/oidc/token.go create mode 100644 sdk/go/openshell/v1/oidc/token_test.go create mode 100644 sdk/go/openshell/v1/options.go create mode 100644 sdk/go/openshell/v1/policy.go create mode 100644 sdk/go/openshell/v1/policy_client.go create mode 100644 sdk/go/openshell/v1/policy_client_test.go create mode 100644 sdk/go/openshell/v1/profile.go create mode 100644 sdk/go/openshell/v1/profile_client.go create mode 100644 sdk/go/openshell/v1/profile_client_test.go create mode 100644 sdk/go/openshell/v1/provider.go create mode 100644 sdk/go/openshell/v1/provider_client.go create mode 100644 sdk/go/openshell/v1/provider_client_test.go create mode 100644 sdk/go/openshell/v1/refresh.go create mode 100644 sdk/go/openshell/v1/refresh_client.go create mode 100644 sdk/go/openshell/v1/refresh_client_test.go create mode 100644 sdk/go/openshell/v1/sandbox.go create mode 100644 sdk/go/openshell/v1/sandbox_client.go create mode 100644 sdk/go/openshell/v1/sandbox_client_test.go create mode 100644 sdk/go/openshell/v1/service.go create mode 100644 sdk/go/openshell/v1/service_client.go create mode 100644 sdk/go/openshell/v1/service_client_test.go create mode 100644 sdk/go/openshell/v1/ssh.go create mode 100644 sdk/go/openshell/v1/ssh_client.go create mode 100644 sdk/go/openshell/v1/ssh_client_test.go create mode 100644 sdk/go/openshell/v1/tcp.go create mode 100644 sdk/go/openshell/v1/tcp_client.go create mode 100644 sdk/go/openshell/v1/tcp_client_test.go create mode 100644 sdk/go/openshell/v1/types.go create mode 100644 sdk/go/openshell/v1/types/auth.go create mode 100644 sdk/go/openshell/v1/types/config.go create mode 100644 sdk/go/openshell/v1/types/doc.go create mode 100644 sdk/go/openshell/v1/types/errors.go create mode 100644 sdk/go/openshell/v1/types/exec.go create mode 100644 sdk/go/openshell/v1/types/health.go create mode 100644 sdk/go/openshell/v1/types/log.go create mode 100644 sdk/go/openshell/v1/types/logger.go create mode 100644 sdk/go/openshell/v1/types/network_policy.go create mode 100644 sdk/go/openshell/v1/types/options.go create mode 100644 sdk/go/openshell/v1/types/policy.go create mode 100644 sdk/go/openshell/v1/types/profile.go create mode 100644 sdk/go/openshell/v1/types/provider.go create mode 100644 sdk/go/openshell/v1/types/refresh.go create mode 100644 sdk/go/openshell/v1/types/sandbox.go create mode 100644 sdk/go/openshell/v1/types/service.go create mode 100644 sdk/go/openshell/v1/types/setting.go create mode 100644 sdk/go/openshell/v1/types/ssh.go create mode 100644 sdk/go/openshell/v1/types/types.go create mode 100644 sdk/go/openshell/v1/types/watch.go create mode 100644 sdk/go/openshell/v1/types_reexport.go create mode 100644 sdk/go/openshell/v1/watch.go create mode 100644 sdk/go/openshell/v1/watch_test.go create mode 100644 sdk/go/proto/UPSTREAM_VERSION create mode 100644 sdk/go/proto/datamodel.proto create mode 100644 sdk/go/proto/datamodelv1/datamodel.pb.go create mode 100644 sdk/go/proto/openshell.proto create mode 100644 sdk/go/proto/openshellv1/openshell.pb.go create mode 100644 sdk/go/proto/openshellv1/openshell_grpc.pb.go create mode 100644 sdk/go/proto/sandbox.proto create mode 100644 sdk/go/proto/sandboxv1/sandbox.pb.go create mode 100644 tasks/go.toml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 7713febb6f..c5081b550d 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -190,3 +190,31 @@ jobs: - name: Lint run: mise run markdown:lint + + go: + name: Go + 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 }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Lint + run: mise run go:lint + + - name: Build + run: mise run go:build + + - name: Test + run: mise run go:test + + - name: Proto check + run: mise run go:proto:check diff --git a/docs/index.yml b/docs/index.yml index b2443e4afd..fe0d935e17 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -23,6 +23,20 @@ navigation: title: "Observability" - folder: kubernetes title: "Kubernetes" +- section: "SDKs" + slug: sdks + contents: + - section: "Go SDK" + slug: go + contents: + - page: "Getting Started" + path: sdks/go/getting-started.mdx + - page: "Architecture" + path: sdks/go/architecture.mdx + - page: "Error Handling" + path: sdks/go/error-handling.mdx + - page: "Authentication" + path: sdks/go/authentication.mdx - folder: reference title: "Reference" - folder: security diff --git a/docs/sdks/go/architecture.mdx b/docs/sdks/go/architecture.mdx new file mode 100644 index 0000000000..46748a1085 --- /dev/null +++ b/docs/sdks/go/architecture.mdx @@ -0,0 +1,104 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Architecture" +description: "Module structure, gRPC transport layer, and proto isolation pattern in the Go SDK." +keywords: "Go SDK, Architecture, gRPC, Protobuf, Module Structure, Transport" +--- + +The Go SDK follows a layered architecture that keeps protocol details +internal while exposing idiomatic Go types to consumers. + +## Module Structure + +``` +sdk/go/ +├── openshell/v1/ # Public API surface +│ ├── client.go # Top-level Client with sub-clients +│ ├── gateway/ # Gateway client factory (reads CLI config) +│ ├── edge/ # Edge API client +│ ├── fake/ # In-memory fakes for testing +│ ├── types/ # Domain types (Config, errors, options) +│ ├── oidc/ # OIDC authentication +│ └── internal/ # Internal helpers (not importable) +│ ├── converter/ # Proto-to-SDK type conversion +│ └── grpc/ # gRPC connection management +└── proto/ # Proto definitions + generated code + ├── openshellv1/ # Generated .pb.go files + ├── datamodelv1/ + └── sandboxv1/ +``` + +## Transport Layer + +The SDK communicates with the OpenShell gateway over gRPC. The transport +is fully internal, and consumers interact only with Go types. + +``` +Consumer Code + │ + ▼ +openshell/v1.Client ← Public API (Go types) + │ + ▼ +internal/converter ← Proto ↔ SDK type conversion + │ + ▼ +internal/grpc ← Connection management, TLS, auth + │ + ▼ +gRPC transport ← Wire protocol +``` + +## Proto Isolation + +Generated protobuf types live in `proto/` packages and are never exposed +through the public API. The `internal/converter` package handles all +translation between proto messages and SDK domain types. + +This means: + +- Consumers never import `proto/` packages directly. +- Proto schema changes do not break the public API. +- Deep copies happen at the boundary, so returned values are safe to mutate. + +## Sub-Clients + +The top-level `Client` provides access to domain-specific sub-clients: + +| Method | Returns | Purpose | +|--------|---------|---------| +| `Sandboxes()` | `SandboxInterface` | Create, list, get, delete sandboxes | +| `Providers()` | `ProviderInterface` | List and inspect compute providers | +| `Services()` | `ServiceInterface` | Manage sandbox services | +| `Exec()` | `ExecInterface` | Execute commands in sandboxes | +| `Files()` | `FileInterface` | Upload and download files | +| `Health()` | `HealthInterface` | Gateway health checks | +| `SSH()` | `SSHInterface` | SSH tunnel management | +| `TCP()` | `TCPInterface` | TCP port forwarding | +| `Policy()` | `PolicyInterface` | Network policy management | + +## Client Construction + +There are two ways to create a client: + +**From gateway configuration** (reads `~/.config/openshell/`): + +```go +client, err := gateway.NewClient("my-gateway") +``` + +**From explicit configuration**: + +```go +client, err := v1.NewClient(types.Config{ + Address: "localhost:8080", + Auth: myAuthProvider, +}) +``` + +## Concurrency + +All client methods are safe for concurrent use. The underlying gRPC +connection handles multiplexing. Call `client.Close()` when done to +release resources. diff --git a/docs/sdks/go/authentication.mdx b/docs/sdks/go/authentication.mdx new file mode 100644 index 0000000000..375539fa51 --- /dev/null +++ b/docs/sdks/go/authentication.mdx @@ -0,0 +1,98 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Authentication" +description: "OIDC authentication, token refresh, and gateway auth configuration for the Go SDK." +keywords: "Go SDK, Authentication, OIDC, Token Refresh, TLS, Security" +--- + +The Go SDK supports multiple authentication modes depending on how the +gateway is configured. The gateway client reads auth settings from the +CLI configuration automatically. + +## Authentication Modes + +| Mode | When to Use | Configuration | +|------|-------------|---------------| +| **OIDC** | Production gateways with identity provider | `openshell gateway auth` configures tokens | +| **API Key** | Simple deployments, development | Set via gateway config or `WithAuth` option | +| **None** | Local development, insecure gateways | Default when no auth is configured | + +## Automatic Auth (Recommended) + +When using `gateway.NewClient()`, authentication is resolved automatically +from the CLI configuration: + +```go +// Auth is read from ~/.config/openshell//config.yaml +client, err := gateway.NewClient("my-gateway") +``` + +The CLI stores OIDC tokens after `openshell gateway auth`. The SDK reads +these tokens and refreshes them transparently. + +## OIDC Token Refresh + +For OIDC-authenticated gateways, the SDK handles token refresh +automatically. You can customize the refresh behavior: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" + +// The refresh wrapper handles token renewal before expiry. +// Configure leeway to refresh tokens before they expire. +refresher := v1.NewTokenRefresher(baseAuth, + v1.WithLeeway(30 * time.Second), + v1.WithLogger(myLogger), +) + +client, err := gateway.NewClient("my-gateway", + gateway.WithAuth(refresher), +) +``` + +## Custom Auth Provider + +For programmatic authentication, implement the `AuthProvider` interface +or use the built-in providers: + +```go +// Static API key +client, err := gateway.NewClient("my-gateway", + gateway.WithAuth(types.StaticToken("my-api-key")), +) +``` + +The `AuthProvider` interface provides gRPC per-RPC credentials. Each +call attaches the token to the request metadata automatically. + +## TLS Configuration + +Control TLS settings when connecting to gateways: + +```go +client, err := gateway.NewClient("my-gateway", + gateway.WithTLS(&types.TLSConfig{ + InsecureSkipVerify: false, + CACertFile: "/path/to/ca.crt", + }), +) +``` + +For local development with self-signed certificates, you can skip +verification, but this should never be used in production. + +## Testing with Fakes + +The fake client does not require authentication. Use it in tests to +avoid gateway dependencies: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + +func TestMyFeature(t *testing.T) { + client := fake.NewClient() + // Use client.Sandboxes(), client.Exec(), etc. + // No gateway connection or auth needed. +} +``` diff --git a/docs/sdks/go/error-handling.mdx b/docs/sdks/go/error-handling.mdx new file mode 100644 index 0000000000..256dc2cde0 --- /dev/null +++ b/docs/sdks/go/error-handling.mdx @@ -0,0 +1,93 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Error Handling" +description: "SDK error types, gRPC status code mapping, and retry patterns." +keywords: "Go SDK, Errors, gRPC, Status Codes, Retry, Error Handling" +--- + +The SDK translates gRPC status codes into typed Go errors. All errors +returned by client methods can be inspected with `errors.As` to extract +structured details. + +## StatusError + +The primary error type is `types.StatusError`: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + +sandbox, err := client.Sandboxes().Get(ctx, "missing") +if err != nil { + var se *types.StatusError + if errors.As(err, &se) { + fmt.Printf("Code: %d, Message: %s\n", se.Code, se.Message) + } +} +``` + +## Error Codes + +The SDK defines error codes that map to gRPC status codes: + +| SDK Error Code | gRPC Status | Meaning | +|----------------|-------------|---------| +| `NotFound` | `NOT_FOUND` | Resource does not exist | +| `AlreadyExists` | `ALREADY_EXISTS` | Resource name conflict | +| `InvalidArgument` | `INVALID_ARGUMENT` | Bad request parameters | +| `PermissionDenied` | `PERMISSION_DENIED` | Insufficient credentials | +| `Unauthenticated` | `UNAUTHENTICATED` | Missing or expired token | +| `Unavailable` | `UNAVAILABLE` | Gateway is unreachable | +| `Internal` | `INTERNAL` | Server-side failure | + +## Checking Error Types + +Use helper functions or direct comparison: + +```go +if err != nil { + var se *types.StatusError + if errors.As(err, &se) { + switch se.Code { + case types.NotFound: + // Handle missing resource + case types.Unavailable: + // Retry or fail over + default: + // Log and return + } + } +} +``` + +## Retry Configuration + +Configure automatic retries for transient failures: + +```go +client, err := gateway.NewClient("my-gateway", + gateway.WithRetryPolicy(&types.RetryPolicy{ + MaxRetries: 3, + Backoff: 100 * time.Millisecond, + }), +) +``` + +The retry policy applies to `Unavailable` and `DeadlineExceeded` errors. +Other error codes are returned immediately without retrying. + +## Context Cancellation + +All client methods accept a `context.Context`. Cancelled or timed-out +contexts produce standard Go `context.Canceled` or +`context.DeadlineExceeded` errors: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +sandboxes, err := client.Sandboxes().List(ctx) +if errors.Is(err, context.DeadlineExceeded) { + // Request timed out +} +``` diff --git a/docs/sdks/go/getting-started.mdx b/docs/sdks/go/getting-started.mdx new file mode 100644 index 0000000000..96a5a927a5 --- /dev/null +++ b/docs/sdks/go/getting-started.mdx @@ -0,0 +1,100 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Getting Started" +description: "Install the OpenShell Go SDK, connect to a gateway, and make your first API call." +keywords: "Go SDK, Installation, Quickstart, Gateway, gRPC, Sandbox" +--- + +The Go SDK provides typed clients for the OpenShell gateway and edge APIs. +It wraps gRPC transport and exposes idiomatic Go types with functional option +configuration. + +## Installation + +```shell +go get github.com/NVIDIA/OpenShell/sdk/go@latest +``` + +Requires Go 1.25 or later. + +## Connect to a Gateway + +The gateway client reads connection details from the CLI configuration +(`~/.config/openshell/`). Pass the gateway name or leave it empty to use +the active gateway. + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +func main() { + // Connect to the active gateway (configured via `openshell gateway use`). + client, err := gateway.NewClient("") + if err != nil { + log.Fatal(err) + } + defer client.Close() + + // List sandboxes. + ctx := context.Background() + sandboxes, err := client.Sandboxes().List(ctx) + if err != nil { + log.Fatal(err) + } + for _, sb := range sandboxes { + fmt.Printf("Sandbox: %s (status: %s)\n", sb.Name, sb.Status) + } +} +``` + +## Create a Sandbox + +```go +sandbox, err := client.Sandboxes().Create(ctx, &types.CreateSandboxRequest{ + Name: "my-sandbox", + Image: "ubuntu:24.04", +}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Created sandbox %s\n", sandbox.Name) +``` + +## Run a Command + +```go +result, err := client.Exec().Run(ctx, "my-sandbox", "echo hello") +if err != nil { + log.Fatal(err) +} +fmt.Println(result.Stdout) +``` + +## Client Options + +Configure the client with functional options: + +```go +client, err := gateway.NewClient("my-gateway", + gateway.WithTimeout(30 * time.Second), + gateway.WithLogger(myLogger), + gateway.WithRetryPolicy(&types.RetryPolicy{ + MaxRetries: 3, + Backoff: 100 * time.Millisecond, + }), +) +``` + +## Next Steps + +- [Architecture](/sdks/go/architecture) for module structure and transport details +- [Error Handling](/sdks/go/error-handling) for SDK error types and retry patterns +- [Authentication](/sdks/go/authentication) for OIDC and token configuration diff --git a/sdk/go/Makefile b/sdk/go/Makefile new file mode 100644 index 0000000000..32170c0f09 --- /dev/null +++ b/sdk/go/Makefile @@ -0,0 +1,45 @@ +MISE := $(shell command -v mise 2>/dev/null) + +.PHONY: test test-integration lint fmt build ci docs-check + +test: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run test + +test-integration: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run test:integration + +lint: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run lint + +fmt: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run fmt + +build: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run build + +ci: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run ci + +docs-check: +ifndef MISE + $(error mise is not installed. Install from https://mise.jdx.dev) +endif + mise run docs:check diff --git a/sdk/go/go.mod b/sdk/go/go.mod new file mode 100644 index 0000000000..26298aebc8 --- /dev/null +++ b/sdk/go/go.mod @@ -0,0 +1,21 @@ +module github.com/NVIDIA/OpenShell/sdk/go + +go 1.25.0 + +require ( + github.com/coder/websocket v1.8.15 + github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.36.0 + google.golang.org/grpc v1.81.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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/sdk/go/go.sum b/sdk/go/go.sum new file mode 100644 index 0000000000..dc14bb3d80 --- /dev/null +++ b/sdk/go/go.sum @@ -0,0 +1,52 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +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= +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/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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdk/go/mise.toml b/sdk/go/mise.toml new file mode 100644 index 0000000000..a5b90b1931 --- /dev/null +++ b/sdk/go/mise.toml @@ -0,0 +1,242 @@ +[tools] +go = "1.25" +protoc = "29.6" +"go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint" = "2.12" +"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11" +"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2" + +[tasks.test] +description = "Run unit tests with coverage" +run = "go test -coverprofile=coverage.out -coverpkg=./openshell/... -race ./..." + +[tasks."test:integration"] +description = "Run integration tests" +run = "go test -tags=integration -race ./..." + +[tasks.lint] +description = "Run linter" +run = "golangci-lint run ./..." + +[tasks.fmt] +description = "Format code" +run = "goimports -w . && go fmt ./..." + +[tasks.build] +description = "Build all packages" +run = "go build ./..." + +[tasks.ci] +description = "Run full CI pipeline" +depends = ["lint", "build", "test", "proto:check", "docs:check"] + +[tasks."docs:check"] +description = "Verify every public package has a docs page" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +DOCS_DIR="docs/src/api" +SUMMARY="docs/src/SUMMARY.md" +MISSING=0 + +# Find all public packages with a doc.go (excluding internal, proto, types) +for docfile in openshell/v1/*/doc.go; do + pkg=$(basename "$(dirname "$docfile")") + + # Skip internal packages and types (no user-facing docs needed) + case "$pkg" in + internal|types) continue ;; + esac + + # Check for matching docs page + if [ ! -f "$DOCS_DIR/$pkg.md" ]; then + echo "MISSING: $DOCS_DIR/$pkg.md (package openshell/v1/$pkg has doc.go but no docs page)" + MISSING=$((MISSING + 1)) + fi + + # Check for SUMMARY.md entry + if ! grep -q "api/$pkg.md" "$SUMMARY" 2>/dev/null; then + echo "MISSING: SUMMARY.md entry for api/$pkg.md" + MISSING=$((MISSING + 1)) + fi +done + +if [ "$MISSING" -gt 0 ]; then + echo "" + echo "ERROR: $MISSING documentation gaps found." + echo "Every public package with doc.go needs a docs/src/api/.md page" + echo "and a SUMMARY.md entry. See Constitution XIII." + exit 1 +fi + +echo "Docs check passed: all public packages have documentation." +""" + +[tasks."proto:gen"] +description = "Generate Go bindings from proto files" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +# Check required tools +for tool in protoc protoc-gen-go protoc-gen-go-grpc; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Run 'mise install' to install it." + exit 1 + fi +done + +MODULE="github.com/NVIDIA/OpenShell/sdk/go" +PROTO_DIR="proto" + +# Create output directories +mkdir -p "$PROTO_DIR/openshellv1" "$PROTO_DIR/datamodelv1" "$PROTO_DIR/sandboxv1" + +# M flag mappings for Go package paths +M_FLAGS="" +M_FLAGS="$M_FLAGS --go_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go_opt=Msandbox.proto=$MODULE/proto/sandboxv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Msandbox.proto=$MODULE/proto/sandboxv1" + +# Generate Go code for all proto files +# Use module= option to strip the module prefix from the output path, +# so files land in proto/openshellv1/ etc. instead of the full module path +protoc \ + --proto_path="$PROTO_DIR" \ + --go_out=. \ + --go_opt=module=$MODULE \ + --go-grpc_out=. \ + --go-grpc_opt=module=$MODULE \ + $M_FLAGS \ + "$PROTO_DIR/openshell.proto" \ + "$PROTO_DIR/datamodel.proto" \ + "$PROTO_DIR/sandbox.proto" + +echo "Proto generation complete." +echo "Generated packages:" +for pkg in openshellv1 datamodelv1 sandboxv1; do + echo " $PROTO_DIR/$pkg/: $(ls "$PROTO_DIR/$pkg/"*.go 2>/dev/null | wc -l | tr -d ' ') files" +done +""" + +[tasks."proto:check"] +description = "Verify generated proto files are up to date" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +# Check required tools +for tool in protoc protoc-gen-go protoc-gen-go-grpc; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Run 'mise install' to install it." + exit 1 + fi +done + +MODULE="github.com/NVIDIA/OpenShell/sdk/go" +PROTO_DIR="proto" +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +# Create output directories in temp +mkdir -p "$WORK_DIR/proto/openshellv1" "$WORK_DIR/proto/datamodelv1" "$WORK_DIR/proto/sandboxv1" + +# M flag mappings (same as proto:gen) +M_FLAGS="" +M_FLAGS="$M_FLAGS --go_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go_opt=Msandbox.proto=$MODULE/proto/sandboxv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mopenshell.proto=$MODULE/proto/openshellv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Mdatamodel.proto=$MODULE/proto/datamodelv1" +M_FLAGS="$M_FLAGS --go-grpc_opt=Msandbox.proto=$MODULE/proto/sandboxv1" + +# Generate to temp directory +protoc \ + --proto_path="$PROTO_DIR" \ + --go_out="$WORK_DIR" \ + --go_opt=module=$MODULE \ + --go-grpc_out="$WORK_DIR" \ + --go-grpc_opt=module=$MODULE \ + $M_FLAGS \ + "$PROTO_DIR/openshell.proto" \ + "$PROTO_DIR/datamodel.proto" \ + "$PROTO_DIR/sandbox.proto" + +# Diff against committed files +DIFF_OUTPUT=$(diff -r "$WORK_DIR/proto" "$PROTO_DIR" \ + --exclude="*.proto" \ + --exclude="UPSTREAM_VERSION" \ + 2>&1) || true + +if [ -n "$DIFF_OUTPUT" ]; then + echo "ERROR: Generated proto files are out of date." + echo "Run 'mise run proto:gen' to regenerate." + echo "" + echo "$DIFF_OUTPUT" + exit 1 +fi + +echo "Proto check passed: generated files are up to date." +""" + +[tasks."proto:clean"] +description = "Remove all generated proto files" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +PROTO_DIR="proto" + +# Remove all generated .pb.go files +find "$PROTO_DIR" -name "*.pb.go" -delete 2>/dev/null || true + +# Remove empty generated subdirectories +for dir in "$PROTO_DIR"/*/; do + [ -d "$dir" ] && rmdir "$dir" 2>/dev/null || true +done + +echo "Proto clean complete." +""" + +[tasks."proto:sync"] +description = "Sync proto files from upstream OpenShell repo" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +UPSTREAM_PATH="${UPSTREAM_PATH:-../OpenShell/proto}" + +if [ ! -d "$UPSTREAM_PATH" ]; then + echo "ERROR: Upstream proto directory not found: $UPSTREAM_PATH" + echo "Set UPSTREAM_PATH env var to the correct location." + exit 1 +fi + +PROTO_DIR="proto" +mkdir -p "$PROTO_DIR" + +PROTO_FILES="openshell.proto datamodel.proto sandbox.proto" +for f in $PROTO_FILES; do + src="$UPSTREAM_PATH/$f" + if [ ! -f "$src" ]; then + echo "ERROR: Proto file not found: $src" + exit 1 + fi + cp "$src" "$PROTO_DIR/$f" + echo "Copied $f" +done + +# Record upstream commit SHA +UPSTREAM_REPO_DIR="$(cd "$UPSTREAM_PATH/.." && pwd)" +if [ -d "$UPSTREAM_REPO_DIR/.git" ]; then + SHA=$(git -C "$UPSTREAM_REPO_DIR" rev-parse HEAD) +else + SHA="unknown" +fi +echo "$SHA" > "$PROTO_DIR/UPSTREAM_VERSION" +echo "Upstream version: $SHA" +echo "Proto sync complete." +""" diff --git a/sdk/go/openshell/v1/auth.go b/sdk/go/openshell/v1/auth.go new file mode 100644 index 0000000000..95a7838f08 --- /dev/null +++ b/sdk/go/openshell/v1/auth.go @@ -0,0 +1,48 @@ +// 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" +) + +// AuthProvider supplies per-RPC credentials. It implements the +// grpc credentials.PerRPCCredentials interface. +type AuthProvider = types.AuthProvider + +type noAuth struct{} + +// NoAuth returns an AuthProvider that sends no credentials. +func NoAuth() AuthProvider { + return &noAuth{} +} + +func (n *noAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return nil, nil +} + +func (n *noAuth) RequireTransportSecurity() bool { + return false +} + +type staticToken struct { + token string +} + +// StaticToken returns an AuthProvider that sends a fixed Bearer token. +func StaticToken(token string) AuthProvider { + return &staticToken{token: token} +} + +func (s *staticToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + s.token, + }, nil +} + +func (s *staticToken) RequireTransportSecurity() bool { + return true +} diff --git a/sdk/go/openshell/v1/auth_extra.go b/sdk/go/openshell/v1/auth_extra.go new file mode 100644 index 0000000000..21a9e9e2cf --- /dev/null +++ b/sdk/go/openshell/v1/auth_extra.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "maps" + "strings" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// extraHeadersAuth wraps a base AuthProvider with additional static headers +// that are merged into every GetRequestMetadata call. Extra headers take +// precedence over base headers on key collision (case-insensitive). +type extraHeadersAuth struct { + base types.AuthProvider + headers map[string]string // keys already lowercase, empty values filtered out +} + +// WithExtraHeaders wraps base with additional per-RPC headers. Keys are +// normalized to lowercase per HTTP/2 (RFC 9113). Empty-string values are +// silently dropped. The headers map is deep-copied at construction time, +// so later mutations to the caller's map have no effect. +// +// Returns an error if base is nil or if headers is nil, empty, or contains +// only empty-string values. +func WithExtraHeaders(base AuthProvider, headers map[string]string) (AuthProvider, error) { + if base == nil { + return nil, errors.New("base auth provider must not be nil") + } + if len(headers) == 0 { + return nil, errors.New("headers must not be nil or empty") + } + + // Deep-copy and normalize: lowercase keys, skip empty values. + normalized := make(map[string]string, len(headers)) + for k, v := range headers { + if v == "" { + continue + } + normalized[strings.ToLower(k)] = v + } + + if len(normalized) == 0 { + return nil, errors.New("headers must contain at least one non-empty value") + } + + return &extraHeadersAuth{ + base: base, + headers: normalized, + }, nil +} + +// GetRequestMetadata merges base metadata with extra headers. Extra headers +// win on key collision because they are applied after the base metadata. +func (e *extraHeadersAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + baseMD, err := e.base.GetRequestMetadata(ctx, uri...) + if err != nil { + return nil, err + } + + // Start with base metadata (may be nil for NoAuth). + // Normalize base keys to lowercase for case-insensitive collision. + merged := make(map[string]string, len(baseMD)+len(e.headers)) + for k, v := range baseMD { + merged[strings.ToLower(k)] = v + } + // Extra headers overwrite base on collision. + maps.Copy(merged, e.headers) + + return merged, nil +} + +// RequireTransportSecurity delegates to the base auth provider. +func (e *extraHeadersAuth) RequireTransportSecurity() bool { + return e.base.RequireTransportSecurity() +} diff --git a/sdk/go/openshell/v1/auth_extra_test.go b/sdk/go/openshell/v1/auth_extra_test.go new file mode 100644 index 0000000000..09bd4d33d2 --- /dev/null +++ b/sdk/go/openshell/v1/auth_extra_test.go @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithExtraHeaders_NilBase(t *testing.T) { + _, err := WithExtraHeaders(nil, map[string]string{"x-key": "val"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "base") +} + +func TestWithExtraHeaders_NilHeaders(t *testing.T) { + _, err := WithExtraHeaders(NoAuth(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers") +} + +func TestWithExtraHeaders_EmptyHeaders(t *testing.T) { + _, err := WithExtraHeaders(NoAuth(), map[string]string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers") +} + +func TestWithExtraHeaders_AllEmptyValues(t *testing.T) { + // All values are empty strings, so after filtering, headers map is empty. + _, err := WithExtraHeaders(NoAuth(), map[string]string{"x-key": ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers") +} + +func TestWithExtraHeaders_MergesWithBase(t *testing.T) { + base := StaticToken("my-token") + auth, err := WithExtraHeaders(base, map[string]string{ + "x-proxy-key": "proxy-secret", + "x-tenant-id": "acme-corp", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "Bearer my-token", md["authorization"]) + assert.Equal(t, "proxy-secret", md["x-proxy-key"]) + assert.Equal(t, "acme-corp", md["x-tenant-id"]) +} + +func TestWithExtraHeaders_ExtraPrecedenceOnCollision(t *testing.T) { + base := StaticToken("my-token") + auth, err := WithExtraHeaders(base, map[string]string{ + "authorization": "Custom override-token", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Extra header wins over base. + assert.Equal(t, "Custom override-token", md["authorization"]) +} + +func TestWithExtraHeaders_CaseInsensitiveCollision(t *testing.T) { + base := StaticToken("my-token") + // "Authorization" with uppercase should still override "authorization". + auth, err := WithExtraHeaders(base, map[string]string{ + "Authorization": "Custom override-token", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "Custom override-token", md["authorization"]) +} + +func TestWithExtraHeaders_EmptyValueSkipped(t *testing.T) { + base := StaticToken("my-token") + auth, err := WithExtraHeaders(base, map[string]string{ + "x-proxy-key": "proxy-secret", + "x-empty": "", // Should be silently skipped. + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "proxy-secret", md["x-proxy-key"]) + _, hasEmpty := md["x-empty"] + assert.False(t, hasEmpty, "empty-string header values should be skipped") +} + +func TestWithExtraHeaders_RequireTransportSecurity_Delegates(t *testing.T) { + tests := []struct { + name string + base AuthProvider + expected bool + }{ + { + name: "delegates to NoAuth (false)", + base: NoAuth(), + expected: false, + }, + { + name: "delegates to StaticToken (true)", + base: StaticToken("tok"), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := WithExtraHeaders(tt.base, map[string]string{"x-key": "val"}) + require.NoError(t, err) + assert.Equal(t, tt.expected, auth.RequireTransportSecurity()) + }) + } +} + +func TestWithExtraHeaders_WithNoAuth(t *testing.T) { + auth, err := WithExtraHeaders(NoAuth(), map[string]string{ + "x-proxy-key": "proxy-secret", + }) + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // NoAuth returns nil metadata, extra headers should still appear. + assert.Equal(t, "proxy-secret", md["x-proxy-key"]) +} + +func TestWithExtraHeaders_BaseError_Propagated(t *testing.T) { + base := &errAuth{err: errors.New("auth failure")} + auth, err := WithExtraHeaders(base, map[string]string{"x-key": "val"}) + require.NoError(t, err) + + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "auth failure") +} + +func TestWithExtraHeaders_DeepCopiesHeaders(t *testing.T) { + original := map[string]string{ + "x-key": "original-value", + } + auth, err := WithExtraHeaders(NoAuth(), original) + require.NoError(t, err) + + // Mutate the original map after construction. + original["x-key"] = "mutated-value" + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // The wrapper should use the value at construction time, not the mutated value. + assert.Equal(t, "original-value", md["x-key"]) +} + +// errAuth is a test helper that always returns an error. +type errAuth struct { + err error +} + +func (e *errAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return nil, e.err +} + +func (e *errAuth) RequireTransportSecurity() bool { + return false +} diff --git a/sdk/go/openshell/v1/auth_refresh.go b/sdk/go/openshell/v1/auth_refresh.go new file mode 100644 index 0000000000..843e920b81 --- /dev/null +++ b/sdk/go/openshell/v1/auth_refresh.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "sync" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const defaultLeeway = 10 * time.Second + +var errNilTokenSource = errors.New("openshell: TokenSource must not be nil") + +// RefreshOption configures the behavior of RefreshableToken. +type RefreshOption func(*refreshConfig) + +type refreshConfig struct { + leeway time.Duration + logger types.Logger +} + +func defaultRefreshConfig() refreshConfig { + return refreshConfig{ + leeway: defaultLeeway, + } +} + +// WithLeeway sets the duration before token expiry at which a proactive +// refresh is triggered. Default is 10 seconds. +func WithLeeway(d time.Duration) RefreshOption { + return func(c *refreshConfig) { + if d < 0 { + d = 0 + } + c.leeway = d + } +} + +// WithLogger sets the logger used for stale-token fallback warnings. +// When not set, warnings are silently dropped. +func WithLogger(l types.Logger) RefreshOption { + return func(c *refreshConfig) { + c.logger = l + } +} + +type refreshableAuth struct { + source oauth2.TokenSource + mu sync.RWMutex + tok *oauth2.Token + leeway time.Duration + logger types.Logger +} + +func (r *refreshableAuth) isTokenValid() bool { + if r.tok == nil { + return false + } + if r.tok.Expiry.IsZero() { + return true + } + return time.Now().Before(r.tok.Expiry.Add(-r.leeway)) +} + +func (r *refreshableAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + // Fast path: RLock, return cached token if valid. + r.mu.RLock() + if r.isTokenValid() { + tok := r.tok.AccessToken + r.mu.RUnlock() + return map[string]string{"authorization": "Bearer " + tok}, nil + } + r.mu.RUnlock() + + // Slow path: Lock, re-check, fetch if still stale. + r.mu.Lock() + defer r.mu.Unlock() + + if r.isTokenValid() { + return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil + } + + newTok, err := r.source.Token() + if err != nil { + if r.tok != nil { + if r.logger != nil { + r.logger.Error(err, "token refresh failed, using cached token") + } + return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil + } + return nil, err + } + + r.tok = newTok + return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil +} + +func (r *refreshableAuth) RequireTransportSecurity() bool { + return true +} + +// RefreshableToken returns an AuthProvider that caches tokens from src +// and refreshes them before expiry. Concurrent callers share a single +// refresh call (coalesced via RWMutex double-checked locking). +func RefreshableToken(src oauth2.TokenSource, opts ...RefreshOption) (AuthProvider, error) { + if src == nil { + return nil, errNilTokenSource + } + + cfg := defaultRefreshConfig() + for _, o := range opts { + o(&cfg) + } + + return &refreshableAuth{ + source: src, + leeway: cfg.leeway, + logger: cfg.logger, + }, nil +} diff --git a/sdk/go/openshell/v1/auth_refresh_test.go b/sdk/go/openshell/v1/auth_refresh_test.go new file mode 100644 index 0000000000..451e8e63df --- /dev/null +++ b/sdk/go/openshell/v1/auth_refresh_test.go @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// mockTokenSource implements oauth2.TokenSource for testing. +type mockTokenSource struct { + mu sync.Mutex + tokenFunc func() (*oauth2.Token, error) + callCount int +} + +func (m *mockTokenSource) Token() (*oauth2.Token, error) { + m.mu.Lock() + m.callCount++ + m.mu.Unlock() + return m.tokenFunc() +} + +func (m *mockTokenSource) calls() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.callCount +} + +// --- Phase 2 tests: constructor validation --- + +func TestRefreshableToken_NilSource(t *testing.T) { + _, err := RefreshableToken(nil) + require.Error(t, err) + assert.Equal(t, "openshell: TokenSource must not be nil", err.Error()) +} + +func TestRefreshableToken_ValidSource(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + assert.NotNil(t, provider) +} + +// --- Phase 3 / US1 tests: automatic token refresh --- + +func TestGetRequestMetadata_FirstCallFetchesToken(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "fresh-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer fresh-token", md["authorization"]) + assert.Equal(t, 1, src.calls()) +} + +func TestGetRequestMetadata_CachedTokenNoExtraCall(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "cached-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer cached-token", md["authorization"]) + assert.Equal(t, 1, src.calls(), "second call should use cache, not invoke TokenSource") +} + +func TestGetRequestMetadata_RefreshesExpiredToken(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "old", Expiry: time.Now().Add(-time.Minute)}, nil + } + return &oauth2.Token{AccessToken: "new", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + // First call gets the expired token, which is immediately stale. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer old", md["authorization"]) + + // Second call should trigger a refresh since the cached token is expired. + md, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer new", md["authorization"]) + assert.Equal(t, 2, src.calls()) +} + +func TestGetRequestMetadata_ConcurrentSingleFlight(t *testing.T) { + var fetchCount atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + fetchCount.Add(1) + time.Sleep(10 * time.Millisecond) // simulate slow token fetch + return &oauth2.Token{AccessToken: "shared-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + const goroutines = 1000 + var wg sync.WaitGroup + wg.Add(goroutines) + results := make([]string, goroutines) + errs := make([]error, goroutines) + + for i := range goroutines { + go func(idx int) { + defer wg.Done() + md, e := provider.GetRequestMetadata(context.Background()) + errs[idx] = e + if md != nil { + results[idx] = md["authorization"] + } + }(i) + } + wg.Wait() + + for i := range goroutines { + require.NoError(t, errs[i], "goroutine %d failed", i) + assert.Equal(t, "Bearer shared-token", results[i], "goroutine %d got wrong token", i) + } + assert.Equal(t, int32(1), fetchCount.Load(), "expected exactly 1 TokenSource.Token() call, got %d", fetchCount.Load()) +} + +func TestRefreshableAuth_RequireTransportSecurity(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "t"}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + assert.True(t, provider.RequireTransportSecurity()) +} + +// --- Phase 4 / US2 tests: graceful degradation --- + +func TestGetRequestMetadata_RefreshFailureReturnsStaleCachedToken(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp unavailable") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + // First call succeeds but returns already-expired token. + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Second call: refresh fails, should return stale token. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer stale", md["authorization"]) +} + +func TestGetRequestMetadata_RefreshFailureLogsWarning(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp unavailable") + }, + } + + logger := &captureLogger{} + provider, err := RefreshableToken(src, WithLeeway(0), WithLogger(logger)) + require.NoError(t, err) + + _, _ = provider.GetRequestMetadata(context.Background()) + _, _ = provider.GetRequestMetadata(context.Background()) + + require.Len(t, logger.errors, 1) + assert.Contains(t, logger.errors[0].msg, "token refresh failed") +} + +func TestGetRequestMetadata_RefreshFailureNoCachedTokenReturnsError(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return nil, fmt.Errorf("idp unavailable") + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "idp unavailable") +} + +func TestGetRequestMetadata_RefreshFailureNoLoggerNoPanic(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp unavailable") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + _, _ = provider.GetRequestMetadata(context.Background()) + + assert.NotPanics(t, func() { + _, _ = provider.GetRequestMetadata(context.Background()) + }) +} + +// --- Phase 5 / US3 tests: configurable leeway --- + +func TestGetRequestMetadata_DefaultLeewayTriggersRefresh(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + return &oauth2.Token{ + AccessToken: fmt.Sprintf("token-%d", n), + Expiry: time.Now().Add(5 * time.Second), // within default 10s leeway + }, nil + }, + } + provider, err := RefreshableToken(src) // default 10s leeway + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Token expires in 5s, which is within 10s leeway, so next call should refresh. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer token-2", md["authorization"]) + assert.Equal(t, 2, src.calls()) +} + +func TestGetRequestMetadata_CustomLeewayTriggersRefresh(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + return &oauth2.Token{ + AccessToken: fmt.Sprintf("token-%d", n), + Expiry: time.Now().Add(25 * time.Second), // within custom 30s leeway + }, nil + }, + } + provider, err := RefreshableToken(src, WithLeeway(30*time.Second)) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Token expires in 25s, which is within 30s leeway, so next call should refresh. + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer token-2", md["authorization"]) + assert.Equal(t, 2, src.calls()) +} + +func TestGetRequestMetadata_ZeroExpiryNeverRefreshes(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "forever-token"}, nil // zero Expiry + }, + } + provider, err := RefreshableToken(src) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Call multiple times; should never refresh since expiry is zero. + for range 10 { + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + } + assert.Equal(t, 1, src.calls(), "zero-expiry token should never be refreshed") +} + +// --- benchmarks --- + +func BenchmarkGetRequestMetadata_CachedToken(b *testing.B) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "bench-token", Expiry: time.Now().Add(time.Hour)}, nil + }, + } + provider, err := RefreshableToken(src) + require.NoError(b, err) + + // Prime the cache. + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(b, err) + + b.ResetTimer() + b.ReportAllocs() + for range b.N { + _, _ = provider.GetRequestMetadata(context.Background()) + } +} + +// --- helpers --- + +type logEntry struct { + err error + msg string +} + +type captureLogger struct { + mu sync.Mutex + debugs []string + infos []string + errors []logEntry +} + +func (l *captureLogger) Debug(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.debugs = append(l.debugs, msg) +} + +func (l *captureLogger) Info(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.infos = append(l.infos, msg) +} + +func (l *captureLogger) Error(err error, msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.errors = append(l.errors, logEntry{err: err, msg: msg}) +} diff --git a/sdk/go/openshell/v1/auth_test.go b/sdk/go/openshell/v1/auth_test.go new file mode 100644 index 0000000000..2981fd7921 --- /dev/null +++ b/sdk/go/openshell/v1/auth_test.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoAuth_GetRequestMetadata(t *testing.T) { + auth := NoAuth() + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Empty(t, md) +} + +func TestNoAuth_RequireTransportSecurity(t *testing.T) { + auth := NoAuth() + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestStaticToken_GetRequestMetadata(t *testing.T) { + auth := StaticToken("my-secret-token") + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer my-secret-token", md["authorization"]) +} + +func TestStaticToken_RequireTransportSecurity(t *testing.T) { + auth := StaticToken("token") + assert.True(t, auth.RequireTransportSecurity()) +} diff --git a/sdk/go/openshell/v1/client.go b/sdk/go/openshell/v1/client.go new file mode 100644 index 0000000000..cfa907290f --- /dev/null +++ b/sdk/go/openshell/v1/client.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + + internalgrpc "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/grpc" + "google.golang.org/grpc" +) + +// Config holds all settings needed to create a Client. +type Config = types.Config + +// ClientInterface defines the top-level SDK surface. +type ClientInterface interface { + Sandboxes() SandboxInterface + Providers() ProviderInterface + Services() ServiceInterface + Exec() ExecInterface + Files() FileInterface + Health() HealthInterface + SSH() SSHInterface + TCP() TCPInterface + Config() ConfigInterface + Policy() PolicyInterface + Close() error +} + +// SandboxInterface is defined in sandbox.go + +// ProviderInterface is defined in provider.go + +// ExecInterface is defined in exec.go + +// FileInterface is defined in file.go + + +// Client implements ClientInterface. It holds a gRPC connection and provides +// sub-client accessors following the Kubernetes client-go pattern. +type Client struct { + conn *grpc.ClientConn + config Config + + 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 +} + +// NewClient creates a new SDK client connected to the given gateway. +func NewClient(cfg Config) (*Client, error) { + if cfg.Address == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "address must not be empty"} + } + + if cfg.Auth == nil { + cfg.Auth = NoAuth() + } + + var tlsParams *internalgrpc.TLSParams + if cfg.TLS != nil { + tlsParams = &internalgrpc.TLSParams{ + CertFile: cfg.TLS.CertFile, + KeyFile: cfg.TLS.KeyFile, + CAFile: cfg.TLS.CAFile, + Insecure: cfg.TLS.Insecure, + } + } + + conn, err := internalgrpc.NewConnection(cfg.Address, tlsParams, cfg.Auth) + if err != nil { + return nil, err + } + + c := &Client{ + conn: conn, + config: cfg, + } + + c.sandboxes = newSandboxClient(conn) + c.providers = newProviderClient(conn) + c.services = newServiceClient(conn) + c.exec = newExecClient(conn, c.sandboxes) + c.files = newFileClient(conn, c.sandboxes) + c.health = newHealthClient(conn) + c.ssh = newSSHClient(conn, c.sandboxes) + c.tcp = newTCPClient(conn, c.sandboxes, c.ssh) + c.cfg = newConfigClient(conn, c.sandboxes) + c.policy = newPolicyClient(conn) + + return c, nil +} + +// Sandboxes returns the sandbox sub-client. +func (c *Client) Sandboxes() SandboxInterface { return c.sandboxes } + +// Providers returns the provider sub-client. +func (c *Client) Providers() ProviderInterface { return c.providers } + +// Services returns the service sub-client. +func (c *Client) Services() ServiceInterface { return c.services } + +// Exec returns the exec sub-client. +func (c *Client) Exec() ExecInterface { return c.exec } + +// Files returns the file sub-client. +func (c *Client) Files() FileInterface { return c.files } + +// Health returns the health sub-client. +func (c *Client) Health() HealthInterface { return c.health } + +// SSH returns the SSH session sub-client. +func (c *Client) SSH() SSHInterface { return c.ssh } + +// TCP returns the TCP port forwarding sub-client. +func (c *Client) TCP() TCPInterface { return c.tcp } + +// Config returns the configuration sub-client. +func (c *Client) Config() ConfigInterface { return c.cfg } + +// Policy returns the policy management sub-client. +func (c *Client) Policy() PolicyInterface { return c.policy } + +// Close closes the underlying gRPC connection. Safe to call multiple times. +func (c *Client) Close() error { + c.closeOnce.Do(func() { + c.closeErr = c.conn.Close() + }) + return c.closeErr +} diff --git a/sdk/go/openshell/v1/client_test.go b/sdk/go/openshell/v1/client_test.go new file mode 100644 index 0000000000..7d6029b053 --- /dev/null +++ b/sdk/go/openshell/v1/client_test.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewClient_EmptyAddress(t *testing.T) { + _, err := NewClient(Config{Address: ""}) + require.Error(t, err) + assert.Contains(t, err.Error(), "address") +} + +func TestNewClient_ValidConfig(t *testing.T) { + client, err := NewClient(Config{ + Address: "localhost:50051", + Auth: NoAuth(), + TLS: &TLSConfig{Insecure: true}, + }) + require.NoError(t, err) + require.NotNil(t, client) + + assert.NotNil(t, client.Sandboxes()) + assert.NotNil(t, client.Providers()) + assert.NotNil(t, client.Exec()) + assert.NotNil(t, client.Files()) + assert.NotNil(t, client.Health()) + + err = client.Close() + assert.NoError(t, err) +} + +func TestClient_CloseIdempotent(t *testing.T) { + client, err := NewClient(Config{ + Address: "localhost:50051", + Auth: NoAuth(), + TLS: &TLSConfig{Insecure: true}, + }) + require.NoError(t, err) + + err = client.Close() + assert.NoError(t, err) + + err = client.Close() + assert.NoError(t, err) +} + +func TestNewClient_DefaultAuth(t *testing.T) { + client, err := NewClient(Config{ + Address: "localhost:50051", + TLS: &TLSConfig{Insecure: true}, + }) + require.NoError(t, err) + require.NotNil(t, client) + _ = client.Close() +} diff --git a/sdk/go/openshell/v1/config.go b/sdk/go/openshell/v1/config.go new file mode 100644 index 0000000000..2dd7fed4a2 --- /dev/null +++ b/sdk/go/openshell/v1/config.go @@ -0,0 +1,76 @@ +// 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" +) + +// SandboxConfig represents the full configuration state of a sandbox. +type SandboxConfig = types.SandboxConfig + +// GatewayConfig represents gateway-global settings. +type GatewayConfig = types.GatewayConfig + +// ConfigUpdate represents a configuration mutation request. +type ConfigUpdate = types.ConfigUpdate + +// ConfigUpdateResult holds the result of a configuration update operation. +type ConfigUpdateResult = types.ConfigUpdateResult + +// SettingValue is a typed setting value (string, bool, int64, or bytes). +type SettingValue = types.SettingValue + +// SettingValueType identifies which typed field of a SettingValue is active. +type SettingValueType = types.SettingValueType + +// EffectiveSetting is a setting value paired with its resolved scope. +type EffectiveSetting = types.EffectiveSetting + +// SettingScope indicates whether a setting is sandbox or global. +type SettingScope = types.SettingScope + +// PolicySource indicates the source of a policy payload. +type PolicySource = types.PolicySource + +// SettingValueType constants re-exported from types package. +const ( + SettingValueString = types.SettingValueString + SettingValueBool = types.SettingValueBool + SettingValueInt = types.SettingValueInt + SettingValueBytes = types.SettingValueBytes +) + +// SettingScope constants re-exported from types package. +const ( + SettingScopeUnspecified = types.SettingScopeUnspecified + SettingScopeSandbox = types.SettingScopeSandbox + SettingScopeGlobal = types.SettingScopeGlobal +) + +// PolicySource constants re-exported from types package. +const ( + PolicySourceUnspecified = types.PolicySourceUnspecified + PolicySourceSandbox = types.PolicySourceSandbox + PolicySourceGlobal = types.PolicySourceGlobal +) + +// ConfigInterface defines operations for reading and updating gateway and +// sandbox configuration. +type ConfigInterface interface { + // GetSandbox retrieves the full configuration state for a sandbox, + // including policy, effective settings, and revision metadata. + // The sandbox is identified by name; the SDK resolves it to an ID internally. + GetSandbox(ctx context.Context, sandboxName string) (*SandboxConfig, error) + + // GetGateway retrieves gateway-global settings. + GetGateway(ctx context.Context) (*GatewayConfig, error) + + // Update applies a configuration mutation. For sandbox-scoped updates, + // set ConfigUpdate.Name to the sandbox name. For global-scoped updates, + // set ConfigUpdate.Global to true. + Update(ctx context.Context, update *ConfigUpdate) (*ConfigUpdateResult, error) +} diff --git a/sdk/go/openshell/v1/config_client.go b/sdk/go/openshell/v1/config_client.go new file mode 100644 index 0000000000..810a66b554 --- /dev/null +++ b/sdk/go/openshell/v1/config_client.go @@ -0,0 +1,67 @@ +// 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" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "google.golang.org/grpc" +) + +type configClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newConfigClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *configClient { + return &configClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes} +} + +func (c *configClient) GetSandbox(ctx context.Context, sandboxName string) (*SandboxConfig, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. + sb, err := c.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + resp, err := c.client.GetSandboxConfig(ctx, &sbv1.GetSandboxConfigRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxConfigFromProto(resp), nil +} + +func (c *configClient) GetGateway(ctx context.Context) (*GatewayConfig, error) { + resp, err := c.client.GetGatewayConfig(ctx, &sbv1.GetGatewayConfigRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.GatewayConfigFromProto(resp), nil +} + +func (c *configClient) Update(ctx context.Context, update *ConfigUpdate) (*ConfigUpdateResult, error) { + if update == nil { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "update must not be nil", + } + } + req, convErr := converter.ConfigUpdateToProto(update) + if convErr != nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: convErr.Error()} + } + resp, err := c.client.UpdateConfig(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ConfigUpdateResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/config_client_test.go b/sdk/go/openshell/v1/config_client_test.go new file mode 100644 index 0000000000..9948e1055f --- /dev/null +++ b/sdk/go/openshell/v1/config_client_test.go @@ -0,0 +1,560 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "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" +) + +// --- Mock server for Config RPCs --- + +type mockConfigServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + // Canned responses. + sandboxResp *sbv1.GetSandboxConfigResponse + gatewayResp *sbv1.GetGatewayConfigResponse + updateResp *pb.UpdateConfigResponse + + // Recorded requests. + lastSandboxReq *sbv1.GetSandboxConfigRequest + lastGatewayReq *sbv1.GetGatewayConfigRequest + lastUpdateReq *pb.UpdateConfigRequest + + // Inject errors. + sandboxErr error + gatewayErr error + updateErr error +} + +func newMockConfigServer() *mockConfigServer { + return &mockConfigServer{} +} + +func (s *mockConfigServer) GetSandboxConfig(_ context.Context, req *sbv1.GetSandboxConfigRequest) (*sbv1.GetSandboxConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastSandboxReq = req + if s.sandboxErr != nil { + return nil, s.sandboxErr + } + return s.sandboxResp, nil +} + +func (s *mockConfigServer) GetGatewayConfig(_ context.Context, req *sbv1.GetGatewayConfigRequest) (*sbv1.GetGatewayConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGatewayReq = req + if s.gatewayErr != nil { + return nil, s.gatewayErr + } + return s.gatewayResp, nil +} + +func (s *mockConfigServer) UpdateConfig(_ context.Context, req *pb.UpdateConfigRequest) (*pb.UpdateConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastUpdateReq = req + if s.updateErr != nil { + return nil, s.updateErr + } + return s.updateResp, nil +} + +// --- Test setup --- + +func setupConfigTest(t *testing.T, mock *mockConfigServer) (*configClient, 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 newConfigClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- GetSandbox tests --- + +func TestConfigGetSandbox(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{ + Policy: &sbv1.SandboxPolicy{ + Version: 4, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + Version: 3, + PolicyHash: "sha256:deadbeef", + ConfigRevision: 42, + PolicySource: sbv1.PolicySource_POLICY_SOURCE_SANDBOX, + GlobalPolicyVersion: 1, + ProviderEnvRevision: 7, + Settings: map[string]*sbv1.EffectiveSetting{ + "max_tokens": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 4096}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + "debug": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, sc) + + // Verify request was forwarded with resolved ID (stubSandboxResolver returns "sb-"). + mock.mu.Lock() + assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId()) + mock.mu.Unlock() + + // Scalar fields. + assert.Equal(t, uint32(3), sc.PolicyVersion) + assert.Equal(t, "sha256:deadbeef", sc.PolicyHash) + assert.Equal(t, uint64(42), sc.ConfigRevision) + assert.Equal(t, PolicySource("sandbox"), sc.PolicySource) + assert.Equal(t, uint32(1), sc.GlobalPolicyVersion) + assert.Equal(t, uint64(7), sc.ProviderEnvRevision) + + // Typed SandboxPolicy. + require.NotNil(t, sc.Policy) + assert.Equal(t, uint32(4), sc.Policy.Version) + require.NotNil(t, sc.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) + + // Settings map. + require.Len(t, sc.Settings, 2) + + maxTok := sc.Settings["max_tokens"] + assert.Equal(t, SettingValueType("int"), maxTok.Value.Type) + assert.Equal(t, int64(4096), maxTok.Value.IntVal) + assert.Equal(t, SettingScope("sandbox"), maxTok.Scope) + + debug := sc.Settings["debug"] + assert.Equal(t, SettingValueType("bool"), debug.Value.Type) + assert.True(t, debug.Value.BoolVal) + assert.Equal(t, SettingScope("global"), debug.Scope) +} + +func TestConfigGetSandbox_DeepCopy(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{ + Version: 1, + Settings: map[string]*sbv1.EffectiveSetting{ + "key": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: []byte("original")}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "sb1") + require.NoError(t, err) + + // Mutate the returned setting — should not affect future calls. + sc.Settings["key"] = EffectiveSetting{} + + sc2, err := client.GetSandbox(context.Background(), "sb1") + require.NoError(t, err) + + // The server still returns the original value — verifies we're not + // sharing references between calls. + require.Contains(t, sc2.Settings, "key") + assert.Equal(t, []byte("original"), sc2.Settings["key"].Value.BytesVal) +} + +func TestConfigGetSandbox_Error(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxErr = status.Errorf(codes.Unavailable, "server unavailable") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "my-sandbox") + + assert.Nil(t, sc) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Name-to-ID resolution tests --- + +func TestConfigGetSandbox_ResolvesNameToID(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{Version: 1} + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "my-sandbox") + require.NoError(t, err) + require.NotNil(t, sc) + + // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name. + mock.mu.Lock() + assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId(), "GetSandbox should send resolved sandbox ID, not the name") + mock.mu.Unlock() +} + +func TestConfigGetSandbox_ResolutionError(t *testing.T) { + mock := newMockConfigServer() + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newConfigClient(conn, resolver) + + sc, err := client.GetSandbox(context.Background(), "nonexistent") + assert.Nil(t, sc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- GetGateway tests --- + +func TestConfigGetGateway(t *testing.T) { + mock := newMockConfigServer() + mock.gatewayResp = &sbv1.GetGatewayConfigResponse{ + SettingsRevision: 99, + Settings: map[string]*sbv1.SettingValue{ + "rate_limit": { + Value: &sbv1.SettingValue_IntValue{IntValue: 1000}, + }, + "motd": { + Value: &sbv1.SettingValue_StringValue{StringValue: "welcome"}, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + gc, err := client.GetGateway(context.Background()) + + require.NoError(t, err) + require.NotNil(t, gc) + + assert.Equal(t, uint64(99), gc.SettingsRevision) + require.Len(t, gc.Settings, 2) + + rl := gc.Settings["rate_limit"] + assert.Equal(t, SettingValueType("int"), rl.Type) + assert.Equal(t, int64(1000), rl.IntVal) + + motd := gc.Settings["motd"] + assert.Equal(t, SettingValueType("string"), motd.Type) + assert.Equal(t, "welcome", motd.StringVal) +} + +func TestConfigGetGateway_Error(t *testing.T) { + mock := newMockConfigServer() + mock.gatewayErr = status.Errorf(codes.Internal, "internal error") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + gc, err := client.GetGateway(context.Background()) + + assert.Nil(t, gc) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +// --- Update tests --- + +func TestConfigUpdate_SandboxScope(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 5, + PolicyHash: "sha256:cafe", + SettingsRevision: 10, + Deleted: false, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "max_tokens", + SettingValue: &SettingValue{ + Type: SettingValueInt, + IntVal: 8192, + }, + ExpectedResourceVersion: 4, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, uint32(5), result.Version) + assert.Equal(t, "sha256:cafe", result.PolicyHash) + assert.Equal(t, uint64(10), result.SettingsRevision) + assert.False(t, result.Deleted) + + // Verify request was correctly converted. + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.GetName()) + assert.Equal(t, "max_tokens", req.GetSettingKey()) + assert.False(t, req.GetGlobal()) + assert.Equal(t, uint64(4), req.GetExpectedResourceVersion()) + assert.Equal(t, int64(8192), req.GetSettingValue().GetIntValue()) +} + +func TestConfigUpdate_GlobalScope(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + SettingsRevision: 20, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Global: true, + SettingKey: "global_flag", + SettingValue: &SettingValue{ + Type: SettingValueBool, + BoolVal: true, + }, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint64(20), result.SettingsRevision) + + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + assert.True(t, req.GetGlobal()) + assert.Empty(t, req.GetName()) +} + +func TestConfigUpdate_DeleteSetting(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + SettingsRevision: 15, + Deleted: true, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "deprecated_key", + DeleteSetting: true, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Deleted) + assert.Equal(t, uint64(15), result.SettingsRevision) + + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + assert.True(t, req.GetDeleteSetting()) +} + +func TestConfigUpdate_WithPolicy(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 2, + PolicyHash: "sha256:newpolicy", + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + Policy: &types.SandboxPolicy{ + Version: 5, + Filesystem: &types.FilesystemPolicy{ + ReadOnly: []string{"/usr"}, + }, + }, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(2), result.Version) + + // Verify the typed policy was converted and sent as proto. + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + require.NotNil(t, req.GetPolicy()) + assert.Equal(t, uint32(5), req.GetPolicy().GetVersion()) + require.NotNil(t, req.GetPolicy().GetFilesystem()) + assert.Equal(t, []string{"/usr"}, req.GetPolicy().GetFilesystem().GetReadOnly()) +} + +func TestConfigUpdate_Error(t *testing.T) { + mock := newMockConfigServer() + mock.updateErr = status.Errorf(codes.FailedPrecondition, "version mismatch") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "key", + ExpectedResourceVersion: 99, + } + + result, err := client.Update(context.Background(), update) + + assert.Nil(t, result) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +func TestConfigUpdate_NilUpdate(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + result, err := client.Update(context.Background(), nil) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestConfigUpdate_MergeOperationsAccepted(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 3, + PolicyHash: "abc123", + } + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + } + + result, err := client.Update(context.Background(), update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(3), result.Version) + assert.Equal(t, "abc123", result.PolicyHash) + + // Verify the merge operations were serialized in the proto request + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + require.NotNil(t, req) + assert.NotEmpty(t, req.GetMergeOperations(), "MergeOperations should be serialized to proto") +} + +func TestConfigUpdate_ErrorConflict(t *testing.T) { + mock := newMockConfigServer() + mock.updateErr = status.Error(codes.Aborted, "resource version conflict") + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + ExpectedResourceVersion: 5, + } + + result, err := client.Update(context.Background(), update) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsConflict(err)) +} + +func TestConfigGetSandbox_EmptySandboxName(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + cfg, err := client.GetSandbox(context.Background(), "") + assert.Nil(t, cfg) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go new file mode 100644 index 0000000000..f36bb154ce --- /dev/null +++ b/sdk/go/openshell/v1/doc.go @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package v1 provides a Go SDK for interacting with OpenShell servers. +// +// The SDK follows the Kubernetes client-go sub-client pattern: a single Client +// provides typed accessors for each resource domain (Sandboxes, Providers, Exec, +// Files, Health, Services, SSH, TCP, Config). All operations accept a context.Context and return idiomatic +// Go types. Proto-generated types never appear in the public API. +// +// # Quick Start +// +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: v1.StaticToken("my-token"), +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// # Sandbox Lifecycle +// +// sandbox, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{ +// Template: &v1.SandboxTemplate{Image: "python:3.12"}, +// Environment: map[string]string{"LANG": "en_US.UTF-8"}, +// }, nil) +// if err != nil { +// log.Fatal(err) +// } +// +// sandbox, err = client.Sandboxes().WaitReady(ctx, sandbox.Name) +// if err != nil { +// log.Fatal(err) +// } +// +// # Command Execution +// +// result, err := client.Exec().Run(ctx, sandbox.Name, []string{"echo", "hello"}, v1.ExecOptions{}) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(string(result.Stdout)) // "hello\n" +// +// # Error Handling +// +// _, err = client.Sandboxes().Get(ctx, "missing") +// if v1.IsNotFound(err) { +// // handle not found +// } +// +// # Watching +// +// watcher, err := client.Sandboxes().Watch(ctx, sandbox.Name) +// if err != nil { +// log.Fatal(err) +// } +// defer watcher.Stop() +// for event := range watcher.ResultChan() { +// fmt.Printf("%s: %s\n", event.Type, event.Object.Name) +// } +// +// # Watching with StopOnTerminal +// +// Use StopOnTerminal to auto-close the watcher when the sandbox reaches a +// terminal phase (Ready or Error): +// +// watcher, err := client.Sandboxes().Watch(ctx, sandbox.Name, +// v1.WatchOptions{StopOnTerminal: true}, +// ) +// if err != nil { +// log.Fatal(err) +// } +// for event := range watcher.ResultChan() { +// fmt.Printf("phase: %s\n", event.Object.Status.Phase) +// } +// // channel closes automatically after Ready or Error +// +// # Service Exposure +// +// Expose an HTTP service running inside a sandbox and retrieve its public URL: +// +// endpoint, err := client.Services().Expose(ctx, "my-sandbox", "api", 8080, true) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Service URL: %s\n", endpoint.URL) +// +// endpoints, err := client.Services().List(ctx, "my-sandbox") +// if err != nil { +// log.Fatal(err) +// } +// for _, ep := range endpoints { +// fmt.Printf(" %s → port %d (URL: %s)\n", ep.ServiceName, ep.TargetPort, ep.URL) +// } +// +// # Provider Profiles +// +// List available provider profiles and import new ones: +// +// profiles, err := client.Providers().Profiles().List(ctx) +// if err != nil { +// log.Fatal(err) +// } +// for _, p := range profiles { +// fmt.Printf("%s (%s): %s\n", p.DisplayName, p.Category, p.Description) +// } +// +// result, err := client.Providers().Profiles().Import(ctx, []v1.ProfileImportItem{ +// {Source: "openai-profile.yaml", Profile: v1.ProviderProfile{ +// DisplayName: "OpenAI", +// Category: v1.ProfileCategoryInference, +// }}, +// }) +// if err != nil { +// log.Fatal(err) +// } +// for _, d := range result.Diagnostics { +// fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message) +// } +// +// # Credential Refresh +// +// Configure gateway-owned credential refresh for a provider: +// +// status, err := client.Providers().Refresh().Configure(ctx, &v1.RefreshConfig{ +// Provider: "openai", +// CredentialKey: "api-key", +// Strategy: v1.RefreshStrategyOAuth2ClientCredentials, +// Material: map[string]string{"client_id": "xxx", "client_secret": "yyy"}, +// }) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Refresh status: %s (next: %s)\n", status.Status, status.NextRefreshAt) +// +// # Token Refresh +// +// Use RefreshableToken for automatic OAuth2 token caching and refresh. +// Concurrent callers share a single refresh call: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// auth, err := v1.RefreshableToken(tokenSource, +// v1.WithLeeway(30*time.Second), +// ) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// # Extra Headers +// +// Use WithExtraHeaders to attach additional per-RPC headers to any auth +// provider. This is useful for edge proxies, API gateways, or any middleware +// that requires custom headers alongside standard authentication: +// +// base := v1.StaticToken("my-token") +// auth, err := v1.WithExtraHeaders(base, map[string]string{ +// "x-proxy-key": "proxy-secret", +// "x-tenant-id": "acme-corp", +// }) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Keys are normalized to lowercase (per HTTP/2 RFC 9113). On key collision, +// extra headers take precedence over base auth headers. Empty-string values +// are silently dropped. WithExtraHeaders composes with any AuthProvider, +// including RefreshableToken: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// refreshAuth, err := v1.RefreshableToken(tokenSource) +// if err != nil { +// log.Fatal(err) +// } +// auth, err := v1.WithExtraHeaders(refreshAuth, map[string]string{ +// "x-proxy-key": "proxy-secret", +// }) +// +// # SSH Session Management +// +// Create an SSH session for a sandbox and use the returned connection details. +// Note: CreateSession accepts a sandbox ID, not a name. For name-based access +// with automatic session cleanup, prefer SSH().Tunnel() instead. +// +// session, err := client.SSH().CreateSession(ctx, sandbox.ID) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("SSH to %s:%d (scheme: %s)\n", +// session.GatewayHost, session.GatewayPort, session.GatewayScheme) +// fmt.Printf("Host key: %s\n", session.HostKeyFingerprint) +// // Use session.Token to authenticate the SSH connection. +// +// revoked, err := client.SSH().RevokeSession(ctx, session.Token) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Session revoked: %v\n", revoked) +// +// # TCP Port Forwarding +// +// Forward a local connection to a port inside a sandbox: +// +// conn, err := client.TCP().Forward(ctx, "my-sandbox", 5432) +// if err != nil { +// log.Fatal(err) +// } +// defer conn.Close() +// +// // conn implements io.ReadWriteCloser, use it like a net.Conn. +// _, err = conn.Write([]byte("PING\n")) +// if err != nil { +// log.Fatal(err) +// } +// buf := make([]byte, 1024) +// n, err := conn.Read(buf) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Response: %s\n", buf[:n]) +// +// Use WithForwardServiceID to tag the forwarding session with a service +// identifier for audit logging: +// +// conn, err := client.TCP().Forward(ctx, "my-sandbox", 5432, +// v1.WithForwardServiceID("billing-db"), +// ) +// +// # SSH Tunneling +// +// Create an SSH tunnel to a sandbox port in a single call. Tunnel combines +// session creation, TCP forwarding with an SSH relay target, and automatic +// session cleanup into one operation: +// +// tunnel, err := client.SSH().Tunnel(ctx, "my-sandbox", 22) +// if err != nil { +// log.Fatal(err) +// } +// defer tunnel.Close() +// +// // tunnel implements io.ReadWriteCloser. The underlying SSH session +// // is automatically revoked when Close is called. +// _, err = tunnel.Write([]byte("SSH-2.0-client\r\n")) +// if err != nil { +// log.Fatal(err) +// } +// buf := make([]byte, 256) +// n, err := tunnel.Read(buf) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Server banner: %s\n", buf[:n]) +// +// Use WithTunnelServiceID to associate a service identifier with the tunnel: +// +// tunnel, err := client.SSH().Tunnel(ctx, "my-sandbox", 22, +// v1.WithTunnelServiceID("dev-ssh"), +// ) +// +// # Sandbox Policy +// +// Set an initial security policy when creating a sandbox: +// +// sandbox, err := client.Sandboxes().Create(ctx, "secure-sandbox", &v1.SandboxSpec{ +// Template: &v1.SandboxTemplate{Image: "python:3.12"}, +// Policy: &v1.SandboxPolicy{ +// Version: 1, +// Filesystem: &v1.FilesystemPolicy{ +// IncludeWorkdir: true, +// ReadOnly: []string{"/usr", "/lib"}, +// }, +// Process: &v1.ProcessPolicy{ +// RunAsUser: "sandbox", +// RunAsGroup: "sandbox", +// }, +// NetworkPolicies: map[string]v1.NetworkPolicyRule{ +// "allow-api": { +// Name: "allow-api", +// Endpoints: []v1.PolicyNetworkEndpoint{ +// {Host: "api.example.com", Port: 443, Protocol: "tcp"}, +// }, +// }, +// }, +// }, +// }, nil) +// +// Replace the full policy at runtime via configuration update: +// +// result, err := client.Config().Update(ctx, &v1.ConfigUpdate{ +// Name: "secure-sandbox", +// Policy: &v1.SandboxPolicy{ +// Version: 2, +// NetworkPolicies: map[string]v1.NetworkPolicyRule{ +// "allow-all": {Name: "allow-all"}, +// }, +// }, +// }) +// +// Read a policy back from revision history: +// +// revisions, err := client.Policy().List(ctx, "secure-sandbox") +// if err != nil { +// log.Fatal(err) +// } +// for _, rev := range revisions { +// if rev.Policy != nil { +// fmt.Printf("v%d: %d network rules\n", rev.Version, len(rev.Policy.NetworkPolicies)) +// } +// } +// +// # Configuration Management +// +// Read sandbox and gateway configuration, and update settings: +// +// sbCfg, err := client.Config().GetSandbox(ctx, "my-sandbox") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Config revision: %d\n", sbCfg.ConfigRevision) +// for name, setting := range sbCfg.Settings { +// fmt.Printf(" %s = %v (scope: %s)\n", name, setting.Value, setting.Scope) +// } +// +// gwCfg, err := client.Config().GetGateway(ctx) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Gateway settings revision: %d\n", gwCfg.SettingsRevision) +// +// result, err := client.Config().Update(ctx, &v1.ConfigUpdate{ +// Name: "my-sandbox", +// SettingKey: "max_tokens", +// SettingValue: &v1.SettingValue{ +// Type: v1.SettingValueInt, +// IntVal: 8192, +// }, +// }) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("New settings revision: %d\n", result.SettingsRevision) +package v1 diff --git a/sdk/go/openshell/v1/edge/cloudflare.go b/sdk/go/openshell/v1/edge/cloudflare.go new file mode 100644 index 0000000000..5363fe07f4 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "errors" + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// CloudflareAccess returns an AuthProvider that adds Cloudflare Access +// headers to every RPC. It sets: +// - cf-access-jwt-assertion: the edge JWT token +// - cookie: CF_Authorization= +// +// The edgeToken authenticates with the Cloudflare Access edge proxy. +// Returns an error if baseAuth is nil or edgeToken is empty. +func CloudflareAccess(baseAuth v1.AuthProvider, edgeToken string) (v1.AuthProvider, error) { + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + return v1.WithExtraHeaders(baseAuth, map[string]string{ + "cf-access-jwt-assertion": edgeToken, + "cookie": fmt.Sprintf("CF_Authorization=%s", edgeToken), + }) +} diff --git a/sdk/go/openshell/v1/edge/cloudflare_test.go b/sdk/go/openshell/v1/edge/cloudflare_test.go new file mode 100644 index 0000000000..902e599d69 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloudflareAccess_ValidToken(t *testing.T) { + base := v1.StaticToken("my-token") + auth, err := CloudflareAccess(base, "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Base auth header preserved. + assert.Equal(t, "Bearer my-token", md["authorization"]) + + // Cloudflare-specific headers present. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_EmptyToken(t *testing.T) { + base := v1.StaticToken("my-token") + _, err := CloudflareAccess(base, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestCloudflareAccess_NilBase(t *testing.T) { + _, err := CloudflareAccess(nil, "cf-edge-jwt-xxx") + require.Error(t, err) + assert.Contains(t, err.Error(), "base") +} + +func TestCloudflareAccess_WithNoAuth(t *testing.T) { + auth, err := CloudflareAccess(v1.NoAuth(), "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // NoAuth provides no base metadata; only CF headers should appear. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_RequireTransportSecurity_Delegates(t *testing.T) { + tests := []struct { + name string + base v1.AuthProvider + expected bool + }{ + { + name: "delegates to NoAuth (false)", + base: v1.NoAuth(), + expected: false, + }, + { + name: "delegates to StaticToken (true)", + base: v1.StaticToken("tok"), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := CloudflareAccess(tt.base, "cf-edge-jwt-xxx") + require.NoError(t, err) + assert.Equal(t, tt.expected, auth.RequireTransportSecurity()) + }) + } +} + +func TestCloudflareAccess_TokenNotInError(t *testing.T) { + // Verify the error for empty token does not leak actual token values. + _, err := CloudflareAccess(v1.StaticToken("s3cr3t-val"), "") + require.Error(t, err) + // The error should mention the parameter name, not any token value. + assert.NotContains(t, err.Error(), "s3cr3t-val") +} diff --git a/sdk/go/openshell/v1/edge/doc.go b/sdk/go/openshell/v1/edge/doc.go new file mode 100644 index 0000000000..92fd9b1f9f --- /dev/null +++ b/sdk/go/openshell/v1/edge/doc.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package edge provides utilities for connecting to OpenShell gateways +// through edge proxies such as Cloudflare Access. It includes convenience +// constructors for common edge auth patterns and a WebSocket tunnel proxy +// for gRPC transport through HTTP/1.1-only proxies. +// +// # Cloudflare Access +// +// CloudflareAccess wraps any AuthProvider with the headers required by +// Cloudflare Access (cf-access-jwt-assertion and CF_Authorization cookie). +// The edge token is typically a service token or application token obtained +// from Cloudflare: +// +// base := v1.StaticToken("my-gateway-token") +// auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN")) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// CloudflareAccess composes with any auth provider, including RefreshableToken +// for automatic token refresh: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// refreshAuth, err := v1.RefreshableToken(tokenSource) +// if err != nil { +// log.Fatal(err) +// } +// auth, err := edge.CloudflareAccess(refreshAuth, cfToken) +// if err != nil { +// log.Fatal(err) +// } +// +// # WebSocket Tunnel +// +// TunnelProxy bridges gRPC connections over a WebSocket tunnel for edge +// proxies that reject standard HTTP/2 POST requests. The tunnel carries +// its own edge token for proxy authentication, independent of the +// application-level auth provider. +// +// Create a tunnel proxy pointed at the gateway, then dial the proxy's +// local address from the gRPC client: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// os.Getenv("CF_ACCESS_TOKEN"), +// ) +// if err != nil { +// log.Fatal(err) +// } +// defer tunnel.Close() +// +// auth := v1.StaticToken("my-gateway-token") +// client, err := v1.NewClient(v1.Config{ +// Address: tunnel.Addr(), +// Auth: auth, +// TLS: &v1.TLSConfig{Insecure: true}, // local tunnel +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Use functional options to configure TLS, logging, and close timeout: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// cfToken, +// edge.WithTunnelTLS(&tls.Config{RootCAs: customCertPool}), +// edge.WithTunnelLogger(myLogger), +// edge.WithCloseTimeout(10*time.Second), +// ) +// +// Close drains in-flight connections gracefully. If draining exceeds the +// configured timeout (default 5 seconds), remaining connections are +// force-closed: +// +// err := tunnel.Close() // safe to call multiple times +package edge diff --git a/sdk/go/openshell/v1/edge/tunnel.go b/sdk/go/openshell/v1/edge/tunnel.go new file mode 100644 index 0000000000..2e50cd1570 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const defaultCloseTimeout = 5 * time.Second + +// tunnelConfig holds configuration set by TunnelOption functions. +type tunnelConfig struct { + logger types.Logger + tlsConfig *tls.Config + closeTimeout time.Duration +} + +// TunnelOption configures TunnelProxy behavior. +type TunnelOption func(*tunnelConfig) + +// WithTunnelLogger sets the structured logger for tunnel events. +func WithTunnelLogger(l types.Logger) TunnelOption { + return func(c *tunnelConfig) { + c.logger = l + } +} + +// WithTunnelTLS sets TLS configuration for the WebSocket connection (wss://). +func WithTunnelTLS(cfg *tls.Config) TunnelOption { + return func(c *tunnelConfig) { + c.tlsConfig = cfg + } +} + +// WithCloseTimeout sets the maximum time Close waits for in-flight +// connections to drain before force-closing. Default is 5 seconds. +func WithCloseTimeout(d time.Duration) TunnelOption { + return func(c *tunnelConfig) { + c.closeTimeout = d + } +} + +// TunnelProxy bridges gRPC connections over a WebSocket tunnel. +// The gRPC client dials TunnelProxy.Addr() instead of the remote gateway. +// Each accepted connection spawns a goroutine that dials the gateway over +// WebSocket and copies data bidirectionally. +type TunnelProxy struct { + listener net.Listener + gatewayURL string + edgeToken string + logger types.Logger + closeTimeout time.Duration + httpClient *http.Client + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +// NewTunnelProxy creates a tunnel proxy that forwards TCP connections +// through a WebSocket connection to gatewayURL. The edgeToken authenticates +// with the edge proxy via Cloudflare Access headers on the WebSocket +// handshake. +// +// Returns error if gatewayURL is empty or invalid, or if edgeToken is empty. +func NewTunnelProxy(gatewayURL, edgeToken string, opts ...TunnelOption) (*TunnelProxy, error) { + if gatewayURL == "" { + return nil, errors.New("gateway URL must not be empty") + } + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + // Validate the URL parses correctly. + u, err := url.Parse(gatewayURL) + if err != nil { + return nil, fmt.Errorf("invalid gateway URL: %w", err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, fmt.Errorf("gateway URL must use ws:// or wss:// scheme, got %q", u.Scheme) + } + if u.Host == "" { + return nil, errors.New("gateway URL must include a host") + } + + cfg := tunnelConfig{ + closeTimeout: defaultCloseTimeout, + } + for _, o := range opts { + o(&cfg) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + var httpClient *http.Client + if cfg.tlsConfig != nil { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: cfg.tlsConfig, + }, + } + } + + tp := &TunnelProxy{ + listener: listener, + gatewayURL: gatewayURL, + edgeToken: edgeToken, + logger: cfg.logger, + closeTimeout: cfg.closeTimeout, + httpClient: httpClient, + ctx: ctx, + cancel: cancel, + } + + // Start the accept loop. + tp.wg.Add(1) + go tp.acceptLoop() + + return tp, nil +} + +// Addr returns the local address the gRPC client should dial. +func (tp *TunnelProxy) Addr() string { + return tp.listener.Addr().String() +} + +// Close drains in-flight connections (up to the configured timeout, +// default 5s) then force-closes any remaining connections. All goroutines +// are cleaned up. Safe to call multiple times; the second and subsequent +// calls return immediately. +func (tp *TunnelProxy) Close() error { + tp.closeOnce.Do(func() { + tp.mu.Lock() + tp.closing = true + tp.mu.Unlock() + + // Stop accepting new connections. + tp.closeErr = tp.listener.Close() + + // Wait for in-flight connections to drain, with a timeout. + done := make(chan struct{}) + go func() { + tp.wg.Wait() + close(done) + }() + + select { + case <-done: + // All goroutines drained cleanly. + case <-time.After(tp.closeTimeout): + // Timeout reached; cancel all bridge contexts to force-close. + if tp.logger != nil { + tp.logger.Info("tunnel close timeout reached, force-closing") + } + tp.cancel() + <-done + } + // Always cancel to release the context tree. + tp.cancel() + }) + return tp.closeErr +} + +// acceptLoop runs in a goroutine. It accepts local TCP connections and +// spawns a bridge goroutine for each one. +func (tp *TunnelProxy) acceptLoop() { + defer tp.wg.Done() + + for { + conn, err := tp.listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + if tp.logger != nil { + tp.logger.Error(err, "tunnel accept error") + } + time.Sleep(10 * time.Millisecond) + continue + } + + tp.mu.Lock() + if tp.closing { + tp.mu.Unlock() + _ = conn.Close() + return + } + tp.wg.Add(1) + tp.mu.Unlock() + + if tp.logger != nil { + tp.logger.Debug("tunnel connection accepted", "remote", conn.RemoteAddr().String()) + } + + go tp.bridge(conn) + } +} + +// bridge dials the gateway over WebSocket and copies data bidirectionally +// between the local TCP connection and the WebSocket connection. +func (tp *TunnelProxy) bridge(local net.Conn) { + defer tp.wg.Done() + defer func() { _ = local.Close() }() + + ctx, cancel := context.WithCancel(tp.ctx) + defer cancel() + + // Build WebSocket dial options with edge auth headers. + dialOpts := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "cf-access-jwt-assertion": []string{tp.edgeToken}, + "cookie": []string{fmt.Sprintf("CF_Authorization=%s", tp.edgeToken)}, + }, + } + if tp.httpClient != nil { + dialOpts.HTTPClient = tp.httpClient + } + + dialCtx, dialCancel := context.WithTimeout(ctx, 10*time.Second) + defer dialCancel() + + wsConn, _, err := websocket.Dial(dialCtx, tp.gatewayURL, dialOpts) + if err != nil { + if tp.logger != nil { + tp.logger.Error(err, "tunnel websocket dial failed") + } + return + } + defer func() { _ = wsConn.CloseNow() }() + + // Set a generous read limit for gRPC frames. + wsConn.SetReadLimit(64 * 1024 * 1024) // 64 MiB + + // Convert the WebSocket connection to a net.Conn for bidirectional I/O. + remote := websocket.NetConn(ctx, wsConn, websocket.MessageBinary) + + // Bidirectional copy. + done := make(chan struct{}, 2) + + // Local -> Remote (WebSocket) + go func() { + _, _ = io.Copy(remote, local) + done <- struct{}{} + }() + + // Remote (WebSocket) -> Local + go func() { + _, _ = io.Copy(local, remote) + done <- struct{}{} + }() + + // Wait for one direction to finish, then tear down both. + <-done + cancel() + _ = local.Close() + <-done + + if tp.logger != nil { + tp.logger.Debug("tunnel bridge closed") + } +} diff --git a/sdk/go/openshell/v1/edge/tunnel_test.go b/sdk/go/openshell/v1/edge/tunnel_test.go new file mode 100644 index 0000000000..79daa062f7 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel_test.go @@ -0,0 +1,494 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "slices" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// echoWSHandler accepts a WebSocket connection and echoes every binary +// message back to the sender until the client disconnects. +func echoWSHandler(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = conn.CloseNow() }() + + ctx := r.Context() + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return + } + if err := conn.Write(ctx, typ, data); err != nil { + return + } + } +} + +// startEchoServer starts an HTTP test server that upgrades connections to +// WebSocket and echoes binary messages. Returns the server and its ws:// URL. +func startEchoServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + // Convert http://host:port to ws://host:port. + wsURL := "ws" + srv.URL[len("http"):] + return srv, wsURL +} + +// startTLSEchoServer starts a TLS HTTP test server. Returns the server, +// its wss:// URL, and a tls.Config that trusts the server's certificate. +func startTLSEchoServer(t *testing.T) (*httptest.Server, string, *tls.Config) { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + wssURL := "wss" + srv.URL[len("https"):] + + certPool := x509.NewCertPool() + certPool.AddCert(srv.Certificate()) + tlsCfg := &tls.Config{ + RootCAs: certPool, + } + return srv, wssURL, tlsCfg +} + +// testLogger captures log messages for assertions. +type testLogger struct { + mu sync.Mutex + messages []string +} + +func (l *testLogger) Debug(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "DEBUG: "+msg) +} + +func (l *testLogger) Info(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "INFO: "+msg) +} + +func (l *testLogger) Error(_ error, msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "ERROR: "+msg) +} + +func (l *testLogger) Messages() []string { + l.mu.Lock() + defer l.mu.Unlock() + cp := make([]string, len(l.messages)) + copy(cp, l.messages) + return cp +} + +// --- Test: NewTunnelProxy creation --- + +func TestNewTunnelProxy_ValidURL(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "edge-token-123") + require.NoError(t, err) + require.NotNil(t, tp) + defer func() { _ = tp.Close() }() + + // Addr() must return a non-empty, dialable address. + addr := tp.Addr() + assert.NotEmpty(t, addr) + + // Verify the address is dialable. + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestNewTunnelProxy_EmptyURL(t *testing.T) { + _, err := NewTunnelProxy("", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway URL") +} + +func TestNewTunnelProxy_InvalidURL(t *testing.T) { + _, err := NewTunnelProxy("://bad-url", "edge-token-123") + require.Error(t, err) +} + +func TestNewTunnelProxy_WrongScheme(t *testing.T) { + _, err := NewTunnelProxy("http://gateway.example.com", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "ws:// or wss://") +} + +func TestNewTunnelProxy_EmptyHost(t *testing.T) { + _, err := NewTunnelProxy("ws://", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "must include a host") +} + +func TestNewTunnelProxy_EmptyEdgeToken(t *testing.T) { + _, wsURL := startEchoServer(t) + + _, err := NewTunnelProxy(wsURL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestNewTunnelProxy_TokenNotInError(t *testing.T) { + // Verify error messages do not leak the edge token. + _, err := NewTunnelProxy("", "super-secret-token-abc") + require.Error(t, err) + assert.NotContains(t, err.Error(), "super-secret-token-abc") +} + +// --- Test: Addr --- + +func TestTunnelProxy_Addr_Dialable(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + addr := tp.Addr() + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + assert.NotEmpty(t, host) + assert.NotEmpty(t, port) +} + +// --- Test: Close on unused proxy --- + +func TestTunnelProxy_Close_Unused(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Close immediately without any connections should return nil. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close drains in-flight connections --- + +func TestTunnelProxy_Close_DrainsInFlight(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(5*time.Second)) + require.NoError(t, err) + + // Establish a connection through the tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + // Send data through the tunnel and verify echo. + testData := []byte("hello tunnel") + _, err = conn.Write(testData) + require.NoError(t, err) + + // Give the tunnel time to relay. + time.Sleep(100 * time.Millisecond) + + // Close the client connection first so the bridge goroutine can drain. + _ = conn.Close() + + // Now close the tunnel; should drain cleanly. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close force-closes after timeout --- + +func TestTunnelProxy_Close_ForceClosesAfterTimeout(t *testing.T) { + // Use a slow handler that holds connections open. + slowHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + return + } + defer func() { _ = wsConn.CloseNow() }() + // Hold the connection open for a long time. + ctx := r.Context() + select { + case <-ctx.Done(): + case <-time.After(30 * time.Second): + } + }) + srv := httptest.NewServer(slowHandler) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + // Use a very short close timeout and a logger to verify timeout logging. + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(200*time.Millisecond), WithTunnelLogger(logger)) + require.NoError(t, err) + + // Establish a connection that will be held open by the slow handler. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write something to trigger the WebSocket dial. + _, _ = conn.Write([]byte("trigger")) + + // Give the tunnel time to establish the bridge. + time.Sleep(100 * time.Millisecond) + + // Close should force-close after the short timeout, not hang. + start := time.Now() + err = tp.Close() + elapsed := time.Since(start) + + // Should complete within a reasonable time (timeout + margin). + assert.Less(t, elapsed, 2*time.Second, "Close should not hang beyond timeout") + // err may or may not be nil depending on force-close; we don't assert on it. + _ = err + + // Verify the timeout was logged. + msgs := logger.Messages() + assert.True(t, slices.Contains(msgs, "INFO: tunnel close timeout reached, force-closing"), + "expected timeout log message, got: %v", msgs) +} + +// --- Test: Concurrent Close is safe --- + +func TestTunnelProxy_Close_ConcurrentSafe(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Call Close concurrently from multiple goroutines. + var wg sync.WaitGroup + errs := make([]error, 10) + for i := range errs { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = tp.Close() + }(i) + } + wg.Wait() + + // All calls should succeed without panic. + for _, e := range errs { + assert.NoError(t, e) + } +} + +// --- Test: Goroutine cleanup --- + +func TestTunnelProxy_GoroutineCleanup(t *testing.T) { + _, wsURL := startEchoServer(t) + + // Record baseline goroutine count. + runtime.GC() + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Open several connections. + conns := make([]net.Conn, 5) + for i := range conns { + c, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + _, _ = fmt.Fprintf(c, "msg-%d", i) + conns[i] = c + } + + // Let bridges establish. + time.Sleep(100 * time.Millisecond) + + // Close all client connections. + for _, c := range conns { + _ = c.Close() + } + + // Close the tunnel. + err = tp.Close() + require.NoError(t, err) + + // Wait for goroutines to wind down. + time.Sleep(200 * time.Millisecond) + runtime.GC() + + // Goroutine count should return to near baseline. + // Allow a small margin for runtime goroutines. + final := runtime.NumGoroutine() + assert.LessOrEqual(t, final, baseline+3, + "goroutine leak: baseline=%d, final=%d", baseline, final) +} + +// --- Test: Concurrent streams --- + +func TestTunnelProxy_ConcurrentStreams(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + const streamCount = 10 + var wg sync.WaitGroup + errs := make(chan error, streamCount) + + for i := range streamCount { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + if err != nil { + errs <- fmt.Errorf("stream %d dial: %w", idx, err) + return + } + defer func() { _ = conn.Close() }() + + msg := fmt.Sprintf("stream-%d-data", idx) + _, err = conn.Write([]byte(msg)) + if err != nil { + errs <- fmt.Errorf("stream %d write: %w", idx, err) + return + } + + // Read echo response. + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, err := io.ReadFull(conn, buf) + if err != nil { + errs <- fmt.Errorf("stream %d read: %w (got %d bytes)", idx, err, n) + return + } + + if string(buf) != msg { + errs <- fmt.Errorf("stream %d: expected %q, got %q", idx, msg, string(buf)) + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } +} + +// --- Test: TLS option --- + +func TestTunnelProxy_TLSOption(t *testing.T) { + _, wssURL, tlsCfg := startTLSEchoServer(t) + + tp, err := NewTunnelProxy(wssURL, "tok", WithTunnelTLS(tlsCfg)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // Verify we can communicate through the TLS tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + msg := []byte("tls-echo-test") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + assert.Equal(t, msg, buf) +} + +// --- Test: Logger option --- + +func TestTunnelProxy_LoggerOption(t *testing.T) { + _, wsURL := startEchoServer(t) + + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithTunnelLogger(logger)) + require.NoError(t, err) + + // Open a connection to trigger log events. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + _, _ = conn.Write([]byte("log-test")) + + // Give the tunnel time to process. + time.Sleep(200 * time.Millisecond) + + _ = conn.Close() + + err = tp.Close() + require.NoError(t, err) + + // Logger should have received at least one message. + msgs := logger.Messages() + assert.NotEmpty(t, msgs, "logger should receive log events") +} + +// --- Test: WithCloseTimeout option --- + +func TestWithCloseTimeout(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(10*time.Second)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // We can't directly inspect the config, but creation should succeed. + assert.NotNil(t, tp) +} + +// --- Test: Data flows through the tunnel --- + +func TestTunnelProxy_DataRoundTrip(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Send data and verify round-trip through the WebSocket echo server. + msg := []byte("round-trip-payload-12345") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + + assert.Equal(t, msg, buf) +} diff --git a/sdk/go/openshell/v1/errors.go b/sdk/go/openshell/v1/errors.go new file mode 100644 index 0000000000..fd6d629a9f --- /dev/null +++ b/sdk/go/openshell/v1/errors.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ErrorCode classifies SDK errors by their gRPC origin. +type ErrorCode = types.ErrorCode + +// ErrorCode values for classifying gRPC errors. +const ( + ErrorNotFound = types.ErrorNotFound + ErrorAlreadyExists = types.ErrorAlreadyExists + ErrorUnavailable = types.ErrorUnavailable + ErrorPermissionDenied = types.ErrorPermissionDenied + ErrorInvalidArgument = types.ErrorInvalidArgument + ErrorDeadlineExceeded = types.ErrorDeadlineExceeded + ErrorCancelled = types.ErrorCancelled + ErrorInternal = types.ErrorInternal + ErrorUnimplemented = types.ErrorUnimplemented + ErrorConflict = types.ErrorConflict +) + +// StatusError is the typed error returned by all SDK operations. +type StatusError = types.StatusError + +// IsNotFound returns true if the error indicates a resource was not found. +func IsNotFound(err error) bool { return types.IsNotFound(err) } + +// IsAlreadyExists returns true if the error indicates a resource already exists. +func IsAlreadyExists(err error) bool { return types.IsAlreadyExists(err) } + +// IsUnavailable returns true if the error indicates the service is unavailable. +func IsUnavailable(err error) bool { return types.IsUnavailable(err) } + +// IsPermissionDenied returns true if the error indicates insufficient permissions. +func IsPermissionDenied(err error) bool { return types.IsPermissionDenied(err) } + +// IsInvalidArgument returns true if the error indicates an invalid argument. +func IsInvalidArgument(err error) bool { return types.IsInvalidArgument(err) } + +// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded. +func IsDeadlineExceeded(err error) bool { return types.IsDeadlineExceeded(err) } + +// IsCancelled returns true if the error indicates the operation was cancelled. +func IsCancelled(err error) bool { return types.IsCancelled(err) } + +// IsUnimplemented returns true if the error indicates the operation is not implemented. +func IsUnimplemented(err error) bool { return types.IsUnimplemented(err) } + +// IsConflict returns true if the error indicates a conflict, such as +// optimistic concurrency or an invalid state transition. +func IsConflict(err error) bool { return types.IsConflict(err) } diff --git a/sdk/go/openshell/v1/errors_test.go b/sdk/go/openshell/v1/errors_test.go new file mode 100644 index 0000000000..8565c0379b --- /dev/null +++ b/sdk/go/openshell/v1/errors_test.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusError_Error(t *testing.T) { + err := &StatusError{ + Code: ErrorNotFound, + Message: "sandbox not found", + } + s := err.Error() + assert.Contains(t, s, "NotFound") + assert.Contains(t, s, "sandbox not found") +} + +func TestStatusError_ErrorWithDetails(t *testing.T) { + err := &StatusError{ + Code: ErrorInvalidArgument, + Message: "bad name", + Details: map[string]string{"field": "name"}, + } + s := err.Error() + assert.Contains(t, s, "InvalidArgument") + assert.Contains(t, s, "bad name") +} + +func TestIsNotFound(t *testing.T) { + err := &StatusError{Code: ErrorNotFound, Message: "not found"} + assert.True(t, IsNotFound(err)) + assert.False(t, IsAlreadyExists(err)) +} + +func TestIsAlreadyExists(t *testing.T) { + err := &StatusError{Code: ErrorAlreadyExists, Message: "exists"} + assert.True(t, IsAlreadyExists(err)) + assert.False(t, IsNotFound(err)) +} + +func TestIsUnavailable(t *testing.T) { + err := &StatusError{Code: ErrorUnavailable, Message: "down"} + assert.True(t, IsUnavailable(err)) +} + +func TestIsPermissionDenied(t *testing.T) { + err := &StatusError{Code: ErrorPermissionDenied, Message: "denied"} + assert.True(t, IsPermissionDenied(err)) +} + +func TestIsInvalidArgument(t *testing.T) { + err := &StatusError{Code: ErrorInvalidArgument, Message: "invalid"} + assert.True(t, IsInvalidArgument(err)) +} + +func TestIsDeadlineExceeded(t *testing.T) { + err := &StatusError{Code: ErrorDeadlineExceeded, Message: "timeout"} + assert.True(t, IsDeadlineExceeded(err)) +} + +func TestIsCancelled(t *testing.T) { + err := &StatusError{Code: ErrorCancelled, Message: "cancelled"} + assert.True(t, IsCancelled(err)) +} + +func TestIsConflict(t *testing.T) { + err := &StatusError{Code: ErrorConflict, Message: "version conflict"} + assert.True(t, IsConflict(err)) + assert.False(t, IsNotFound(err)) +} + +func TestIsHelpers_NonStatusError(t *testing.T) { + err := errors.New("plain error") + assert.False(t, IsNotFound(err)) + assert.False(t, IsAlreadyExists(err)) + assert.False(t, IsUnavailable(err)) + assert.False(t, IsPermissionDenied(err)) + assert.False(t, IsInvalidArgument(err)) + assert.False(t, IsDeadlineExceeded(err)) + assert.False(t, IsCancelled(err)) + assert.False(t, IsConflict(err)) +} + +func TestIsHelpers_NilError(t *testing.T) { + assert.False(t, IsNotFound(nil)) + assert.False(t, IsConflict(nil)) +} + +func TestStatusError_WrappedError(t *testing.T) { + inner := &StatusError{Code: ErrorNotFound, Message: "not found"} + wrapped := fmt.Errorf("operation failed: %w", inner) + assert.True(t, IsNotFound(wrapped)) + + var se *StatusError + require.True(t, errors.As(wrapped, &se)) + assert.Equal(t, ErrorNotFound, se.Code) +} + +func TestErrorCode_String(t *testing.T) { + tests := []struct { + code ErrorCode + want string + }{ + {ErrorNotFound, "NotFound"}, + {ErrorAlreadyExists, "AlreadyExists"}, + {ErrorUnavailable, "Unavailable"}, + {ErrorPermissionDenied, "PermissionDenied"}, + {ErrorInvalidArgument, "InvalidArgument"}, + {ErrorDeadlineExceeded, "DeadlineExceeded"}, + {ErrorCancelled, "Cancelled"}, + {ErrorInternal, "Internal"}, + {ErrorUnimplemented, "Unimplemented"}, + {ErrorConflict, "Conflict"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, tt.code.String()) + } +} diff --git a/sdk/go/openshell/v1/example_fake_test.go b/sdk/go/openshell/v1/example_fake_test.go new file mode 100644 index 0000000000..35c0e78c97 --- /dev/null +++ b/sdk/go/openshell/v1/example_fake_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ExampleNewClient_addSandbox demonstrates pre-seeding a fake client with +// a sandbox fixture. +func ExampleNewClient_addSandbox() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + // Pre-seed a sandbox that already exists in Ready state + client.AddSandbox(&types.Sandbox{ + Name: "pre-existing", + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + ResourceVersion: 5, + }) + + ctx := context.Background() + + sb, err := client.Sandboxes().Get(ctx, "pre-existing") + if err != nil { + log.Fatal(err) + } + fmt.Println("Name:", sb.Name) + fmt.Println("Phase:", sb.Status.Phase) + // Output: + // Name: pre-existing + // Phase: Ready +} + +// ExampleNewClient_addProvider demonstrates pre-seeding a fake client with +// a provider fixture. +func ExampleNewClient_addProvider() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + // Pre-seed a provider + client.AddProvider(&types.Provider{ + Name: "seeded-provider", + Type: "openai", + }) + + ctx := context.Background() + + providers, err := client.Providers().List(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Count:", len(providers)) + fmt.Println("Name:", providers[0].Name) + // Output: + // Count: 1 + // Name: seeded-provider +} + +// ExampleNewClient_withHealthResult demonstrates configuring the fake +// health sub-client to return a custom result. +func ExampleNewClient_withHealthResult() { + client := fake.NewClient(fake.WithHealthResult(&types.HealthResult{ + Healthy: false, + Version: "1.2.3", + })) + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + result, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Healthy:", result.Healthy) + fmt.Println("Version:", result.Version) + // Output: + // Healthy: false + // Version: 1.2.3 +} + +// ExampleNewClient_watchEvents demonstrates watching for sandbox events +// using the fake client. +func ExampleNewClient_watchEvents() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Start watching before creating + watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox") + if err != nil { + log.Fatal(err) + } + defer watcher.Stop() + + // Create triggers an ADDED event + _, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + + event := <-watcher.ResultChan() + fmt.Println("Type:", event.Type) + fmt.Println("Name:", event.Object.Name) + // Output: + // Type: ADDED + // Name: my-sandbox +} + +// ExampleNewClient_stopOnTerminal demonstrates the StopOnTerminal watch +// option that automatically closes the watcher when a sandbox reaches a +// terminal phase. +func ExampleNewClient_stopOnTerminal() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Watch with StopOnTerminal + watcher, err := client.Sandboxes().Watch(ctx, "my-sandbox", v1.WatchOptions{ + StopOnTerminal: true, + }) + if err != nil { + log.Fatal(err) + } + + // Create and transition to Ready + _, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + _, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") + if err != nil { + log.Fatal(err) + } + + // Drain events, channel closes after terminal phase + var count int + for range watcher.ResultChan() { + count++ + } + fmt.Println("Events received:", count) + // Output: + // Events received: 2 +} diff --git a/sdk/go/openshell/v1/example_test.go b/sdk/go/openshell/v1/example_test.go new file mode 100644 index 0000000000..cdbe2c63be --- /dev/null +++ b/sdk/go/openshell/v1/example_test.go @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" +) + +// ExampleClient_Sandboxes demonstrates the sandbox lifecycle: create a sandbox, +// wait for it to become ready, and then clean up. +func ExampleClient_Sandboxes() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Create a sandbox + sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("Phase after create:", sb.Status.Phase) + + // Wait for the sandbox to become ready + sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") + if err != nil { + log.Fatal(err) + } + fmt.Println("Phase after wait:", sb.Status.Phase) + + // Clean up + if err := client.Sandboxes().Delete(ctx, "my-sandbox"); err != nil { + log.Fatal(err) + } + fmt.Println("Deleted") + // Output: + // Phase after create: Provisioning + // Phase after wait: Ready + // Deleted +} + +// ExampleClient_Providers demonstrates registering and listing providers. +func ExampleClient_Providers() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Register a provider + _, err := client.Providers().Create(ctx, &v1.Provider{ + Name: "my-openai", + Type: "openai", + }) + if err != nil { + log.Fatal(err) + } + + // List all providers + providers, err := client.Providers().List(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Count:", len(providers)) + fmt.Println("Name:", providers[0].Name) + // Output: + // Count: 1 + // Name: my-openai +} + +// ExampleClient_Health demonstrates checking gateway health. +func ExampleClient_Health() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + result, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Healthy:", result.Healthy) + // Output: + // Healthy: true +} + +// ExampleClient_Exec demonstrates running a command in a sandbox. +// The fake client returns Unimplemented for exec operations, so this +// example shows the call pattern and error handling. +func ExampleClient_Exec() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := client.Exec().Run(ctx, "my-sandbox", []string{"echo", "hello"}) + if v1.IsUnimplemented(err) { + fmt.Println("Exec requires a real gateway") + } + // Output: + // Exec requires a real gateway +} + +// ExampleClient_TCP demonstrates binding a local port to a sandbox port +// using the net.Listener pattern. The returned listener tunnels every +// accepted connection to the remote port inside the sandbox. +// +// The fake client returns Unimplemented for Listen, so this example shows +// the call pattern and error handling rather than a live tunnel. +func ExampleClient_TCP() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Bind local port 0 (OS-assigned) to sandbox port 8080. + ln, err := client.TCP().Listen(ctx, "my-sandbox", 8080, 0) + if v1.IsUnimplemented(err) { + fmt.Println("Listen requires a real gateway") + } + if ln != nil { + // In production, use ln.Addr() to discover the assigned port, + // then accept connections in a loop: + // + // for { + // conn, err := ln.Accept() + // if err != nil { break } + // go handleConn(conn) + // } + defer ln.Close() //nolint:errcheck + } + // Output: + // Listen requires a real gateway +} + +// ExampleIsNotFound demonstrates handling a not-found error. +func ExampleIsNotFound() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := client.Sandboxes().Get(ctx, "nonexistent") + if v1.IsNotFound(err) { + fmt.Println("Sandbox not found") + } + // Output: + // Sandbox not found +} + +// ExampleIsAlreadyExists demonstrates handling a duplicate-creation error. +func ExampleIsAlreadyExists() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Create a sandbox + _, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + + // Try to create the same sandbox again + _, err = client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) + if v1.IsAlreadyExists(err) { + fmt.Println("Sandbox already exists") + } + // Output: + // Sandbox already exists +} + +// ExampleIsUnavailable demonstrates detecting a closed client. +func ExampleIsUnavailable() { + client := fake.NewClient() + _ = client.Close() + + ctx := context.Background() + + _, err := client.Sandboxes().Get(ctx, "any") + if v1.IsUnavailable(err) { + fmt.Println("Client is closed") + } + // Output: + // Client is closed +} diff --git a/sdk/go/openshell/v1/exec.go b/sdk/go/openshell/v1/exec.go new file mode 100644 index 0000000000..373f518eee --- /dev/null +++ b/sdk/go/openshell/v1/exec.go @@ -0,0 +1,40 @@ +// 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" +) + +// ExecResult holds the collected output of a completed command execution. +type ExecResult = types.ExecResult + +// ExecChunk represents a single chunk of output from a streaming command execution. +type ExecChunk = types.ExecChunk + +// ExecStream provides an iterator interface over streaming command output. +type ExecStream interface { + Next() (*ExecChunk, error) + ExitCode() (int, error) + Close() error +} + +// InteractiveSession provides bidirectional I/O for interactive command execution. +type InteractiveSession interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Resize(cols, rows uint32) error + ExitCode() (int, error) + Close() error +} + +// ExecInterface defines command execution operations on sandboxes. +// Methods accept a sandbox name and resolve it to an ID internally. +type ExecInterface interface { + Run(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) + Stream(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) + Interactive(ctx context.Context, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) +} diff --git a/sdk/go/openshell/v1/exec_client.go b/sdk/go/openshell/v1/exec_client.go new file mode 100644 index 0000000000..f60143b43a --- /dev/null +++ b/sdk/go/openshell/v1/exec_client.go @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type execClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newExecClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *execClient { + return &execClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes} +} + +func (e *execClient) Run(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + req := converter.ExecRequestToProto(sb.ID, command, opt) + + stream, err := e.client.ExecSandbox(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + var events []*pb.ExecSandboxEvent + for { + ev, recvErr := stream.Recv() + if recvErr == io.EOF { + break + } + if recvErr != nil { + return nil, converter.FromGRPCError(recvErr) + } + events = append(events, ev) + } + + return converter.ExecResultFromEvents(events) +} + +func (e *execClient) Stream(ctx context.Context, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + req := converter.ExecRequestToProto(sb.ID, command, opt) + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := e.client.ExecSandbox(streamCtx, req) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + return &execStream{stream: stream, cancel: cancel}, nil +} + +func (e *execClient) Interactive(ctx context.Context, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + + stream, err := e.client.ExecSandboxInteractive(ctx) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + startReq := converter.ExecInteractiveRequestToProto(sb.ID, command, cols, rows, opt) + if sendErr := stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Start{Start: startReq}, + }); sendErr != nil { + return nil, converter.FromGRPCError(sendErr) + } + + return newInteractiveSession(stream), nil +} + +// execStream wraps a server-streaming RPC into the ExecStream interface. +type execStream struct { + stream grpc.ServerStreamingClient[pb.ExecSandboxEvent] + cancel context.CancelFunc + exitCode int + exited bool + hasExit bool +} + +func (s *execStream) Next() (*ExecChunk, error) { + if s.exited { + return nil, io.EOF + } + + ev, err := s.stream.Recv() + if err == io.EOF { + return nil, io.EOF + } + if err != nil { + return nil, converter.FromGRPCError(err) + } + + chunk, code, convErr := converter.ExecChunkFromEvent(ev) + if convErr != nil { + return nil, convErr + } + if chunk != nil { + return chunk, nil + } + // nil chunk with no error means exit event + s.exitCode = code + s.exited = true + s.hasExit = true + return nil, io.EOF +} + +func (s *execStream) ExitCode() (int, error) { + if !s.exited { + for { + _, err := s.Next() + if err == io.EOF { + break + } + if err != nil { + return -1, err + } + } + } + if !s.hasExit { + return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} + } + return s.exitCode, nil +} + +func (s *execStream) Close() error { + if s.cancel != nil { + s.cancel() + } + return nil +} + +// interactiveSession wraps a bidirectional streaming RPC into the InteractiveSession interface. +// A background goroutine owns the Recv loop and routes events to dataCh (for Read) +// and exitCh (for ExitCode), preventing concurrent Recv calls on the stream. +type interactiveSession struct { + stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent] + sendMu sync.Mutex + dataCh chan []byte + exitCh chan int + done chan struct{} + errOnce sync.Once + err error + buf []byte +} + +func newInteractiveSession(stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]) *interactiveSession { + s := &interactiveSession{ + stream: stream, + dataCh: make(chan []byte, 64), + exitCh: make(chan int, 1), + done: make(chan struct{}), + } + go s.readLoop() + return s +} + +func (s *interactiveSession) setErr(err error) { + s.errOnce.Do(func() { s.err = err }) +} + +func (s *interactiveSession) readLoop() { + defer close(s.dataCh) + defer close(s.done) + for { + ev, err := s.stream.Recv() + if err != nil { + if err != io.EOF { + s.setErr(converter.FromGRPCError(err)) + } + return + } + + chunk, code, convErr := converter.ExecChunkFromEvent(ev) + if convErr != nil { + s.setErr(convErr) + return + } + // nil chunk with no error means exit event + if chunk == nil { + select { + case s.exitCh <- code: + default: + } + return + } + select { + case s.dataCh <- chunk.Data: + case <-s.done: + return + } + } +} + +func (s *interactiveSession) Read(p []byte) (int, error) { + if len(s.buf) > 0 { + n := copy(p, s.buf) + s.buf = s.buf[n:] + return n, nil + } + + data, ok := <-s.dataCh + if !ok { + if s.err != nil { + return 0, s.err + } + return 0, io.EOF + } + n := copy(p, data) + if n < len(data) { + s.buf = append(s.buf, data[n:]...) + } + return n, nil +} + +func (s *interactiveSession) Write(p []byte) (int, error) { + s.sendMu.Lock() + defer s.sendMu.Unlock() + err := s.stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Stdin{Stdin: p}, + }) + if err != nil { + return 0, converter.FromGRPCError(err) + } + return len(p), nil +} + +func (s *interactiveSession) Resize(cols, rows uint32) error { + s.sendMu.Lock() + defer s.sendMu.Unlock() + err := s.stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Resize{ + Resize: &pb.ExecSandboxWindowResize{ + Cols: cols, + Rows: rows, + }, + }, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (s *interactiveSession) ExitCode() (int, error) { + select { + case code := <-s.exitCh: + return code, nil + case <-s.done: + select { + case code := <-s.exitCh: + return code, nil + default: + if s.err != nil { + return -1, s.err + } + return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} + } + } +} + +func (s *interactiveSession) Close() error { + return s.stream.CloseSend() +} diff --git a/sdk/go/openshell/v1/exec_client_test.go b/sdk/go/openshell/v1/exec_client_test.go new file mode 100644 index 0000000000..fed9e5b121 --- /dev/null +++ b/sdk/go/openshell/v1/exec_client_test.go @@ -0,0 +1,621 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "net" + "sync" + "testing" + + 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" +) + +// stubSandboxResolver implements SandboxInterface for testing name-to-ID resolution. +// Get returns a Sandbox with ID = "sb-" + name. All other methods panic. +type stubSandboxResolver struct { + getErr error // if non-nil, Get returns this error +} + +func (r *stubSandboxResolver) Get(_ context.Context, name string) (*Sandbox, error) { + if r.getErr != nil { + return nil, r.getErr + } + return &Sandbox{ID: "sb-" + name, Name: name}, nil +} + +func (r *stubSandboxResolver) Create(context.Context, string, *SandboxSpec, map[string]string) (*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) List(context.Context, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) Delete(context.Context, string) error { panic("not implemented") } +func (r *stubSandboxResolver) AttachProvider(context.Context, string, string, uint64) (*AttachProviderResult, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) DetachProvider(context.Context, string, string, uint64) (*DetachProviderResult, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) ListProviders(context.Context, string) ([]*Provider, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) WaitReady(context.Context, string, ...WaitOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) Watch(context.Context, string, ...WatchOptions) (WatchInterface[*Sandbox], error) { + panic("not implemented") +} +func (r *stubSandboxResolver) GetLogs(context.Context, string, ...LogOption) (*LogResult, error) { + panic("not implemented") +} + +type mockExecServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + execEvents []*pb.ExecSandboxEvent + execErr error + lastExecRequest *pb.ExecSandboxRequest + + interactiveEvents []*pb.ExecSandboxEvent + interactiveErr error + interactiveWaitInput bool + receivedInputs []*pb.ExecSandboxInput +} + +func newMockExecServer() *mockExecServer { + return &mockExecServer{} +} + +func (s *mockExecServer) ExecSandbox(req *pb.ExecSandboxRequest, stream grpc.ServerStreamingServer[pb.ExecSandboxEvent]) error { + s.mu.Lock() + s.lastExecRequest = req + events := make([]*pb.ExecSandboxEvent, len(s.execEvents)) + copy(events, s.execEvents) + execErr := s.execErr + s.mu.Unlock() + + if execErr != nil { + return execErr + } + + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + return nil +} + +func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[pb.ExecSandboxInput, pb.ExecSandboxEvent]) error { + s.mu.Lock() + interactiveErr := s.interactiveErr + events := make([]*pb.ExecSandboxEvent, len(s.interactiveEvents)) + copy(events, s.interactiveEvents) + s.mu.Unlock() + + if interactiveErr != nil { + return interactiveErr + } + + // Read the start message + startMsg, err := stream.Recv() + if err != nil { + return err + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, startMsg) + s.mu.Unlock() + + s.mu.Lock() + waitInput := s.interactiveWaitInput + s.mu.Unlock() + + if waitInput { + msg, recvErr := stream.Recv() + if recvErr != nil { + return recvErr + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, msg) + s.mu.Unlock() + } + + // Read subsequent messages until client closes, collecting them + go func() { + for { + msg, recvErr := stream.Recv() + if recvErr != nil { + return + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, msg) + s.mu.Unlock() + } + }() + + // Send canned events + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + return nil +} + +func setupExecTest(t *testing.T, mock *mockExecServer) (*execClient, 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 newExecClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T043: Run and Stream tests --- + +func TestExecRun(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("hello ")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("world\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + result, err := client.Run(context.Background(), "test-sandbox", []string{"echo", "hello", "world"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, []byte("hello world\n"), result.Stdout) + assert.Equal(t, []byte("warn\n"), result.Stderr) +} + +func TestExecRun_WithOptions(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + opts := ExecOptions{ + Env: map[string]string{"FOO": "bar"}, + WorkDir: "/tmp", + } + result, err := client.Run(context.Background(), "test-sandbox", []string{"ls"}, opts) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.ExitCode) + + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Equal(t, "sb-test-sandbox", mock.lastExecRequest.GetSandboxId()) + assert.Equal(t, []string{"ls"}, mock.lastExecRequest.GetCommand()) + assert.Equal(t, "/tmp", mock.lastExecRequest.GetWorkdir()) + assert.Equal(t, map[string]string{"FOO": "bar"}, mock.lastExecRequest.GetEnvironment()) +} + +func TestExecRun_NonZeroExit(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("fail\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + result, err := client.Run(context.Background(), "test-sandbox", []string{"false"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Equal(t, []byte("fail\n"), result.Stderr) +} + +func TestExecRun_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.execErr = status.Error(codes.NotFound, "sandbox not found") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + _, err := client.Run(context.Background(), "missing-sandbox", []string{"ls"}) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecStream(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("err1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 42}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "test-sandbox", []string{"cat"}) + require.NoError(t, err) + require.NotNil(t, stream) + defer func() { _ = stream.Close() }() + + chunk1, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStdout, chunk1.Stream) + assert.Equal(t, []byte("line1\n"), chunk1.Data) + + chunk2, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStderr, chunk2.Stream) + assert.Equal(t, []byte("err1\n"), chunk2.Data) + + chunk3, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStdout, chunk3.Stream) + assert.Equal(t, []byte("line2\n"), chunk3.Data) + + // Next call after exit should return io.EOF + _, err = stream.Next() + assert.ErrorIs(t, err, io.EOF) + + exitCode, err := stream.ExitCode() + require.NoError(t, err) + assert.Equal(t, 42, exitCode) +} + +func TestExecStream_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.execErr = status.Error(codes.Internal, "internal error") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "test-sandbox", []string{"ls"}) + if err != nil { + return + } + _, err = stream.Next() + require.Error(t, err) +} + +func TestExecStream_EmptyOutput(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "test-sandbox", []string{"true"}) + require.NoError(t, err) + defer func() { _ = stream.Close() }() + + _, err = stream.Next() + assert.ErrorIs(t, err, io.EOF) + + exitCode, err := stream.ExitCode() + require.NoError(t, err) + assert.Equal(t, 0, exitCode) +} + +// --- T044: Interactive session tests --- + +func TestExecInteractive(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("output\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/bash"}, 80, 24) + require.NoError(t, err) + require.NotNil(t, session) + defer func() { _ = session.Close() }() + + // Read output + buf := make([]byte, 1024) + n, err := session.Read(buf) + require.NoError(t, err) + assert.Equal(t, "$ ", string(buf[:n])) + + // Verify start message was received + mock.mu.Lock() + require.GreaterOrEqual(t, len(mock.receivedInputs), 1) + startInput := mock.receivedInputs[0] + mock.mu.Unlock() + + startReq := startInput.GetStart() + require.NotNil(t, startReq) + assert.Equal(t, "sb-test-sandbox", startReq.GetSandboxId()) + assert.Equal(t, []string{"/bin/bash"}, startReq.GetCommand()) + assert.True(t, startReq.GetTty()) + assert.Equal(t, uint32(80), startReq.GetCols()) + assert.Equal(t, uint32(24), startReq.GetRows()) +} + +func TestExecInteractive_Write(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitInput = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + n, err := session.Write([]byte("ls\n")) + require.NoError(t, err) + assert.Equal(t, 3, n) +} + +func TestExecInteractive_Resize(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitInput = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + err = session.Resize(120, 40) + require.NoError(t, err) +} + +func TestExecInteractive_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.interactiveErr = status.Error(codes.PermissionDenied, "not allowed") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + if err != nil { + return + } + buf := make([]byte, 1024) + _, err = session.Read(buf) + require.Error(t, err) +} + +func TestExecInteractive_ExitCode(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("done\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 130}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + // Drain output + buf := make([]byte, 1024) + for { + _, readErr := session.Read(buf) + if readErr != nil { + break + } + } + + exitCode, err := session.ExitCode() + require.NoError(t, err) + assert.Equal(t, 130, exitCode) + + _ = session.Close() +} + +func TestExecInteractive_ConcurrentReadAndExitCode(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line3\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "test-sandbox", []string{"sh"}, 80, 24) + require.NoError(t, err) + + var wg sync.WaitGroup + var readData []byte + var readErr error + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 1024) + for { + n, err := session.Read(buf) + if err != nil { + readErr = err + return + } + readData = append(readData, buf[:n]...) + } + }() + + exitCode, exitErr := session.ExitCode() + wg.Wait() + + require.NoError(t, exitErr) + assert.Equal(t, 0, exitCode) + assert.Equal(t, io.EOF, readErr) + assert.Contains(t, string(readData), "line1\n") +} + +// --- Name-to-ID resolution tests --- + +func TestExecRun_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + _, err := client.Run(context.Background(), "my-sandbox", []string{"echo", "hi"}) + require.NoError(t, err) + + mock.mu.Lock() + defer mock.mu.Unlock() + // Verify the proto request contains the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) +} + +func TestExecRun_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Run(context.Background(), "nonexistent", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecStream_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "my-sandbox", []string{"echo"}) + require.NoError(t, err) + + _, _ = stream.ExitCode() + _ = stream.Close() + + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) +} + +func TestExecStream_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Stream(context.Background(), "nonexistent", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecInteractive_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "my-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + _, _ = session.ExitCode() + _ = session.Close() + + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotEmpty(t, mock.receivedInputs) + startReq := mock.receivedInputs[0].GetStart() + require.NotNil(t, startReq) + assert.Equal(t, "sb-my-sandbox", startReq.GetSandboxId()) +} + +func TestExecInteractive_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Interactive(context.Background(), "nonexistent", []string{"/bin/sh"}, 80, 24) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecRun_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Run(context.Background(), "", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestExecStream_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Stream(context.Background(), "", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestExecInteractive_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Interactive(context.Background(), "", []string{"/bin/sh"}, 80, 24) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// stubConn creates a minimal gRPC connection for resolution-error tests +// where the RPC is never reached. +func stubConn(t *testing.T) *grpc.ClientConn { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, newMockExecServer()) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { srv.Stop() }) + + 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) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/sdk/go/openshell/v1/fake/broadcaster.go b/sdk/go/openshell/v1/fake/broadcaster.go new file mode 100644 index 0000000000..f5d01d4743 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const watchChannelBuffer = 100 + +// watchBroadcaster manages a set of watchers and broadcasts events to them. +// Each watcher can optionally filter events by resource name. +type watchBroadcaster[T any] struct { + mu sync.Mutex + watchers []*fakeWatcher[T] +} + +// newWatchBroadcaster creates a new watchBroadcaster. +func newWatchBroadcaster[T any]() *watchBroadcaster[T] { + return &watchBroadcaster[T]{} +} + +// Watch registers a new watcher. If name is non-empty, the watcher only +// receives events matching that name. If name is empty, all events are +// delivered. The returned WatchInterface must be stopped by the caller. +func (b *watchBroadcaster[T]) Watch(name string) types.WatchInterface[T] { + ch := make(chan types.Event[T], watchChannelBuffer) + w := &fakeWatcher[T]{ + ch: ch, + name: name, + } + + b.mu.Lock() + b.watchers = append(b.watchers, w) + b.mu.Unlock() + + return w +} + +// Broadcast sends an event to all registered watchers whose name filter +// matches (or whose filter is empty). Stopped watchers are skipped and +// cleaned up lazily. +func (b *watchBroadcaster[T]) Broadcast(event types.Event[T], name string) { + b.mu.Lock() + defer b.mu.Unlock() + + active := b.watchers[:0] + for _, w := range b.watchers { + if w.isStopped() { + continue + } + active = append(active, w) + + if w.name != "" && w.name != name { + continue + } + + w.send(event) + } + b.watchers = active +} + +// StopAll closes all active watchers. +func (b *watchBroadcaster[T]) StopAll() { + b.mu.Lock() + defer b.mu.Unlock() + + for _, w := range b.watchers { + w.Stop() + } + b.watchers = nil +} + +// fakeWatcher implements types.WatchInterface[T] with a buffered channel +// and optional name filter. +type fakeWatcher[T any] struct { + ch chan types.Event[T] + name string + once sync.Once + stopped bool + mu sync.Mutex +} + +// ResultChan returns the channel delivering watch events. +func (w *fakeWatcher[T]) ResultChan() <-chan types.Event[T] { + return w.ch +} + +// send delivers an event to the watcher under its lock, preventing a +// race between Broadcast (send) and Stop (close) on w.ch. +func (w *fakeWatcher[T]) send(event types.Event[T]) { + w.mu.Lock() + defer w.mu.Unlock() + if w.stopped { + return + } + select { + case w.ch <- event: + default: + } +} + +// Stop closes the event channel. It is safe to call multiple times. +func (w *fakeWatcher[T]) Stop() { + w.once.Do(func() { + w.mu.Lock() + defer w.mu.Unlock() + w.stopped = true + close(w.ch) + }) +} + +// isStopped returns true if Stop has been called. +func (w *fakeWatcher[T]) isStopped() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.stopped +} diff --git a/sdk/go/openshell/v1/fake/broadcaster_test.go b/sdk/go/openshell/v1/fake/broadcaster_test.go new file mode 100644 index 0000000000..e3605503e5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster_test.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T005: watchBroadcaster tests --- + +func TestWatchBroadcaster_Watch_ReceivesEvents(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + defer w.Stop() + + item := &testItem{Name: "alpha", Value: "v1"} + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: item}, "alpha") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +func TestWatchBroadcaster_Watch_NameFiltering(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher filtered to "alpha" only + wAlpha := b.Watch("alpha") + defer wAlpha.Stop() + + // Watcher filtered to "beta" only + wBeta := b.Watch("beta") + defer wBeta.Stop() + + // Broadcast event for "alpha" + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // alpha watcher should receive event + select { + case ev := <-wAlpha.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("alpha watcher: timed out waiting for event") + } + + // beta watcher should NOT receive event + select { + case ev := <-wBeta.ResultChan(): + t.Fatalf("beta watcher: unexpected event %v", ev) + case <-time.After(50 * time.Millisecond): + // Expected: no event for beta + } +} + +func TestWatchBroadcaster_Watch_EmptyNameReceivesAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher with empty name receives all events + w := b.Watch("") + defer w.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "beta"}}, "beta") + + received := make([]string, 0, 2) + for i := 0; i < 2; i++ { + select { + case ev := <-w.ResultChan(): + received = append(received, ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } + assert.ElementsMatch(t, []string{"alpha", "beta"}, received) +} + +func TestWatchBroadcaster_MultipleWatchers(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + defer w1.Stop() + w2 := b.Watch("") + defer w2.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // Both watchers should receive the event + for _, w := range []types.WatchInterface[*testItem]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } +} + +func TestWatchBroadcaster_Stop_ClosesChannel(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Channel should be closed after Stop + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestWatchBroadcaster_Stop_Idempotent(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + + // Multiple stops should not panic + w.Stop() + w.Stop() +} + +func TestWatchBroadcaster_StopAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("alpha") + + b.StopAll() + + // Both channels should be closed + _, ok1 := <-w1.ResultChan() + assert.False(t, ok1, "w1 channel should be closed after StopAll") + + _, ok2 := <-w2.ResultChan() + assert.False(t, ok2, "w2 channel should be closed after StopAll") +} + +func TestWatchBroadcaster_BroadcastAfterStop_NoDelivery(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Broadcasting after a watcher stops should not panic + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") +} + +func TestWatchBroadcaster_StoppedWatcher_RemovedFromBroadcast(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("") + + // Stop w1, keep w2 + w1.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // w2 should still receive events + select { + case ev := <-w2.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on w2") + } + + w2.Stop() +} diff --git a/sdk/go/openshell/v1/fake/config.go b/sdk/go/openshell/v1/fake/config.go new file mode 100644 index 0000000000..3bb94605c5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/config.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeConfigClient implements v1.ConfigInterface. All methods return +// Unimplemented because configuration management requires a real gateway. +type fakeConfigClient struct { + closedFunc func() bool +} + +// newFakeConfigClient creates a new fakeConfigClient. +func newFakeConfigClient(closedFunc func() bool) *fakeConfigClient { + return &fakeConfigClient{closedFunc: closedFunc} +} + +// GetSandbox returns Unimplemented. +func (c *fakeConfigClient) GetSandbox(_ context.Context, _ string) (*types.SandboxConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetSandbox is not supported by the fake client"} +} + +// GetGateway returns Unimplemented. +func (c *fakeConfigClient) GetGateway(_ context.Context) (*types.GatewayConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetGateway is not supported by the fake client"} +} + +// Update returns Unimplemented. A nil update is rejected with InvalidArgument +// to match the real client's behavior. +func (c *fakeConfigClient) Update(_ context.Context, update *types.ConfigUpdate) (*types.ConfigUpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if update == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "update must not be nil"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Compile-time check that fakeConfigClient implements v1.ConfigInterface. +var _ v1.ConfigInterface = (*fakeConfigClient)(nil) diff --git a/sdk/go/openshell/v1/fake/config_test.go b/sdk/go/openshell/v1/fake/config_test.go new file mode 100644 index 0000000000..9f86e739ba --- /dev/null +++ b/sdk/go/openshell/v1/fake/config_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T020: fakeConfigClient stub tests --- + +func TestFakeConfig_GetSandbox_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetSandbox(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetGateway_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetSandbox_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetSandbox(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_GetGateway_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T033: MergeOperations acceptance test --- + +func TestFakeConfig_Update_MergeOperationsAccepted(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + }) + require.Error(t, err) + // Should return Unimplemented (not InvalidArgument) — MergeOperations are now accepted + assert.True(t, types.IsUnimplemented(err)) + assert.False(t, types.IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/fake/doc.go b/sdk/go/openshell/v1/fake/doc.go new file mode 100644 index 0000000000..2b17dfcd63 --- /dev/null +++ b/sdk/go/openshell/v1/fake/doc.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package fake provides an in-memory fake implementation of the OpenShell SDK +// client interfaces for use in consumer test suites. +// +// The fake client follows the client-go/kubernetes/fake pattern: it maintains +// in-memory stores for sandboxes and providers, supports watch event broadcasting, +// and returns the same StatusError codes as the real client for equivalent error +// conditions (NotFound, AlreadyExists, Unavailable, Unimplemented). +// +// All operations are safe for concurrent use from multiple goroutines. +// +// # Usage +// +// Create a FakeClient, exercise the sandbox lifecycle, and assert results: +// +// func TestSandboxLifecycle(t *testing.T) { +// client := fake.NewClient() +// defer client.Close() +// +// ctx := context.Background() +// +// // Create a sandbox — starts in Provisioning phase +// sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) +// require.NoError(t, err) +// assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) +// +// // Wait until ready — transitions synchronously in the fake +// sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") +// require.NoError(t, err) +// assert.Equal(t, types.SandboxReady, sb.Status.Phase) +// +// // Clean up +// require.NoError(t, client.Sandboxes().Delete(ctx, "my-sandbox")) +// } +package fake diff --git a/sdk/go/openshell/v1/fake/exec.go b/sdk/go/openshell/v1/fake/exec.go new file mode 100644 index 0000000000..d8a4627b53 --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.ExecInterface = (*fakeExecClient)(nil) + +// fakeExecClient implements v1.ExecInterface. All methods return +// Unimplemented because command execution requires a real sandbox runtime. +type fakeExecClient struct { + closedFunc func() bool +} + +// newFakeExecClient creates a new fakeExecClient. +func newFakeExecClient(closedFunc func() bool) *fakeExecClient { + return &fakeExecClient{closedFunc: closedFunc} +} + +// Run returns Unimplemented. +func (c *fakeExecClient) Run(_ context.Context, _ string, _ []string, _ ...v1.ExecOptions) (*types.ExecResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Run is not supported by the fake client"} +} + +// Stream returns Unimplemented. +func (c *fakeExecClient) Stream(_ context.Context, _ string, _ []string, _ ...v1.ExecOptions) (v1.ExecStream, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Stream is not supported by the fake client"} +} + +// Interactive returns Unimplemented. +func (c *fakeExecClient) Interactive(_ context.Context, _ string, _ []string, _, _ uint32, _ ...v1.ExecOptions) (v1.InteractiveSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Interactive is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/exec_test.go b/sdk/go/openshell/v1/fake/exec_test.go new file mode 100644 index 0000000000..028a4e09b4 --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T021: Exec stub tests --- + +func TestExec_Run_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Run(ctx, "sandbox-1", []string{"echo", "hello"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Stream_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "sandbox-1", []string{"tail", "-f", "/var/log/app.log"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Interactive_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Run_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Run(ctx, "sandbox-1", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Stream_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "sandbox-1", []string{"tail"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Interactive_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/fake.go b/sdk/go/openshell/v1/fake/fake.go new file mode 100644 index 0000000000..c29634e25b --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Client implements v1.ClientInterface with in-memory stores. It is +// designed for testing consumers of the OpenShell SDK without requiring a +// real gRPC connection. Create one with NewClient. +type Client struct { + sandboxStore *objectStore[*types.Sandbox] + providerStore *objectStore[*types.Provider] + 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 + + closeOnce sync.Once + closed bool + mu sync.RWMutex // guards closed flag +} + +// ClientOption configures a Client during construction. +type ClientOption func(*Client) + +// WithHealthResult returns an option that configures the health sub-client +// to return the given result instead of the default healthy response. +func WithHealthResult(r *types.HealthResult) ClientOption { + return func(fc *Client) { + fc.health = newFakeHealthClient(r, fc.isClosed) + } +} + +// NewClient creates a new Client with all sub-clients wired up. +// Options (e.g., WithHealthResult) are applied after the default setup. +func NewClient(opts ...ClientOption) *Client { + fc := &Client{ + sandboxStore: newobjectStore(sandboxName, copySandbox), + providerStore: newobjectStore(providerName, copyProvider), + sandboxBroadcaster: newWatchBroadcaster[*types.Sandbox](), + } + + fc.sandboxes = newFakeSandboxClient(fc.sandboxStore, fc.sandboxBroadcaster, fc.isClosed) + fc.providers = newFakeProviderClient(fc.providerStore, fc.isClosed) + fc.services = newFakeServiceClient(fc.isClosed) + fc.exec = newFakeExecClient(fc.isClosed) + fc.files = newFakeFileClient(fc.isClosed) + fc.health = newFakeHealthClient(nil, fc.isClosed) + fc.ssh = newFakeSSHClient(fc.isClosed) + fc.tcp = newFakeTCPClient(fc.isClosed) + fc.cfg = newFakeConfigClient(fc.isClosed) + fc.policy = newFakePolicyClient(fc.isClosed) + + for _, opt := range opts { + opt(fc) + } + + return fc +} + +// isClosed returns true if the client has been closed. This is passed to +// all sub-clients as the closedFunc parameter. +func (fc *Client) isClosed() bool { + fc.mu.RLock() + defer fc.mu.RUnlock() + return fc.closed +} + +// Sandboxes returns the sandbox sub-client. +func (fc *Client) Sandboxes() v1.SandboxInterface { return fc.sandboxes } + +// Providers returns the provider sub-client. +func (fc *Client) Providers() v1.ProviderInterface { return fc.providers } + +// Services returns the service sub-client. +func (fc *Client) Services() v1.ServiceInterface { return fc.services } + +// Exec returns the exec sub-client. +func (fc *Client) Exec() v1.ExecInterface { return fc.exec } + +// Files returns the file sub-client. +func (fc *Client) Files() v1.FileInterface { return fc.files } + +// Health returns the health sub-client. +func (fc *Client) Health() v1.HealthInterface { return fc.health } + +// SSH returns the SSH session sub-client. +func (fc *Client) SSH() v1.SSHInterface { return fc.ssh } + +// TCP returns the TCP port forwarding sub-client. +func (fc *Client) TCP() v1.TCPInterface { return fc.tcp } + +// Config returns the configuration sub-client. +func (fc *Client) Config() v1.ConfigInterface { return fc.cfg } + +// Policy returns the policy management sub-client. +func (fc *Client) Policy() v1.PolicyInterface { return fc.policy } + +// Close marks the client as closed, stops all active watchers, and causes +// subsequent sub-client calls to return Unavailable. Safe to call multiple +// times. +func (fc *Client) Close() error { + fc.closeOnce.Do(func() { + fc.mu.Lock() + fc.closed = true + fc.mu.Unlock() + + fc.sandboxBroadcaster.StopAll() + }) + return nil +} + +// AddSandbox inserts a sandbox directly into the store without triggering +// watch events. This is intended for pre-seeding test fixtures before the +// test begins. The sandbox is deep-copied on insert. +func (fc *Client) AddSandbox(sb *types.Sandbox) { + fc.sandboxStore.Insert(sb) +} + +// 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. +func (fc *Client) AddProvider(p *types.Provider) { + fc.providerStore.Insert(p) +} + +// Compile-time interface check. +var _ v1.ClientInterface = (*Client)(nil) diff --git a/sdk/go/openshell/v1/fake/fake_test.go b/sdk/go/openshell/v1/fake/fake_test.go new file mode 100644 index 0000000000..8be588119b --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T025: FakeClient Close tests --- + +func TestFakeClient_Close(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Close_Idempotent(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) + + err = fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Sandboxes_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Sandboxes().Create(ctx, "test", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Providers_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Providers().Create(ctx, &types.Provider{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Health_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Health().Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Exec_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Exec().Run(ctx, "sandbox", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Files_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + err := fc.Files().Upload(ctx, "sandbox", "/local", "/remote") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Watch_StoppedOnClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "") + require.NoError(t, err) + + _ = fc.Close() + + // Channel should be closed after FakeClient.Close + _, ok := <-w.ResultChan() + assert.False(t, ok, "watcher channel should be closed after FakeClient.Close") +} + +func TestFakeClient_WithHealthResult(t *testing.T) { + custom := &types.HealthResult{Healthy: false, Version: "broken"} + fc := NewClient(WithHealthResult(custom)) + ctx := context.Background() + + result, err := fc.Health().Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "broken", result.Version) +} + +func TestFakeClient_SubClients(t *testing.T) { + fc := NewClient() + + assert.NotNil(t, fc.Sandboxes()) + assert.NotNil(t, fc.Providers()) + assert.NotNil(t, fc.Exec()) + assert.NotNil(t, fc.Files()) + assert.NotNil(t, fc.Health()) +} + +// --- T013: Pre-seed tests --- + +func TestFakeClient_AddSandbox(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Spec: types.SandboxSpec{LogLevel: "debug"}, + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + } + + fc.AddSandbox(sb) + + got, err := fc.Sandboxes().Get(ctx, "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "pre-seeded", got.Name) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestFakeClient_AddSandbox_InList(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + fc.AddSandbox(&types.Sandbox{Name: "sb-1"}) + fc.AddSandbox(&types.Sandbox{Name: "sb-2"}) + + list, err := fc.Sandboxes().List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestFakeClient_AddSandbox_NoWatchEvents(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + fc.AddSandbox(&types.Sandbox{Name: "pre-seeded"}) + + // No event should be received — AddSandbox bypasses the broadcaster + select { + case ev := <-w.ResultChan(): + t.Fatalf("unexpected event: %v", ev) + default: + // Good — no event received + } +} + +func TestFakeClient_AddSandbox_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Labels: map[string]string{"env": "test"}, + } + fc.AddSandbox(sb) + + // Mutate the input + sb.Labels["env"] = "mutated" + + got, err := fc.Sandboxes().Get(ctx, "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) +} + +func TestFakeClient_AddProvider(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + fc.AddProvider(p) + + got, err := fc.Providers().Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestFakeClient_AddProvider_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + fc.AddProvider(p) + + // Mutate input + p.Spec.Config["model"] = "mutated" + + got, err := fc.Providers().Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} diff --git a/sdk/go/openshell/v1/fake/file.go b/sdk/go/openshell/v1/fake/file.go new file mode 100644 index 0000000000..1834ceb404 --- /dev/null +++ b/sdk/go/openshell/v1/fake/file.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.FileInterface = (*fakeFileClient)(nil) + +// fakeFileClient implements v1.FileInterface. All methods return +// Unimplemented because file transfer requires a real sandbox runtime. +type fakeFileClient struct { + closedFunc func() bool +} + +// newFakeFileClient creates a new fakeFileClient. +func newFakeFileClient(closedFunc func() bool) *fakeFileClient { + return &fakeFileClient{closedFunc: closedFunc} +} + +// Upload returns Unimplemented. +func (c *fakeFileClient) Upload(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Upload is not supported by the fake client"} +} + +// Download returns Unimplemented. +func (c *fakeFileClient) Download(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Download is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/file_test.go b/sdk/go/openshell/v1/fake/file_test.go new file mode 100644 index 0000000000..40cbcaa8b6 --- /dev/null +++ b/sdk/go/openshell/v1/fake/file_test.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T022: File stub tests --- + +func TestFile_Upload_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Upload(ctx, "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Download_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Download(ctx, "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Upload_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Upload(ctx, "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFile_Download_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Download(ctx, "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/health.go b/sdk/go/openshell/v1/fake/health.go new file mode 100644 index 0000000000..75bda3b3ce --- /dev/null +++ b/sdk/go/openshell/v1/fake/health.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeHealthClient implements v1.HealthInterface with a configurable +// health result. When no custom result is provided, Check returns a +// default healthy response. +type fakeHealthClient struct { + result *types.HealthResult + closedFunc func() bool +} + +// newFakeHealthClient creates a new fakeHealthClient. If result is nil, +// Check will return the default healthy response. +func newFakeHealthClient(result *types.HealthResult, closedFunc func() bool) *fakeHealthClient { + return &fakeHealthClient{ + result: result, + closedFunc: closedFunc, + } +} + +// Check returns the configured health result. If no custom result was +// provided, it returns {Healthy: true, Version: "fake"}. +func (c *fakeHealthClient) Check(_ context.Context) (*types.HealthResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if c.result != nil { + cp := *c.result + return &cp, nil + } + + return &types.HealthResult{ + Healthy: true, + Version: "fake", + }, nil +} diff --git a/sdk/go/openshell/v1/fake/health_test.go b/sdk/go/openshell/v1/fake/health_test.go new file mode 100644 index 0000000000..1fcdbc03e9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/health_test.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T017: Health check tests --- + +func TestHealth_DefaultHealthy(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "fake", result.Version) +} + +func TestHealth_ConfigurableResult(t *testing.T) { + custom := &types.HealthResult{ + Healthy: false, + Version: "v0.0.0-broken", + } + hc := newFakeHealthClient(custom, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "v0.0.0-broken", result.Version) +} + +func TestHealth_ClosedClient(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return true }) + ctx := context.Background() + + _, err := hc.Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go new file mode 100644 index 0000000000..df6a136901 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakePolicyClient implements v1.PolicyInterface. All methods return +// Unimplemented because policy management requires a real gateway. +type fakePolicyClient struct { + closedFunc func() bool +} + +// newFakePolicyClient creates a new fakePolicyClient. +func newFakePolicyClient(closedFunc func() bool) *fakePolicyClient { + return &fakePolicyClient{closedFunc: closedFunc} +} + +// GetDraft returns Unimplemented. +func (c *fakePolicyClient) GetDraft(_ context.Context, _ string, _ ...v1.GetDraftOption) (*types.DraftPolicy, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraft is not supported by the fake client"} +} + +// ApproveDraftChunk returns Unimplemented. +func (c *fakePolicyClient) ApproveDraftChunk(_ context.Context, _, _ string) (*types.ApproveResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveDraftChunk is not supported by the fake client"} +} + +// RejectDraftChunk returns Unimplemented. +func (c *fakePolicyClient) RejectDraftChunk(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "RejectDraftChunk is not supported by the fake client"} +} + +// ApproveAllDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ApproveAllDraftChunks(_ context.Context, _ string, _ ...v1.ApproveAllOption) (*types.ApproveAllResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveAllDraftChunks is not supported by the fake client"} +} + +// ClearDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ClearDraftChunks(_ context.Context, _ string) (*types.ClearResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ClearDraftChunks is not supported by the fake client"} +} + +// GetDraftHistory returns Unimplemented. +func (c *fakePolicyClient) GetDraftHistory(_ context.Context, _ string) ([]types.DraftHistoryEntry, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraftHistory is not supported by the fake client"} +} + +// GetStatus returns Unimplemented. +func (c *fakePolicyClient) GetStatus(_ context.Context, _ string, _ ...v1.GetStatusOption) (*types.PolicyStatusResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakePolicyClient) List(_ context.Context, _ string, _ ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// EditDraftChunk returns Unimplemented. +func (c *fakePolicyClient) EditDraftChunk(_ context.Context, _, _ string, _ *types.NetworkPolicyRule) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "EditDraftChunk is not supported by the fake client"} +} + +// UndoDraftChunk returns Unimplemented. +func (c *fakePolicyClient) UndoDraftChunk(_ context.Context, _, _ string) (*types.UndoResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "UndoDraftChunk is not supported by the fake client"} +} + +// Compile-time check that fakePolicyClient implements v1.PolicyInterface. +var _ v1.PolicyInterface = (*fakePolicyClient)(nil) diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go new file mode 100644 index 0000000000..20a247d9c0 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T031: fakePolicyClient stub tests --- + +func TestFakePolicy_GetDraft_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraft(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_RejectDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.RejectDraftChunk(context.Background(), "sb-1", "chunk-1", "bad rule") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveAllDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveAllDraftChunks(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ClearDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ClearDraftChunks(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetDraftHistory_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraftHistory(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_List_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.List(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_EditDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.EditDraftChunk(context.Background(), "sb-1", "chunk-1", &types.NetworkPolicyRule{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_UndoDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.UndoDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +// --- Closed client tests --- + +func TestFakePolicy_GetDraft_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.GetDraft(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.ApproveDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_RejectDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + err := c.RejectDraftChunk(context.Background(), "sb-1", "chunk-1", "reason") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/profile.go b/sdk/go/openshell/v1/fake/profile.go new file mode 100644 index 0000000000..34c7395096 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeProfileClient implements v1.ProfileInterface. All methods return +// Unimplemented because profile management requires a real server. +type fakeProfileClient struct { + closedFunc func() bool +} + +// newFakeProfileClient creates a new fakeProfileClient. +func newFakeProfileClient(closedFunc func() bool) *fakeProfileClient { + return &fakeProfileClient{closedFunc: closedFunc} +} + +// List returns Unimplemented. +func (c *fakeProfileClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeProfileClient) Get(_ context.Context, _ string) (*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// Import returns Unimplemented. +func (c *fakeProfileClient) Import(_ context.Context, _ []types.ProfileImportItem) (*types.ImportResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Import is not supported by the fake client"} +} + +// Update returns Unimplemented. +func (c *fakeProfileClient) Update(_ context.Context, _ string, _ uint64, _ types.ProfileImportItem) (*types.UpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Lint returns Unimplemented. +func (c *fakeProfileClient) Lint(_ context.Context, _ []types.ProfileImportItem) (*types.LintResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Lint is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeProfileClient) Delete(_ context.Context, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeProfileClient implements v1.ProfileInterface. +var _ v1.ProfileInterface = (*fakeProfileClient)(nil) diff --git a/sdk/go/openshell/v1/fake/profile_test.go b/sdk/go/openshell/v1/fake/profile_test.go new file mode 100644 index 0000000000..9dddc175f9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T027: fakeProfileClient stub tests --- + +func TestFakeProfile_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.List(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Get(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Import_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Import(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Update(context.Background(), "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Lint_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Lint(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.List(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Get(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Import_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Import(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Update(context.Background(), "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Lint_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Lint(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/provider.go b/sdk/go/openshell/v1/fake/provider.go new file mode 100644 index 0000000000..042570ca25 --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider.go @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// providerName extracts the name from a Provider pointer for use as the +// objectStore key function. +func providerName(p *types.Provider) string { + return p.Name +} + +// copyProvider returns a deep copy of a Provider pointer. All maps are +// duplicated to prevent aliasing. +func copyProvider(p *types.Provider) *types.Provider { + if p == nil { + return nil + } + cp := *p + cp.Labels = copyStringMap(p.Labels) + cp.Spec = copyProviderSpec(p.Spec) + return &cp +} + +func copyProviderSpec(s types.ProviderSpec) types.ProviderSpec { + s.Credentials = copyStringMap(s.Credentials) + s.Config = copyStringMap(s.Config) + s.CredentialExpiresAt = copyTimeMap(s.CredentialExpiresAt) + return s +} + +// copyTimeMap returns a shallow copy of a string-to-time.Time map. +func copyTimeMap(m map[string]time.Time) map[string]time.Time { + if m == nil { + return nil + } + cp := make(map[string]time.Time, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// fakeProviderClient implements v1.ProviderInterface backed by an in-memory +// objectStore. +type fakeProviderClient struct { + store *objectStore[*types.Provider] + closedFunc func() bool + profiles *fakeProfileClient + refresh *fakeRefreshClient +} + +// newFakeProviderClient creates a new fakeProviderClient. +func newFakeProviderClient( + store *objectStore[*types.Provider], + closedFunc func() bool, +) *fakeProviderClient { + return &fakeProviderClient{ + store: store, + closedFunc: closedFunc, + profiles: newFakeProfileClient(closedFunc), + refresh: newFakeRefreshClient(closedFunc), + } +} + +// Profiles returns a sub-client for provider profile operations. +func (c *fakeProviderClient) Profiles() v1.ProfileInterface { + return c.profiles +} + +// Refresh returns a sub-client for credential refresh operations. +func (c *fakeProviderClient) Refresh() v1.RefreshInterface { + return c.refresh +} + +// Create adds a new provider. CreatedAt and ResourceVersion are set +// automatically. +func (c *fakeProviderClient) Create(_ context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + p := copyProvider(provider) + p.CreatedAt = time.Now() + p.ResourceVersion = 1 + + return c.store.Create(p) +} + +// Get retrieves a provider by name. +func (c *fakeProviderClient) Get(_ context.Context, name string) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(name) +} + +// List returns all providers. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeProviderClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.List(), nil +} + +// Update replaces an existing provider's data. ResourceVersion is +// incremented automatically. +func (c *fakeProviderClient) Update(_ context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Fetch existing to preserve CreatedAt and increment ResourceVersion + existing, err := c.store.Get(provider.Name) + if err != nil { + return nil, err + } + + p := copyProvider(provider) + p.CreatedAt = existing.CreatedAt + p.ResourceVersion = existing.ResourceVersion + 1 + + return c.store.Update(p) +} + +// Delete removes a provider by name. The operation is idempotent. +func (c *fakeProviderClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + c.store.Delete(name) + return nil +} + +// Ensure creates a provider if it does not exist, or updates it if it does. +func (c *fakeProviderClient) Ensure(ctx context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + _, err := c.store.Get(provider.Name) + if err != nil { + // Not found — create + if types.IsNotFound(err) { + return c.Create(ctx, provider) + } + return nil, err + } + // Exists — update + return c.Update(ctx, provider) +} diff --git a/sdk/go/openshell/v1/fake/provider_test.go b/sdk/go/openshell/v1/fake/provider_test.go new file mode 100644 index 0000000000..b02ab36388 --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider_test.go @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake provider client for testing. +func newTestProviderClient() *fakeProviderClient { + store := newobjectStore(providerName, copyProvider) + return newFakeProviderClient(store, func() bool { return false }) +} + +// --- T015: Provider CRUD tests --- + +func TestProvider_Create(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{ + Credentials: map[string]string{"api_key": "sk-test"}, + Config: map[string]string{"model": "gpt-4"}, + }, + } + + result, err := pc.Create(ctx, p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "openai", result.Type) + assert.Equal(t, "sk-test", result.Spec.Credentials["api_key"]) + assert.NotZero(t, result.CreatedAt) + assert.Equal(t, uint64(1), result.ResourceVersion) +} + +func TestProvider_Create_AlreadyExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{Name: "openai", Type: "openai"} + _, err := pc.Create(ctx, p) + require.NoError(t, err) + + _, err = pc.Create(ctx, p) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestProvider_Get(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai", Type: "openai"}) + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) +} + +func TestProvider_Get_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Get(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_List_Empty(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + list, err := pc.List(ctx) + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestProvider_List(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai", Type: "openai"}) + _, _ = pc.Create(ctx, &types.Provider{Name: "anthropic", Type: "anthropic"}) + + list, err := pc.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestProvider_Update(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + updated, err := pc.Update(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", updated.Spec.Config["model"]) + assert.Equal(t, uint64(2), updated.ResourceVersion) +} + +func TestProvider_Update_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Update(ctx, &types.Provider{Name: "nonexistent"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai"}) + + err := pc.Delete(ctx, "openai") + require.NoError(t, err) + + _, err = pc.Get(ctx, "openai") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete_Idempotent(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + err := pc.Delete(ctx, "nonexistent") + require.NoError(t, err) +} + +func TestProvider_Ensure_CreatesIfMissing(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + result, err := pc.Ensure(ctx, p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) + + // Verify it was stored + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestProvider_Ensure_UpdatesIfExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + result, err := pc.Ensure(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) +} + +func TestProvider_DeepCopy_OnCreate(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + spec := types.ProviderSpec{ + Credentials: map[string]string{"key": "secret"}, + Config: map[string]string{"model": "gpt-4"}, + CredentialExpiresAt: map[string]time.Time{"key": time.Now()}, + } + p := &types.Provider{ + Name: "openai", + Labels: map[string]string{"env": "test"}, + Spec: spec, + } + + result, err := pc.Create(ctx, p) + require.NoError(t, err) + + // Mutate inputs + p.Labels["env"] = "mutated" + p.Spec.Credentials["key"] = "mutated" + p.Spec.Config["model"] = "mutated" + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "secret", got.Spec.Credentials["key"]) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) + + // Mutate returned object + result.Labels["env"] = "mutated-return" + got2, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestProvider_DeepCopy_OnGet(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + + got.Spec.Config["model"] = "mutated" + + got2, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got2.Spec.Config["model"]) +} + +// --- T020: Concurrent provider access tests --- + +func TestProvider_ConcurrentCreateGetListDeleteEnsure(_ *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("prov-%d-%d", id, j) + p := &types.Provider{Name: name, Type: "test"} + _, _ = pc.Create(ctx, p) + _, _ = pc.Get(ctx, name) + _, _ = pc.List(ctx) + _, _ = pc.Update(ctx, &types.Provider{Name: name, Type: "updated"}) + _, _ = pc.Ensure(ctx, &types.Provider{Name: name, Type: "ensured"}) + _ = pc.Delete(ctx, name) + } + }(i) + } + wg.Wait() +} diff --git a/sdk/go/openshell/v1/fake/refresh.go b/sdk/go/openshell/v1/fake/refresh.go new file mode 100644 index 0000000000..6f1dedd1ef --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeRefreshClient implements v1.RefreshInterface. All methods return +// Unimplemented because credential refresh requires a real server. +type fakeRefreshClient struct { + closedFunc func() bool +} + +// newFakeRefreshClient creates a new fakeRefreshClient. +func newFakeRefreshClient(closedFunc func() bool) *fakeRefreshClient { + return &fakeRefreshClient{closedFunc: closedFunc} +} + +// GetStatus returns Unimplemented. +func (c *fakeRefreshClient) GetStatus(_ context.Context, _, _ string) ([]*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// Configure returns Unimplemented. +func (c *fakeRefreshClient) Configure(_ context.Context, _ *types.RefreshConfig) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Configure is not supported by the fake client"} +} + +// Rotate returns Unimplemented. +func (c *fakeRefreshClient) Rotate(_ context.Context, _, _ string) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Rotate is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeRefreshClient) Delete(_ context.Context, _, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeRefreshClient implements v1.RefreshInterface. +var _ v1.RefreshInterface = (*fakeRefreshClient)(nil) diff --git a/sdk/go/openshell/v1/fake/refresh_test.go b/sdk/go/openshell/v1/fake/refresh_test.go new file mode 100644 index 0000000000..bc131816d3 --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T028: fakeRefreshClient stub tests --- + +func TestFakeRefresh_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Configure_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Configure(context.Background(), &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Rotate_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Rotate(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_GetStatus_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.GetStatus(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Configure_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Configure(context.Background(), &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Rotate_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Rotate(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go new file mode 100644 index 0000000000..0e1a97c138 --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -0,0 +1,504 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// sandboxName extracts the name from a Sandbox pointer for use as the +// objectStore key function. +func sandboxName(sb *types.Sandbox) string { + return sb.Name +} + +// copySandbox returns a deep copy of a Sandbox pointer. All maps, slices, +// and nested pointer fields are duplicated to prevent aliasing. +func copySandbox(sb *types.Sandbox) *types.Sandbox { + if sb == nil { + return nil + } + cp := *sb + cp.Labels = copyStringMap(sb.Labels) + cp.Spec = copySandboxSpec(sb.Spec) + cp.Status = copySandboxStatus(sb.Status) + return &cp +} + +func copySandboxSpec(s types.SandboxSpec) types.SandboxSpec { + s.Environment = copyStringMap(s.Environment) + s.Providers = copyStringSlice(s.Providers) + if s.Template != nil { + t := copySandboxTemplate(*s.Template) + s.Template = &t + } + if s.GPUCount != nil { + v := *s.GPUCount + s.GPUCount = &v + } + s.Policy = copySandboxPolicy(s.Policy) + return s +} + +// copySandboxPolicy returns a deep copy of a SandboxPolicy pointer. +// All sub-policies, slices, and map entries are duplicated. +func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { + if p == nil { + return nil + } + cp := *p + if p.Filesystem != nil { + fs := *p.Filesystem + fs.ReadOnly = copyStringSlice(p.Filesystem.ReadOnly) + fs.ReadWrite = copyStringSlice(p.Filesystem.ReadWrite) + cp.Filesystem = &fs + } + if p.Landlock != nil { + ll := *p.Landlock + cp.Landlock = &ll + } + if p.Process != nil { + pr := *p.Process + cp.Process = &pr + } + if p.NetworkPolicies != nil { + np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, rule := range p.NetworkPolicies { + r := rule + if rule.Endpoints != nil { + eps := make([]types.PolicyNetworkEndpoint, len(rule.Endpoints)) + for i, ep := range rule.Endpoints { + eps[i] = copyPolicyNetworkEndpoint(ep) + } + r.Endpoints = eps + } + if rule.Binaries != nil { + bins := make([]types.PolicyNetworkBinary, len(rule.Binaries)) + copy(bins, rule.Binaries) + r.Binaries = bins + } + np[k] = r + } + cp.NetworkPolicies = np + } + return &cp +} + +func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetworkEndpoint { + if ep.Ports != nil { + ports := make([]uint32, len(ep.Ports)) + copy(ports, ep.Ports) + ep.Ports = ports + } + if ep.Rules != nil { + rules := make([]types.L7Rule, len(ep.Rules)) + for i, r := range ep.Rules { + if r.Allow != nil { + a := *r.Allow + a.Query = copyL7QueryMap(r.Allow.Query) + a.Fields = copyStringSlice(r.Allow.Fields) + rules[i] = types.L7Rule{Allow: &a} + } + } + ep.Rules = rules + } + ep.AllowedIPs = copyStringSlice(ep.AllowedIPs) + if ep.DenyRules != nil { + drs := make([]types.L7DenyRule, len(ep.DenyRules)) + for i, dr := range ep.DenyRules { + dr.Query = copyL7QueryMap(dr.Query) + dr.Fields = copyStringSlice(dr.Fields) + drs[i] = dr + } + ep.DenyRules = drs + } + if ep.GraphqlPersistedQueries != nil { + gq := make(map[string]types.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + for k, v := range ep.GraphqlPersistedQueries { + v.Fields = copyStringSlice(v.Fields) + gq[k] = v + } + ep.GraphqlPersistedQueries = gq + } + return ep +} + +func copyL7QueryMap(m map[string]types.L7QueryMatcher) map[string]types.L7QueryMatcher { + if m == nil { + return nil + } + cp := make(map[string]types.L7QueryMatcher, len(m)) + for k, v := range m { + v.Any = copyStringSlice(v.Any) + cp[k] = v + } + return cp +} + +func copySandboxTemplate(t types.SandboxTemplate) types.SandboxTemplate { + t.Labels = copyStringMap(t.Labels) + t.Annotations = copyStringMap(t.Annotations) + t.Environment = copyStringMap(t.Environment) + if t.UserNamespaces != nil { + v := *t.UserNamespaces + t.UserNamespaces = &v + } + return t +} + +func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus { + if s.Conditions != nil { + conds := make([]types.SandboxCondition, len(s.Conditions)) + copy(conds, s.Conditions) + s.Conditions = conds + } + return s +} + +// copyStringMap returns a shallow copy of a string-to-string map. +func copyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + cp := make(map[string]string, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// copyStringSlice returns a copy of a string slice. +func copyStringSlice(s []string) []string { + if s == nil { + return nil + } + cp := make([]string, len(s)) + copy(cp, s) + return cp +} + +// 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 +} + +// newFakeSandboxClient creates a new fakeSandboxClient. +func newFakeSandboxClient( + store *objectStore[*types.Sandbox], + broadcaster *watchBroadcaster[*types.Sandbox], + closedFunc func() bool, +) *fakeSandboxClient { + return &fakeSandboxClient{ + store: store, + broadcaster: broadcaster, + closedFunc: closedFunc, + } +} + +// Create creates a new sandbox with Provisioning phase. +func (c *fakeSandboxClient) Create(_ context.Context, name string, spec *types.SandboxSpec, labels map[string]string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if spec == nil { + spec = &types.SandboxSpec{} + } + + sb := &types.Sandbox{ + Name: name, + CreatedAt: time.Now(), + Labels: copyStringMap(labels), + ResourceVersion: 1, + Spec: copySandboxSpec(*spec), + Status: types.SandboxStatus{ + SandboxName: name, + Phase: types.SandboxProvisioning, + }, + } + + result, err := c.store.Create(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventAdded, + Object: copySandbox(result), + }, name) + + return result, nil +} + +// Get retrieves a sandbox by name. +func (c *fakeSandboxClient) Get(_ context.Context, name string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(name) +} + +// List returns all sandboxes. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeSandboxClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.List(), nil +} + +// Delete removes a sandbox by name. The operation is idempotent. +func (c *fakeSandboxClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Atomically remove and retrieve the last-known object for the DELETED event. + deleted, existed := c.store.DeleteAndGet(name) + if !existed { + // Not found — idempotent delete + return nil + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventDeleted, + Object: deleted, + }, name) + + return nil +} + +// WaitReady transitions a sandbox to the Ready phase. In the fake +// implementation this happens synchronously — context cancellation is +// checked first to support timeout testing. +func (c *fakeSandboxClient) WaitReady(ctx context.Context, name string, _ ...v1.WaitOptions) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Check context before proceeding + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + sb, err := c.store.Get(name) + if err != nil { + return nil, err + } + + // If already ready, return immediately + if sb.Status.Phase == types.SandboxReady { + return sb, nil + } + + // Transition to Ready + sb.Status.Phase = types.SandboxReady + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, fmt.Errorf("updating sandbox phase: %w", err) + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, name) + + return updated, nil +} + +// 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). +func (c *fakeSandboxClient) Watch(_ 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"} + } + + inner := c.broadcaster.Watch(name) + + var stopOnTerminal bool + if len(opts) > 0 { + stopOnTerminal = opts[0].StopOnTerminal + } + + if !stopOnTerminal { + return inner, nil + } + + // Wrap with a filtering watcher that auto-stops after terminal events. + out := make(chan types.Event[*types.Sandbox], watchChannelBuffer) + tw := &terminalWatcher{ + ch: out, + inner: inner, + } + go func() { + defer close(out) + for ev := range inner.ResultChan() { + select { + case out <- ev: + default: + } + if ev.Object != nil && + (ev.Object.Status.Phase == types.SandboxReady || ev.Object.Status.Phase == types.SandboxError) { + inner.Stop() + return + } + } + }() + return tw, nil +} + +// terminalWatcher wraps an inner watcher and exposes its own output channel. +type terminalWatcher struct { + ch chan types.Event[*types.Sandbox] + inner types.WatchInterface[*types.Sandbox] + once sync.Once +} + +func (w *terminalWatcher) ResultChan() <-chan types.Event[*types.Sandbox] { + return w.ch +} + +func (w *terminalWatcher) Stop() { + w.once.Do(func() { + w.inner.Stop() + }) +} + +// AttachProvider adds a provider name to the sandbox's Spec.Providers list. +// If the provider is already attached, Attached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast. +func (c *fakeSandboxClient) AttachProvider(_ context.Context, sandboxName, providerName string, _ uint64) (*types.AttachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + // Check if already attached + for _, p := range sb.Spec.Providers { + if p == providerName { + return &types.AttachProviderResult{ + Sandbox: sb, + Attached: false, + }, nil + } + } + + sb.Spec.Providers = append(sb.Spec.Providers, providerName) + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.AttachProviderResult{ + Sandbox: updated, + Attached: true, + }, nil +} + +// DetachProvider removes a provider name from the sandbox's Spec.Providers +// list. If the provider is not attached, Detached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast when a provider is actually removed. +func (c *fakeSandboxClient) DetachProvider(_ context.Context, sandboxName, providerName string, _ uint64) (*types.DetachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + // Find and remove the provider + found := false + providers := make([]string, 0, len(sb.Spec.Providers)) + for _, p := range sb.Spec.Providers { + if p == providerName { + found = true + continue + } + providers = append(providers, p) + } + + if !found { + return &types.DetachProviderResult{ + Sandbox: sb, + Detached: false, + }, nil + } + + sb.Spec.Providers = providers + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.DetachProviderResult{ + Sandbox: updated, + Detached: true, + }, nil +} + +// GetLogs returns Unimplemented — fake log retrieval is not yet supported. +func (c *fakeSandboxClient) GetLogs(_ context.Context, _ string, _ ...v1.LogOption) (*types.LogResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetLogs not implemented in fake client"} +} + +// ListProviders returns stub Provider objects for each provider name +// attached to the sandbox. The returned providers contain only the Name +// field, since the fake client does not maintain a full provider registry +// per sandbox. +func (c *fakeSandboxClient) ListProviders(_ context.Context, sandboxName string) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + result := make([]*types.Provider, len(sb.Spec.Providers)) + for i, name := range sb.Spec.Providers { + result[i] = &types.Provider{Name: name} + } + return result, nil +} diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go new file mode 100644 index 0000000000..635455067a --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -0,0 +1,814 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake sandbox client for testing. +func newTestSandboxClient() *fakeSandboxClient { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + return newFakeSandboxClient(store, broadcaster, func() bool { return false }) +} + +// --- T008: Sandbox CRUD tests --- + +func TestSandbox_Create(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) + assert.Equal(t, "debug", sb.Spec.LogLevel) + assert.Equal(t, "test", sb.Labels["env"]) + assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + assert.NotZero(t, sb.CreatedAt) + assert.Equal(t, uint64(1), sb.ResourceVersion) +} + +func TestSandbox_Create_AlreadyExists(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + _, err = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestSandbox_Create_NilSpec(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", nil, nil) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) +} + +func TestSandbox_Get(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test-sb", got.Name) + assert.Equal(t, "info", got.Spec.LogLevel) +} + +func TestSandbox_Get_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Get(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_List_Empty(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + list, err := sc.List(ctx) + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestSandbox_List(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "sb-1", &types.SandboxSpec{}, nil) + _, _ = sc.Create(ctx, "sb-2", &types.SandboxSpec{}, nil) + + list, err := sc.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestSandbox_Delete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + err := sc.Delete(ctx, "test-sb") + require.NoError(t, err) + + _, err = sc.Get(ctx, "test-sb") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_Delete_Idempotent(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Delete non-existent sandbox should not error + err := sc.Delete(ctx, "nonexistent") + require.NoError(t, err) +} + +func TestSandbox_DeepCopy_OnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + labels := map[string]string{"env": "test"} + spec := &types.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"KEY": "value"}, + } + + sb, err := sc.Create(ctx, "test-sb", spec, labels) + require.NoError(t, err) + + // Mutating inputs should not affect stored object + labels["env"] = "mutated" + spec.LogLevel = "mutated" + spec.Environment["KEY"] = "mutated" + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, "value", got.Spec.Environment["KEY"]) + + // Mutating returned object should not affect stored object + sb.Labels["env"] = "mutated-return" + got2, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestSandbox_DeepCopy_OnGet(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{ + Environment: map[string]string{"KEY": "value"}, + }, nil) + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + + got.Spec.Environment["KEY"] = "mutated" + + got2, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "value", got2.Spec.Environment["KEY"]) +} + +// --- T009: WaitReady tests --- + +func TestSandbox_WaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + sb, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) + + // Verify the store is also updated + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestSandbox_WaitReady_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.WaitReady(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_WaitReady_ContextCancellation(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err = sc.WaitReady(ctx, "test-sb") + require.Error(t, err) + // Should return a context error, not a status error + assert.ErrorIs(t, err, context.Canceled) +} + +func TestSandbox_WaitReady_AlreadyReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + // Make it ready + _, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // WaitReady on an already-ready sandbox should return immediately + sb, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) +} + +func TestSandbox_WaitReady_IncrementsResourceVersion(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + initialVersion := created.ResourceVersion + + ready, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Greater(t, ready.ResourceVersion, initialVersion) +} + +func TestSandbox_WaitReady_ContextTimeout(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + // Override the sandbox phase to Error so WaitReady doesn't auto-transition + // Actually, with our simple fake, WaitReady transitions immediately unless context is done. + // So just test the context-cancelled path: + cancel() + _, err = sc.WaitReady(ctx, "test-sb") + require.Error(t, err) +} + +// --- T011: Watch tests --- + +func TestSandbox_Watch_AddedOnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + assert.Equal(t, "info", ev.Object.Spec.LogLevel) + case <-time.After(time.Second): + t.Fatal("timed out waiting for ADDED event") + } +} + +func TestSandbox_Watch_DeletedOnDelete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + err = sc.Delete(ctx, "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +func TestSandbox_Watch_ModifiedOnWaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event") + } +} + +func TestSandbox_Watch_NameFiltering(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Watch only "alpha" + w, err := sc.Watch(ctx, "alpha") + require.NoError(t, err) + defer w.Stop() + + // Create "beta" — should not be received + _, _ = sc.Create(ctx, "beta", &types.SandboxSpec{}, nil) + + // Create "alpha" — should be received + _, _ = sc.Create(ctx, "alpha", &types.SandboxSpec{}, nil) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered event") + } +} + +func TestSandbox_Watch_MultipleWatchers(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w1, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w1.Stop() + + w2, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w2.Stop() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + for _, w := range []types.WatchInterface[*types.Sandbox]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on watcher") + } + } +} + +func TestSandbox_Watch_StopClosesChannel(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + + w.Stop() + + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestSandbox_Watch_DeletedEventContainsFullObject(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _ = sc.Delete(ctx, "test-sb") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + // Verify the DELETED event contains the full last-known object + assert.Equal(t, "debug", ev.Object.Spec.LogLevel) + assert.Equal(t, "test", ev.Object.Labels["env"]) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +// --- T019: Concurrent sandbox access tests --- + +func TestSandbox_ConcurrentCreateGetDeleteWatch(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + // Start a watcher to exercise broadcast under concurrency + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + // Drain watcher events in a background goroutine + done := make(chan struct{}) + go func() { + defer close(done) + for range w.ResultChan() { //nolint:revive // intentionally draining channel + } + }() + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("sb-%d-%d", id, j) + _, _ = sc.Create(ctx, name, &types.SandboxSpec{LogLevel: "info"}, nil) + _, _ = sc.Get(ctx, name) + _, _ = sc.List(ctx) + _, _ = sc.WaitReady(ctx, name) + _ = sc.Delete(ctx, name) + } + }(i) + } + wg.Wait() + + // Stop watcher and wait for drain goroutine + w.Stop() + <-done +} + +// --- T026: AttachProvider / DetachProvider / ListProviders tests --- + +func TestSandbox_AttachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + assert.Equal(t, "test-sb", result.Sandbox.Name) + assert.Contains(t, result.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_AttachProvider_AlreadyAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + + // Attach again — should return Attached=false (idempotent, already attached) + result2, err := sc.AttachProvider(ctx, "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.False(t, result2.Attached) +} + +func TestSandbox_AttachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.AttachProvider(ctx, "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_DetachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + detach, err := sc.DetachProvider(ctx, "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.True(t, detach.Detached) + assert.NotContains(t, detach.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_DetachProvider_NotAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.DetachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.False(t, result.Detached) +} + +func TestSandbox_DetachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.DetachProvider(ctx, "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_ListProviders(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // No providers yet + providers, err := sc.ListProviders(ctx, "test-sb") + require.NoError(t, err) + assert.Empty(t, providers) + + // Attach two providers + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + _, err = sc.AttachProvider(ctx, "test-sb", "anthropic", result.Sandbox.ResourceVersion) + require.NoError(t, err) + + providers, err = sc.ListProviders(ctx, "test-sb") + require.NoError(t, err) + assert.Len(t, providers, 2) + + names := make([]string, len(providers)) + for i, p := range providers { + names[i] = p.Name + } + assert.Contains(t, names, "openai") + assert.Contains(t, names, "anthropic") +} + +func TestSandbox_ListProviders_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.ListProviders(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_AttachProvider_BroadcastsModified(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Contains(t, ev.Object.Spec.Providers, "openai") + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event from AttachProvider") + } +} + +// --- T033: StopOnTerminal tests for fake Watch --- + +func TestSandbox_Watch_StopOnTerminal_Ready(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Transition to Ready — this broadcasts a MODIFIED event with SandboxReady phase + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // Should receive the Ready event + var gotReady bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxReady { + gotReady = true + } + } + // Channel should be closed after the terminal event + assert.True(t, gotReady, "expected to receive a Ready event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_Error(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Manually transition to Error phase via store update + broadcast + sb.Status.Phase = types.SandboxError + sb.ResourceVersion++ + updated, err := sc.store.Update(sb) + require.NoError(t, err) + sc.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, "test-sb") + + // Should receive the Error event and then the channel closes + var gotError bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxError { + gotError = true + } + } + assert.True(t, gotError, "expected to receive an Error event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_False_DoesNotClose(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // Watch WITHOUT StopOnTerminal + w, err := sc.Watch(ctx, "test-sb") + require.NoError(t, err) + defer w.Stop() + + // Transition to Ready + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // Receive the Ready event + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for Ready event") + } + + // Channel should still be open — verify by checking no close + select { + case _, ok := <-w.ResultChan(): + if !ok { + t.Fatal("channel closed unexpectedly when StopOnTerminal was not set") + } + // Got another event, that's fine + case <-time.After(100 * time.Millisecond): + // No event and not closed — correct behavior + } +} + +// --- T016: Sandbox Create with Policy --- + +func TestFakeSandboxCreateWithPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + spec := &types.SandboxSpec{ + LogLevel: "debug", + Policy: &types.SandboxPolicy{ + Version: 3, + Filesystem: &types.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp"}, + }, + Landlock: &types.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &types.ProcessPolicy{ + RunAsUser: "sandbox", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "web": { + Name: "web", + Endpoints: []types.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + }, + }, + } + + created, err := sc.Create(ctx, "policy-sb", spec, nil) + require.NoError(t, err) + + // Verify created sandbox has policy + require.NotNil(t, created.Spec.Policy) + assert.Equal(t, uint32(3), created.Spec.Policy.Version) + + // Get it back and verify all fields + got, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + require.NotNil(t, got.Spec.Policy) + + p := got.Spec.Policy + assert.Equal(t, uint32(3), p.Version) + + require.NotNil(t, p.Filesystem) + assert.True(t, p.Filesystem.IncludeWorkdir) + assert.Equal(t, []string{"/etc", "/usr/share"}, p.Filesystem.ReadOnly) + assert.Equal(t, []string{"/tmp"}, p.Filesystem.ReadWrite) + + require.NotNil(t, p.Landlock) + assert.Equal(t, "best_effort", p.Landlock.Compatibility) + + require.NotNil(t, p.Process) + assert.Equal(t, "sandbox", p.Process.RunAsUser) + assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + + require.Len(t, p.NetworkPolicies, 1) + webRule, ok := p.NetworkPolicies["web"] + require.True(t, ok) + assert.Equal(t, "web", webRule.Name) + require.Len(t, webRule.Endpoints, 1) + assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) + assert.Equal(t, uint32(443), webRule.Endpoints[0].Port) + + // Deep-copy isolation: mutate input spec, verify stored copy unchanged + spec.Policy.Version = 99 + spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} + + got2, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + assert.Equal(t, uint32(3), got2.Spec.Policy.Version) + assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) + + // Deep-copy isolation: mutate returned object, verify store unchanged + got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got3, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) +} + +func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "no-policy-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + assert.Nil(t, created.Spec.Policy) + + got, err := sc.Get(ctx, "no-policy-sb") + require.NoError(t, err) + assert.Nil(t, got.Spec.Policy) +} + +// --- T032: GetLogs stub tests --- + +func TestSandbox_GetLogs_ReturnsUnimplemented(t *testing.T) { + sc := newTestSandboxClient() + _, err := sc.GetLogs(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestSandbox_GetLogs_ClosedReturnsUnavailable(t *testing.T) { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + sc := newFakeSandboxClient(store, broadcaster, func() bool { return true }) + _, err := sc.GetLogs(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/service.go b/sdk/go/openshell/v1/fake/service.go new file mode 100644 index 0000000000..3df23520cc --- /dev/null +++ b/sdk/go/openshell/v1/fake/service.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeServiceClient implements v1.ServiceInterface. All methods return +// Unimplemented because service exposure requires a real sandbox runtime. +type fakeServiceClient struct { + closedFunc func() bool +} + +// newFakeServiceClient creates a new fakeServiceClient. +func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient { + return &fakeServiceClient{closedFunc: closedFunc} +} + +// Expose returns Unimplemented. +func (c *fakeServiceClient) Expose(_ context.Context, _, _ string, _ uint32, _ bool) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Expose is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeServiceClient) Get(_ context.Context, _, _ string) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakeServiceClient) List(_ context.Context, _ string, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeServiceClient) Delete(_ context.Context, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeServiceClient implements v1.ServiceInterface. +var _ v1.ServiceInterface = (*fakeServiceClient)(nil) diff --git a/sdk/go/openshell/v1/fake/service_test.go b/sdk/go/openshell/v1/fake/service_test.go new file mode 100644 index 0000000000..9744ce5cb1 --- /dev/null +++ b/sdk/go/openshell/v1/fake/service_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T026: fakeServiceClient stub tests --- + +func TestFakeService_Expose_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Expose(context.Background(), "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Get(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.List(context.Background(), "sb1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + err := c.Delete(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Expose_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Expose(context.Background(), "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Get(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.List(context.Background(), "sb1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + err := c.Delete(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/ssh.go b/sdk/go/openshell/v1/fake/ssh.go new file mode 100644 index 0000000000..654a5ab102 --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeSSHClient implements v1.SSHInterface. All methods return +// Unimplemented because SSH session management requires a real gateway. +type fakeSSHClient struct { + closedFunc func() bool +} + +// newFakeSSHClient creates a new fakeSSHClient. +func newFakeSSHClient(closedFunc func() bool) *fakeSSHClient { + return &fakeSSHClient{closedFunc: closedFunc} +} + +// CreateSession returns Unimplemented. +func (c *fakeSSHClient) CreateSession(_ context.Context, _ string) (*types.SSHSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "CreateSession is not supported by the fake client"} +} + +// RevokeSession returns Unimplemented. +func (c *fakeSSHClient) RevokeSession(_ context.Context, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "RevokeSession is not supported by the fake client"} +} + +// Tunnel returns Unimplemented. Ports outside 1-65535 and empty sandbox names +// are rejected with InvalidArgument to match the real client's behavior. +func (c *fakeSSHClient) Tunnel(_ context.Context, sandboxName string, port uint32, _ ...v1.TunnelOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Tunnel is not supported by the fake client"} +} + +// Compile-time check that fakeSSHClient implements v1.SSHInterface. +var _ v1.SSHInterface = (*fakeSSHClient)(nil) diff --git a/sdk/go/openshell/v1/fake/ssh_test.go b/sdk/go/openshell/v1/fake/ssh_test.go new file mode 100644 index 0000000000..fd18b84ffe --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T018: fakeSSHClient stub tests --- + +func TestFakeSSH_CreateSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.CreateSession(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_RevokeSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.RevokeSession(context.Background(), "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_CreateSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.CreateSession(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeSSH_RevokeSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.RevokeSession(context.Background(), "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T014: Fake Tunnel tests --- + +func TestFakeSSH_Tunnel_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_WithTunnelOption(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22, v1.WithTunnelServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_InvalidPort(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + + _, err := c.Tunnel(context.Background(), "my-sandbox", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + + _, err = c.Tunnel(context.Background(), "my-sandbox", 65536) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_EmptySandboxName(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "", 22) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/store.go b/sdk/go/openshell/v1/fake/store.go new file mode 100644 index 0000000000..e5c64c6441 --- /dev/null +++ b/sdk/go/openshell/v1/fake/store.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "fmt" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// objectStore is a generic, thread-safe, in-memory store for named objects. +// It deep-copies objects at all boundaries (insert and retrieval) to prevent +// callers from mutating internal state. +type objectStore[T any] struct { + mu sync.RWMutex + items map[string]T + nameFunc func(T) string + copyFunc func(T) T +} + +// newobjectStore creates a new objectStore with the given name-extraction +// and deep-copy functions. +func newobjectStore[T any](nameFunc func(T) string, copyFunc func(T) T) *objectStore[T] { + return &objectStore[T]{ + items: make(map[string]T), + nameFunc: nameFunc, + copyFunc: copyFunc, + } +} + +// Create adds a new object to the store. Returns AlreadyExists if an object +// with the same name already exists. The object is deep-copied on insert and +// a deep copy is returned. +func (s *objectStore[T]) Create(obj T) (T, error) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[name]; exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorAlreadyExists, + Message: fmt.Sprintf("%s already exists", name), + } + } + + stored := s.copyFunc(obj) + s.items[name] = stored + return s.copyFunc(stored), nil +} + +// Get retrieves an object by name. Returns NotFound if the object does not exist. +// The returned object is a deep copy. +func (s *objectStore[T]) Get(name string) (T, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + obj, exists := s.items[name] + if !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + return s.copyFunc(obj), nil +} + +// List returns deep copies of all objects in the store. The order is not +// guaranteed. +func (s *objectStore[T]) List() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]T, 0, len(s.items)) + for _, obj := range s.items { + result = append(result, s.copyFunc(obj)) + } + return result +} + +// Update replaces an existing object in the store. Returns NotFound if the +// object does not exist. The object is deep-copied on insert and a deep copy +// is returned. +func (s *objectStore[T]) Update(obj T) (T, error) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[name]; !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + + stored := s.copyFunc(obj) + s.items[name] = stored + return s.copyFunc(stored), nil +} + +// Delete removes an object from the store by name. The operation is +// idempotent — deleting a non-existent object is a no-op. +func (s *objectStore[T]) Delete(name string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, name) +} + +// DeleteAndGet atomically removes an object from the store and returns a +// deep copy of the removed object. Returns the zero value and false if the +// object did not exist. This avoids the race between a separate Get+Delete. +func (s *objectStore[T]) DeleteAndGet(name string) (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + obj, exists := s.items[name] + if !exists { + var zero T + return zero, false + } + delete(s.items, name) + return s.copyFunc(obj), true +} + +// Insert directly places an object into the store without checking for +// duplicates. This is intended for pre-seeding test fixtures. The object +// is deep-copied on insert. +func (s *objectStore[T]) Insert(obj T) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + s.items[name] = s.copyFunc(obj) +} diff --git a/sdk/go/openshell/v1/fake/store_test.go b/sdk/go/openshell/v1/fake/store_test.go new file mode 100644 index 0000000000..1eb738451d --- /dev/null +++ b/sdk/go/openshell/v1/fake/store_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// testItem is a simple struct used for objectStore tests. +type testItem struct { + Name string + Value string + Tags map[string]string +} + +func testItemName(t *testItem) string { return t.Name } + +func copyTestItem(t *testItem) *testItem { + if t == nil { + return nil + } + c := *t + if t.Tags != nil { + c.Tags = make(map[string]string, len(t.Tags)) + for k, v := range t.Tags { + c.Tags[k] = v + } + } + return &c +} + +func newTestStore() *objectStore[*testItem] { + return newobjectStore(testItemName, copyTestItem) +} + +// --- T004: objectStore CRUD tests --- + +func TestObjectStore_Create(t *testing.T) { + s := newTestStore() + + item := &testItem{Name: "alpha", Value: "v1"} + created, err := s.Create(item) + require.NoError(t, err) + assert.Equal(t, "alpha", created.Name) + assert.Equal(t, "v1", created.Value) +} + +func TestObjectStore_Create_AlreadyExists(t *testing.T) { + s := newTestStore() + + _, err := s.Create(&testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + _, err = s.Create(&testItem{Name: "alpha", Value: "v2"}) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err), "expected AlreadyExists error, got: %v", err) +} + +func TestObjectStore_Get(t *testing.T) { + s := newTestStore() + + _, err := s.Create(&testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "alpha", got.Name) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Get_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Get("nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_List_Empty(t *testing.T) { + s := newTestStore() + items := s.List() + assert.Empty(t, items) +} + +func TestObjectStore_List(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + _, _ = s.Create(&testItem{Name: "beta", Value: "v2"}) + + items := s.List() + assert.Len(t, items, 2) + + // Sort for deterministic comparison + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + assert.Equal(t, "alpha", items[0].Name) + assert.Equal(t, "beta", items[1].Name) +} + +func TestObjectStore_Update(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + + updated, err := s.Update(&testItem{Name: "alpha", Value: "v2"}) + require.NoError(t, err) + assert.Equal(t, "v2", updated.Value) + + // Verify the stored value is updated + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_Update_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Update(&testItem{Name: "nonexistent", Value: "v1"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_Delete(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + s.Delete("alpha") + + _, err := s.Get("alpha") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestObjectStore_Delete_Idempotent(_ *testing.T) { + s := newTestStore() + + // Deleting a non-existent item should not panic or error + s.Delete("nonexistent") + + // Create and delete twice + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + s.Delete("alpha") + s.Delete("alpha") // second delete should be idempotent +} + +func TestObjectStore_Insert(t *testing.T) { + s := newTestStore() + + // Insert bypasses duplicate checks (for pre-seeding) + s.Insert(&testItem{Name: "alpha", Value: "v1"}) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Insert_Overwrites(t *testing.T) { + s := newTestStore() + + s.Insert(&testItem{Name: "alpha", Value: "v1"}) + s.Insert(&testItem{Name: "alpha", Value: "v2"}) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_DeepCopy_OnCreate(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + created, err := s.Create(original) + require.NoError(t, err) + + // Mutating original should not affect stored object + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) + + // Mutating returned object should not affect stored object + created.Value = "mutated-created" + got2, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) +} + +func TestObjectStore_DeepCopy_OnGet(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + got, err := s.Get("alpha") + require.NoError(t, err) + + // Mutating retrieved object should not affect stored object + got.Value = "mutated" + got.Tags["env"] = "mutated" + + got2, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) + assert.Equal(t, "test", got2.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnList(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + items := s.List() + require.Len(t, items, 1) + + // Mutating listed object should not affect stored object + items[0].Value = "mutated" + items[0].Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnInsert(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + s.Insert(original) + + // Mutating original should not affect stored object + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} diff --git a/sdk/go/openshell/v1/fake/tcp.go b/sdk/go/openshell/v1/fake/tcp.go new file mode 100644 index 0000000000..b39699c369 --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + "net" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeTCPClient implements v1.TCPInterface. All methods return +// Unimplemented because TCP port forwarding requires a real sandbox runtime. +type fakeTCPClient struct { + closedFunc func() bool +} + +// newFakeTCPClient creates a new fakeTCPClient. +func newFakeTCPClient(closedFunc func() bool) *fakeTCPClient { + return &fakeTCPClient{closedFunc: closedFunc} +} + +// Forward returns Unimplemented. Ports outside 1-65535 are rejected with +// InvalidArgument to match the real client's behavior. +func (c *fakeTCPClient) Forward(_ context.Context, _ string, port uint32, _ ...v1.ForwardOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Forward is not supported by the fake client"} +} + +// Listen validates inputs then returns Unimplemented. The fake does not bind +// any local port; it checks that sandboxName is non-empty, remotePort is in +// the range 1-65535, and localPort is in the range 0-65535. +func (c *fakeTCPClient) Listen(_ context.Context, sandboxName string, remotePort uint32, localPort uint32, _ ...v1.ListenOption) (net.Listener, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort)} + } + if localPort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Listen is not supported by the fake client"} +} + +// Compile-time check that fakeTCPClient implements v1.TCPInterface. +var _ v1.TCPInterface = (*fakeTCPClient)(nil) diff --git a/sdk/go/openshell/v1/fake/tcp_test.go b/sdk/go/openshell/v1/fake/tcp_test.go new file mode 100644 index 0000000000..2a8d1a2a70 --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T019: fakeTCPClient stub tests --- + +func TestFakeTCP_Forward_ReturnsUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Forward_WithForwardOption(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080, v1.WithForwardServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_InvalidPort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +// --- T020: fakeTCPClient.Listen tests --- + +func TestFakeTCP_Listen_EmptySandboxName(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeTCP_Listen_InvalidRemotePort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := c.Listen(context.Background(), "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + }) + } +} + +func TestFakeTCP_Listen_ValidInputsReturnUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Listen_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Listen_WithOptions(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0, + v1.WithBindAddress("0.0.0.0"), + v1.WithSSHTunnel(), + v1.WithListenServiceID("svc-1"), + ) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} diff --git a/sdk/go/openshell/v1/file.go b/sdk/go/openshell/v1/file.go new file mode 100644 index 0000000000..e7e8a7ecd3 --- /dev/null +++ b/sdk/go/openshell/v1/file.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import "context" + +// FileInterface defines file transfer operations on sandboxes. +// Methods accept a sandbox name and resolve it to an ID internally. +type FileInterface interface { + Upload(ctx context.Context, sandboxName string, localPath string, remotePath string) error + Download(ctx context.Context, sandboxName string, remotePath string, localPath string) error +} diff --git a/sdk/go/openshell/v1/file_client.go b/sdk/go/openshell/v1/file_client.go new file mode 100644 index 0000000000..8fb51a72c5 --- /dev/null +++ b/sdk/go/openshell/v1/file_client.go @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type fileClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface + transport sshTransport +} + +type sshTransport interface { + upload(ctx context.Context, session *pb.CreateSshSessionResponse, localPath, remotePath string) error + download(ctx context.Context, session *pb.CreateSshSessionResponse, remotePath, localPath string) error +} + +func newFileClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *fileClient { + return &fileClient{ + client: pb.NewOpenShellClient(conn), + sandboxes: sandboxes, + transport: &defaultSSHTransport{}, + } +} + +func (f *fileClient) Upload(ctx context.Context, sandboxName string, localPath string, remotePath string) error { + if sandboxName == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} + } + + info, err := os.Stat(localPath) + if err != nil { + return fmt.Errorf("local file error: %w", err) + } + if info.IsDir() { + return fmt.Errorf("local path is a directory, not a file: %s", localPath) + } + + sb, err := f.sandboxes.Get(ctx, sandboxName) + if err != nil { + return err + } + + session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return converter.FromGRPCError(err) + } + + defer func() { + revokeCtx, revokeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer revokeCancel() + _, _ = f.client.RevokeSshSession(revokeCtx, &pb.RevokeSshSessionRequest{ + Token: session.GetToken(), + }) + }() + + return f.transport.upload(ctx, session, localPath, remotePath) +} + +func (f *fileClient) Download(ctx context.Context, sandboxName string, remotePath string, localPath string) error { + if sandboxName == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} + } + + sb, err := f.sandboxes.Get(ctx, sandboxName) + if err != nil { + return err + } + + session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return converter.FromGRPCError(err) + } + + defer func() { + revokeCtx, revokeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer revokeCancel() + _, _ = f.client.RevokeSshSession(revokeCtx, &pb.RevokeSshSessionRequest{ + Token: session.GetToken(), + }) + }() + + return f.transport.download(ctx, session, remotePath, localPath) +} + +type defaultSSHTransport struct{} + +func (t *defaultSSHTransport) upload(_ context.Context, session *pb.CreateSshSessionResponse, localPath, remotePath string) error { + return fmt.Errorf("SSH transport to %s:%d not implemented (local: %s -> remote: %s)", + session.GetGatewayHost(), session.GetGatewayPort(), localPath, remotePath) +} + +func (t *defaultSSHTransport) download(_ context.Context, session *pb.CreateSshSessionResponse, remotePath, localPath string) error { + return fmt.Errorf("SSH transport to %s:%d not implemented (remote: %s -> local: %s)", + session.GetGatewayHost(), session.GetGatewayPort(), remotePath, localPath) +} diff --git a/sdk/go/openshell/v1/file_client_test.go b/sdk/go/openshell/v1/file_client_test.go new file mode 100644 index 0000000000..afc78672a6 --- /dev/null +++ b/sdk/go/openshell/v1/file_client_test.go @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + + 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" +) + +type mockFileServer struct { + pb.UnimplementedOpenShellServer + createResp *pb.CreateSshSessionResponse + createErr error + revokeResp *pb.RevokeSshSessionResponse + revokeErr error + lastCreateReq *pb.CreateSshSessionRequest + lastRevokeReq *pb.RevokeSshSessionRequest + createCallCount int + revokeCallCount int +} + +func newMockFileServer() *mockFileServer { + return &mockFileServer{} +} + +func (s *mockFileServer) CreateSshSession(_ context.Context, req *pb.CreateSshSessionRequest) (*pb.CreateSshSessionResponse, error) { //nolint:revive // method name matches proto interface + s.lastCreateReq = req + s.createCallCount++ + if s.createErr != nil { + return nil, s.createErr + } + return s.createResp, nil +} + +func (s *mockFileServer) RevokeSshSession(_ context.Context, req *pb.RevokeSshSessionRequest) (*pb.RevokeSshSessionResponse, error) { //nolint:revive // method name matches proto interface + s.lastRevokeReq = req + s.revokeCallCount++ + if s.revokeErr != nil { + return nil, s.revokeErr + } + return s.revokeResp, nil +} + +func setupFileTest(t *testing.T, mock *mockFileServer) (*fileClient, 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 newFileClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T051: Upload and Download tests --- + +func TestFileUpload(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-test-sandbox", + Token: "session-token-123", + GatewayHost: "gateway.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("file content"), 0644)) + + err := client.Upload(context.Background(), "test-sandbox", localPath, "/remote/upload.txt") + + // Upload will fail because there's no real SSH server, but it should + // at least call CreateSshSession with the resolved sandbox ID + // and attempt the transfer. The error is from the SSH connection, not the RPC. + assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, 1, mock.createCallCount) + + // We accept an error here since there's no real SSH server to connect to. + // Integration tests will verify end-to-end transfer. + _ = err +} + +func TestFileUpload_CreateSessionError(t *testing.T) { + mock := newMockFileServer() + mock.createErr = status.Error(codes.NotFound, "sandbox not found") + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err := client.Upload(context.Background(), "test-sandbox", localPath, "/remote/file.txt") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestFileDownload(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-test-sandbox", + Token: "session-token-456", + GatewayHost: "gateway.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "test-sandbox", "/remote/file.txt", localPath) + + // Download will fail at SSH connection (no real server), but should + // call CreateSshSession with the resolved sandbox ID. + assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, 1, mock.createCallCount) + + _ = err +} + +func TestFileDownload_CreateSessionError(t *testing.T) { + mock := newMockFileServer() + mock.createErr = status.Error(codes.PermissionDenied, "access denied") + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "test-sandbox", "/remote/file.txt", localPath) + + require.Error(t, err) + assert.True(t, IsPermissionDenied(err)) +} + +// --- T052: Upload error cases --- + +func TestFileUpload_NonExistentLocalFile(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + err := client.Upload(context.Background(), "test-sandbox", "/nonexistent/file.txt", "/remote/file.txt") + + require.Error(t, err) + // Should fail before contacting gateway + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_LocalPathIsDirectory(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + + err := client.Upload(context.Background(), "test-sandbox", tmpDir, "/remote/file.txt") + + require.Error(t, err) + // Should fail before contacting gateway + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_EmptySandboxName(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err := client.Upload(context.Background(), "", localPath, "/remote/file.txt") + + require.Error(t, err) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileDownload_EmptyRemotePath(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "test-sandbox", "", localPath) + + require.Error(t, err) + assert.Equal(t, 0, mock.createCallCount) +} + +// --- Name-to-ID resolution tests --- + +func TestFileUpload_ResolvesNameToID(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-my-sandbox", + Token: "token", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + _ = client.Upload(context.Background(), "my-sandbox", localPath, "/remote/file.txt") + + // Verify the proto request contains the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) +} + +func TestFileUpload_ResolutionError(t *testing.T) { + mock := newMockFileServer() + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newFileClient(conn, resolver) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err = client.Upload(context.Background(), "nonexistent", localPath, "/remote/file.txt") + require.Error(t, err) + assert.True(t, IsNotFound(err)) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileDownload_ResolvesNameToID(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + localPath := filepath.Join(t.TempDir(), "downloaded.txt") + _ = client.Download(context.Background(), "my-sandbox", "/remote/file.txt", localPath) + + assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) +} + +func TestFileDownload_ResolutionError(t *testing.T) { + mock := newMockFileServer() + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newFileClient(conn, resolver) + + localPath := filepath.Join(t.TempDir(), "downloaded.txt") + err = client.Download(context.Background(), "nonexistent", "/remote/file.txt", localPath) + require.Error(t, err) + assert.True(t, IsNotFound(err)) + assert.Equal(t, 0, mock.createCallCount) +} diff --git a/sdk/go/openshell/v1/gateway/config.go b/sdk/go/openshell/v1/gateway/config.go new file mode 100644 index 0000000000..c0813986e9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// AuthMode represents the authentication mode configured for a gateway. +type AuthMode string + +// Known auth mode values matching the Rust CLI's gateway configuration. +const ( + // AuthModeNone indicates no authentication (default when auth_mode is + // unset or explicitly "none"). + AuthModeNone AuthMode = "" + + // AuthModePlaintext indicates an insecure plaintext connection with + // no TLS and no authentication. + AuthModePlaintext AuthMode = "plaintext" + + // AuthModeCloudflareJWT indicates Cloudflare Access JWT authentication + // using an edge token loaded from disk. + AuthModeCloudflareJWT AuthMode = "cloudflare_jwt" + + // AuthModeOIDC indicates OpenID Connect authentication using a + // refreshable token bundle loaded from disk. + AuthModeOIDC AuthMode = "oidc" + + // AuthModeMTLS indicates mutual TLS authentication. Currently + // unsupported; returns [ErrUnsupportedAuthMode] with guidance. + AuthModeMTLS AuthMode = "mtls" +) + +// ConfigSource identifies where a gateway configuration was found. +type ConfigSource string + +const ( + // SourceUser indicates the gateway was found in the user config + // directory ($XDG_CONFIG_HOME/openshell/gateways/). + SourceUser ConfigSource = "user" + + // SourceSystem indicates the gateway was found in the system config + // directory (/etc/openshell/gateways/). + SourceSystem ConfigSource = "system" +) + +// Config is a parsed representation of a gateway's on-disk metadata.json. +// It is an immutable snapshot captured at load time; subsequent changes to +// the on-disk files are not reflected. +type Config struct { + // Name is the validated gateway name. + Name string + + // Endpoint is the host:port address of the gateway. + Endpoint string + + // AuthMode is the resolved authentication mode. + AuthMode AuthMode + + // Source indicates whether the config came from the user or system + // directory. + Source ConfigSource + + // Dir is the absolute path to the gateway config directory. + Dir string + + // OIDCIssuer is the OIDC provider's issuer URL read from + // metadata.json. Empty when the gateway does not use OIDC auth. + OIDCIssuer string + + // OIDCClientID is the OAuth2 client ID read from metadata.json. + // Empty when the gateway does not use OIDC auth. + OIDCClientID string +} + +// Info is a lightweight summary of a gateway for listing purposes. +// It does not load tokens or validate config completeness. +type Info struct { + // Name is the gateway name derived from the directory listing. + Name string + + // Active indicates whether this is the currently active gateway. + Active bool + + // Source indicates whether the gateway is from the user or system + // directory. + Source ConfigSource +} + +// metadataJSON is the on-disk representation of metadata.json. +// Unknown fields are silently ignored for forward compatibility. +type metadataJSON struct { + Endpoint string `json:"gateway_endpoint"` + AuthMode string `json:"auth_mode"` + Name string `json:"name"` + OIDCIssuer string `json:"oidc_issuer"` + OIDCClientID string `json:"oidc_client_id"` +} + +// parseAuthMode converts a raw auth_mode string to the typed AuthMode. +// Empty string and "none" both map to AuthModeNone. +func parseAuthMode(raw string) (AuthMode, error) { + switch raw { + case "", "none": + return AuthModeNone, nil + case "plaintext": + return AuthModePlaintext, nil + case "cloudflare_jwt": + return AuthModeCloudflareJWT, nil + case "oidc": + return AuthModeOIDC, nil + case "mtls": + return AuthModeMTLS, nil + default: + return "", fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, raw) + } +} + +// parseMetadata reads and parses metadata.json from the given gateway +// directory. Unknown fields are silently ignored for forward compatibility +// with newer Rust CLI versions. +func parseMetadata(dir string) (*Config, error) { + path := filepath.Join(dir, "metadata.json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrConfigParse, err) + } + + var meta metadataJSON + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrConfigParse, path, err) + } + + if meta.Endpoint == "" { + return nil, fmt.Errorf("%w: missing gateway_endpoint in %s", ErrConfigParse, path) + } + + mode, err := parseAuthMode(meta.AuthMode) + if err != nil { + return nil, err + } + + return &Config{ + Name: meta.Name, + Endpoint: meta.Endpoint, + AuthMode: mode, + Dir: dir, + OIDCIssuer: meta.OIDCIssuer, + OIDCClientID: meta.OIDCClientID, + }, nil +} diff --git a/sdk/go/openshell/v1/gateway/config_test.go b/sdk/go/openshell/v1/gateway/config_test.go new file mode 100644 index 0000000000..8411d2eef9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config_test.go @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T010: metadata.json parsing tests --- + +func TestParseMetadata_ValidConfig(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"localhost:8080","auth_mode":"none","name":"prod"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "localhost:8080", cfg.Endpoint) + assert.Equal(t, AuthModeNone, cfg.AuthMode) + assert.Equal(t, dir, cfg.Dir) +} + +func TestParseMetadata_EmptyAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, AuthModeNone, cfg.AuthMode) +} + +func TestParseMetadata_AllAuthModes(t *testing.T) { + cases := []struct { + mode string + expected AuthMode + }{ + {"", AuthModeNone}, + {"none", AuthModeNone}, + {"plaintext", AuthModePlaintext}, + {"cloudflare_jwt", AuthModeCloudflareJWT}, + {"oidc", AuthModeOIDC}, + {"mtls", AuthModeMTLS}, + } + + for _, tc := range cases { + t.Run("mode_"+tc.mode, func(t *testing.T) { + dir := t.TempDir() + if tc.mode == "" { + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + } else { + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"`+tc.mode+`"}`) + } + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, tc.expected, cfg.AuthMode) + }) + } +} + +func TestParseMetadata_MissingEndpoint(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"auth_mode":"none","name":"prod"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "missing gateway_endpoint") +} + +func TestParseMetadata_MissingFile(t *testing.T) { + dir := t.TempDir() + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) +} + +func TestParseMetadata_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{invalid json}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseMetadata_UnknownFieldsIgnored(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"none", + "name":"prod", + "future_field":"some_value", + "another_new_thing": 42 + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +func TestParseMetadata_UnsupportedAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"kerberos"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "kerberos") +} + +func TestParseAuthMode(t *testing.T) { + cases := []struct { + input string + expected AuthMode + wantErr bool + }{ + {"", AuthModeNone, false}, + {"none", AuthModeNone, false}, + {"plaintext", AuthModePlaintext, false}, + {"cloudflare_jwt", AuthModeCloudflareJWT, false}, + {"oidc", AuthModeOIDC, false}, + {"mtls", AuthModeMTLS, false}, + {"unknown", "", true}, + {"NONE", "", true}, // case-sensitive + } + + for _, tc := range cases { + t.Run("input_"+tc.input, func(t *testing.T) { + mode, err := parseAuthMode(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, mode) + } + }) + } +} + +// --- T010: OIDC config field tests --- + +func TestParseMetadata_OIDCFields(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "name":"oidc-gw", + "oidc_issuer":"https://auth.example.com", + "oidc_client_id":"my-client-id" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "oidc-gw", cfg.Name) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, "https://auth.example.com", cfg.OIDCIssuer) + assert.Equal(t, "my-client-id", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsMissing(t *testing.T) { + // When OIDC fields are absent (older gateway or non-OIDC mode), + // the Config should have empty strings for OIDCIssuer/OIDCClientID. + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"cloudflare_jwt", + "name":"legacy-gw" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsEmpty(t *testing.T) { + // Explicit empty strings for OIDC fields should be handled + // gracefully (backward compatibility). + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "oidc_issuer":"", + "oidc_client_id":"" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +// writeJSON is a test helper that writes a metadata.json file. +func writeJSON(t *testing.T, dir, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/gateway/doc.go b/sdk/go/openshell/v1/gateway/doc.go new file mode 100644 index 0000000000..ef060530d7 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/doc.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package gateway reads on-disk gateway configurations created by the +// OpenShell Rust CLI and constructs fully wired SDK clients. +// +// The package resolves XDG config paths, validates gateway names, loads +// tokens lazily, maps auth modes to existing auth providers, and provides +// one-call convenience constructors. This eliminates 20+ lines of +// boilerplate for Go programs connecting to gateways managed by the CLI. +// +// # Quick Start +// +// Connect to a named gateway: +// +// client, err := gateway.NewClient("prod") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Connect to the active gateway (set via `openshell gateway use`): +// +// client, err := gateway.NewClient("") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Inspect configuration without creating a client: +// +// cfg, err := gateway.LoadConfig("staging") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Endpoint: %s, Auth: %s\n", cfg.Endpoint, cfg.AuthMode) +// +// List all configured gateways: +// +// gateways, err := gateway.ListGateways() +// if err != nil { +// log.Fatal(err) +// } +// for _, gw := range gateways { +// fmt.Printf("%s (active=%v, source=%s)\n", gw.Name, gw.Active, gw.Source) +// } +// +// # On-Disk Layout +// +// The package reads gateway metadata from the following locations: +// +// $XDG_CONFIG_HOME/openshell/gateways//metadata.json (user) +// /etc/openshell/gateways//metadata.json (system) +// +// Token files (edge_token, cf_token, oidc_token.json) sit alongside +// metadata.json and are loaded lazily on first authentication attempt. +// +// # Error Handling +// +// The package provides typed errors for precise failure classification: +// +// - [ErrGatewayNotFound]: no gateway directory found +// - [ErrConfigParse]: metadata.json missing or malformed +// - [ErrTokenLoad]: token file missing or unreadable +// - [ErrUnsupportedAuthMode]: unrecognized auth_mode value +// - [ErrInvalidGatewayName]: name fails validation +// - [ErrNoActiveGateway]: no active gateway configured +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. +package gateway diff --git a/sdk/go/openshell/v1/gateway/errors.go b/sdk/go/openshell/v1/gateway/errors.go new file mode 100644 index 0000000000..ef26774617 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import "errors" + +// Sentinel errors for gateway configuration failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrGatewayNotFound is returned when no gateway directory exists + // in either the user or system config paths. + ErrGatewayNotFound = errors.New("gateway: not found") + + // ErrConfigParse is returned when metadata.json is missing, + // unreadable, or contains invalid JSON. + ErrConfigParse = errors.New("gateway: config parse error") + + // ErrTokenLoad is returned when a token file (edge_token, + // oidc_token.json) is missing, unreadable, or malformed. + ErrTokenLoad = errors.New("gateway: token load error") + + // ErrUnsupportedAuthMode is returned when the auth_mode value in + // metadata.json is not recognized (not none, plaintext, + // cloudflare_jwt, oidc, or mtls). + ErrUnsupportedAuthMode = errors.New("gateway: unsupported auth mode") + + // ErrInvalidGatewayName is returned when a gateway name fails + // validation (empty, contains path separators, dots, or + // non-ASCII-alnum-dash-underscore characters). + ErrInvalidGatewayName = errors.New("gateway: invalid gateway name") + + // ErrNoActiveGateway is returned when no active gateway is + // configured (active_gateway file missing or empty). + ErrNoActiveGateway = errors.New("gateway: no active gateway") +) diff --git a/sdk/go/openshell/v1/gateway/errors_test.go b/sdk/go/openshell/v1/gateway/errors_test.go new file mode 100644 index 0000000000..09d8b40d6d --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- T011: Error type tests --- + +func TestSentinelErrors_ErrorsIs(t *testing.T) { + sentinels := []struct { + name string + err error + }{ + {"ErrGatewayNotFound", ErrGatewayNotFound}, + {"ErrConfigParse", ErrConfigParse}, + {"ErrTokenLoad", ErrTokenLoad}, + {"ErrUnsupportedAuthMode", ErrUnsupportedAuthMode}, + {"ErrInvalidGatewayName", ErrInvalidGatewayName}, + {"ErrNoActiveGateway", ErrNoActiveGateway}, + } + + for _, tc := range sentinels { + t.Run(tc.name+"_direct", func(t *testing.T) { + assert.True(t, errors.Is(tc.err, tc.err), + "errors.Is should match sentinel directly") + }) + + t.Run(tc.name+"_wrapped", func(t *testing.T) { + wrapped := fmt.Errorf("context: %w", tc.err) + assert.True(t, errors.Is(wrapped, tc.err), + "errors.Is should match through fmt.Errorf wrapping") + }) + + t.Run(tc.name+"_double_wrapped", func(t *testing.T) { + inner := fmt.Errorf("inner: %w", tc.err) + outer := fmt.Errorf("outer: %w", inner) + assert.True(t, errors.Is(outer, tc.err), + "errors.Is should match through double wrapping") + }) + } +} + +func TestSentinelErrors_NotConfused(t *testing.T) { + // Verify that different sentinel errors are not equal. + pairs := []struct { + a, b error + }{ + {ErrGatewayNotFound, ErrConfigParse}, + {ErrConfigParse, ErrTokenLoad}, + {ErrTokenLoad, ErrUnsupportedAuthMode}, + {ErrUnsupportedAuthMode, ErrInvalidGatewayName}, + {ErrInvalidGatewayName, ErrNoActiveGateway}, + {ErrNoActiveGateway, ErrGatewayNotFound}, + } + + for _, tc := range pairs { + t.Run(tc.a.Error()+"_vs_"+tc.b.Error(), func(t *testing.T) { + assert.False(t, errors.Is(tc.a, tc.b), + "different sentinels must not match") + }) + } +} + +func TestSentinelErrors_HaveMessages(t *testing.T) { + sentinels := []error{ + ErrGatewayNotFound, + ErrConfigParse, + ErrTokenLoad, + ErrUnsupportedAuthMode, + ErrInvalidGatewayName, + ErrNoActiveGateway, + } + + for _, err := range sentinels { + t.Run(err.Error(), func(t *testing.T) { + msg := err.Error() + assert.NotEmpty(t, msg) + assert.Contains(t, msg, "gateway:") + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go new file mode 100644 index 0000000000..8d1bd9d87b --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// NewClient creates a fully wired SDK client from an on-disk gateway +// configuration. If name is empty, the active gateway (set via +// `openshell gateway use`) is used. +// +// The function resolves the gateway directory, parses metadata.json, +// loads tokens lazily, maps the auth mode to an SDK auth provider, and +// applies any ClientOptions before delegating to [v1.NewClient]. +// +// NewClient is safe for concurrent use from multiple goroutines. +func NewClient(name string, opts ...ClientOption) (*v1.Client, error) { + cfg, err := loadConfigInternal(name) + if err != nil { + return nil, err + } + + // Apply caller options. + cc := &clientConfig{} + for _, o := range opts { + o(cc) + } + + // Resolve auth provider: caller override takes precedence. + auth := cc.auth + if auth == nil { + auth, err = resolveAuthProvider(cfg) + if err != nil { + return nil, err + } + } + + // Build the SDK Config. + sdkCfg := types.Config{ + Address: cfg.Endpoint, + Auth: auth, + } + + // Apply TLS: caller override or auth-mode defaults. + if cc.tls != nil { + sdkCfg.TLS = cc.tls + } else if cfg.AuthMode == AuthModePlaintext { + sdkCfg.TLS = &types.TLSConfig{Insecure: true} + } + + if cc.timeout > 0 { + sdkCfg.Timeout = cc.timeout + } + if cc.retryPolicy != nil { + sdkCfg.RetryPolicy = cc.retryPolicy + } + if cc.logger != nil { + sdkCfg.Logger = cc.logger + } + + return v1.NewClient(sdkCfg) +} + +// LoadConfig reads and parses a gateway's on-disk configuration without +// creating a client connection. If name is empty, the active gateway is +// used. +// +// The returned [Config] is an immutable snapshot; changes to the on-disk +// files after this call are not reflected. +// +// LoadConfig is safe for concurrent use from multiple goroutines. +func LoadConfig(name string) (*Config, error) { + return loadConfigInternal(name) +} + +// loadConfigInternal resolves the gateway name (including active gateway +// fallback), finds the config directory, and parses metadata.json. This +// shared implementation is used by both NewClient and LoadConfig. +func loadConfigInternal(name string) (*Config, error) { + // If name is empty, resolve the active gateway. + if name == "" { + activeName, err := resolveActiveGateway() + if err != nil { + return nil, err + } + name = activeName + } + + dir, source, err := resolveGatewayDir(name) + if err != nil { + return nil, err + } + + cfg, err := parseMetadata(dir) + if err != nil { + return nil, err + } + + // Override name from directory (validated) rather than metadata.json. + cfg.Name = name + cfg.Source = source + + return cfg, nil +} + +// ListGateways enumerates all available gateways from user and system +// directories. User gateways appear first. If the same name exists in +// both directories, only the user gateway is returned (user precedence). +// Returns an empty slice (not an error) when no gateways are configured. +// +// ListGateways is safe for concurrent use from multiple goroutines. +func ListGateways() ([]Info, error) { + seen := make(map[string]bool) + var result []Info + + activeName, _ := resolveActiveGateway() + + userBase, err := userConfigDir() + if err == nil { + names, listErr := listGatewayDirs(userBase) + if listErr != nil { + return nil, listErr + } + for _, name := range names { + seen[name] = true + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceUser, + }) + } + } + + sysNames, listErr := listGatewayDirs(systemConfigBase) + if listErr != nil { + return nil, listErr + } + for _, name := range sysNames { + if !seen[name] { + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceSystem, + }) + } + } + + return result, nil +} + +// resolveAuthProvider maps a Config's AuthMode to an SDK AuthProvider. +// Tokens are loaded lazily where possible. +func resolveAuthProvider(cfg *Config) (types.AuthProvider, error) { + switch cfg.AuthMode { + case AuthModeNone: + return v1.NoAuth(), nil + + case AuthModePlaintext: + return v1.NoAuth(), nil + + case AuthModeCloudflareJWT: + // Token loading is deferred to GetRequestMetadata so that + // NewClient succeeds even when the token file is missing. + // The error surfaces on first authentication attempt (FR-007). + return &lazyEdgeAuth{loader: &edgeTokenLoader{dir: cfg.Dir}}, nil + + case AuthModeOIDC: + src := newDiskTokenSource(cfg.Dir) + return v1.RefreshableToken(src) + + case AuthModeMTLS: + return nil, fmt.Errorf("%w: mtls is not yet supported; use WithAuth() to provide a custom auth provider", ErrUnsupportedAuthMode) + + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, cfg.AuthMode) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go new file mode 100644 index 0000000000..1462175f24 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- Test helpers --- + +// setupGateway creates a gateway directory with the given metadata.json +// content under a temp XDG config dir. Returns the XDG root path. +func setupGateway(t *testing.T, name, metadataJSON string) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", metadataJSON) + + return tmp +} + +// setupGatewayWithTokens creates a gateway directory with metadata and +// token files. +func setupGatewayWithTokens(t *testing.T, name, metadataJSON string, tokens map[string]string) string { + t.Helper() + tmp := setupGateway(t, name, metadataJSON) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + for filename, content := range tokens { + writeFile(t, gwDir, filename, content) + } + + return tmp +} + +// --- T018: NewClient tests --- + +func TestNewClient_AuthModeNone(t *testing.T) { + setupGateway(t, "test-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("test-gw", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModePlaintext(t *testing.T) { + setupGateway(t, "plain-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"plaintext"}`) + + // Plaintext mode should auto-set insecure TLS. + client, err := NewClient("plain-gw") + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeCloudflareJWT(t *testing.T) { + setupGatewayWithTokens(t, "cf-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: "test-edge-token"}, + ) + + // StaticToken requires transport security, so use WithAuth override + // to bypass gRPC TLS requirement. Auth resolution is verified by + // TestResolveAuthProvider_CloudflareJWT. + client, err := NewClient("cf-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeOIDC(t *testing.T) { + setupGatewayWithTokens(t, "oidc-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`, + map[string]string{oidcTokenFile: `{"access_token":"test-oidc-token"}`}, + ) + + // RefreshableToken requires transport security, so use WithAuth + // override. Auth resolution verified by TestResolveAuthProvider_OIDC. + client, err := NewClient("oidc-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := NewClient("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestNewClient_InvalidName(t *testing.T) { + _, err := NewClient("../escape") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestNewClient_MissingEdgeToken(t *testing.T) { + setupGateway(t, "no-token-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Edge token loading is lazy (FR-007): NewClient succeeds even when + // the token file is missing. The error surfaces on first use via + // GetRequestMetadata, not at construction time. + _, err := NewClient("no-token-gw") + require.NoError(t, err) +} + +func TestNewClient_MissingOIDCToken(t *testing.T) { + setupGateway(t, "no-oidc-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`) + + _, err := NewClient("no-oidc-gw") + // OIDC uses RefreshableToken which defers the disk read to Token(), + // so NewClient should succeed. The error would come on first use. + // Let's verify it doesn't fail at construction time. + require.NoError(t, err) + assert.NoError(t, err) +} + +func TestNewClient_MTLSUnsupported(t *testing.T) { + setupGateway(t, "mtls-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"mtls"}`) + + _, err := NewClient("mtls-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") +} + +func TestNewClient_WithAuthOverride(t *testing.T) { + setupGateway(t, "override-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Even though auth_mode is cloudflare_jwt and there's no edge_token, + // the WithAuth override should bypass token loading entirely. + customAuth := &mockAuth{} + client, err := NewClient("override-gw", + WithAuth(customAuth), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithLogger(t *testing.T) { + setupGateway(t, "logger-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("logger-gw", + WithLogger(nil), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithOptions(t *testing.T) { + setupGateway(t, "opts-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("opts-gw", + WithTimeout(5*time.Second), + WithRetryPolicy(&types.RetryPolicy{MaxRetries: 3}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +// --- T019: Credential leak test --- + +func TestNewClient_NoCredentialLeaks(t *testing.T) { + // Test 1: Invalid gateway with path traversal attempt. + _, err := NewClient("../../../etc/passwd") + require.Error(t, err) + assert.NotContains(t, err.Error(), "passwd") + + // Test 2: Verify error from missing edge token does not reveal + // file system details beyond the generic message. + setupGateway(t, "no-edge-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + cfg := &Config{ + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "no-edge-gw"), + } + auth, authErr := resolveAuthProvider(cfg) + require.NoError(t, authErr) + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + + // Test 3: Verify credential values never appear in error strings. + secretToken := "SUPER_SECRET_TOKEN_12345" + setupGatewayWithTokens(t, "leak-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: secretToken}, + ) + // Use resolveAuthProvider directly to test token loading. + cfg = &Config{ + Name: "leak-gw", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "leak-gw"), + } + auth, err = resolveAuthProvider(cfg) + require.NoError(t, err) + + // The provider should not expose the token in its string form. + if stringer, ok := auth.(interface{ String() string }); ok { + assert.NotContains(t, stringer.String(), secretToken) + } +} + +func TestResolveAuthProvider_NoTokenLeaks(t *testing.T) { + secretToken := "CREDENTIAL_THAT_MUST_NOT_LEAK" + + // Create a gateway with a bad OIDC token. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + gwDir := filepath.Join(tmp, "openshell", "gateways", "leak-test") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + writeFile(t, gwDir, edgeTokenFile, secretToken) + + cfg := &Config{ + Name: "leak-test", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: gwDir, + } + + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + + // The auth provider should work but the token value should not + // appear in the provider's string representation (if any). + providerStr := "" + if stringer, ok := auth.(interface{ String() string }); ok { + providerStr = stringer.String() + assert.NotContains(t, providerStr, secretToken) + } + + // Verify the token IS used correctly via GetRequestMetadata. + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Contains(t, md["authorization"], secretToken) +} + +// --- Test helpers --- + +// mockAuth is a minimal AuthProvider for testing WithAuth overrides. +type mockAuth struct{} + +func (m *mockAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer mock-token"}, nil +} + +func (m *mockAuth) RequireTransportSecurity() bool { + return false +} + +// --- LoadConfig tests (T025 placeholder, implemented here for Phase 3 coverage) --- + +func TestLoadConfig_ValidConfig(t *testing.T) { + setupGateway(t, "cfg-gw", `{"gateway_endpoint":"host:443","auth_mode":"oidc","name":"ignored"}`) + + cfg, err := LoadConfig("cfg-gw") + require.NoError(t, err) + // Name comes from directory, not metadata.json "name" field. + assert.Equal(t, "cfg-gw", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, SourceUser, cfg.Source) + assert.NotEmpty(t, cfg.Dir) +} + +func TestLoadConfig_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := LoadConfig("missing-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestLoadConfig_FrozenSnapshot(t *testing.T) { + xdg := setupGateway(t, "snap-gw", `{"gateway_endpoint":"original:443","auth_mode":"none"}`) + + cfg, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "original:443", cfg.Endpoint) + + // Modify the on-disk file. + gwDir := filepath.Join(xdg, "openshell", "gateways", "snap-gw") + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"modified:443","auth_mode":"none"}`) + + // The previously loaded config should be unchanged. + assert.Equal(t, "original:443", cfg.Endpoint) + + // A new load should see the change. + cfg2, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "modified:443", cfg2.Endpoint) +} + +// --- resolveAuthProvider tests --- + +func TestResolveAuthProvider_None(t *testing.T) { + cfg := &Config{AuthMode: AuthModeNone} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_Plaintext(t *testing.T) { + cfg := &Config{AuthMode: AuthModePlaintext} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_CloudflareJWT(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "cf-jwt-token") + + cfg := &Config{AuthMode: AuthModeCloudflareJWT, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer cf-jwt-token", md["authorization"]) +} + +func TestResolveAuthProvider_OIDC(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token":"oidc-access-token"}`) + + cfg := &Config{AuthMode: AuthModeOIDC, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer oidc-access-token", md["authorization"]) +} + +func TestResolveAuthProvider_MTLS(t *testing.T) { + cfg := &Config{AuthMode: AuthModeMTLS} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") + assert.Contains(t, err.Error(), "WithAuth") +} + +func TestResolveAuthProvider_UnknownMode(t *testing.T) { + cfg := &Config{AuthMode: "alien_auth"} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) +} + +// --- T021/T023: Active gateway tests --- + +func TestNewClient_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "active-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + activeFile := filepath.Join(tmp, "openshell", "active_gateway") + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "active-gw") + _ = activeFile + + client, err := NewClient("", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NoActiveGateway(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := NewClient("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestLoadConfig_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "my-active", `{"gateway_endpoint":"host:443","auth_mode":"oidc"}`) + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "my-active") + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "my-active", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +// --- T027: ListGateways tests --- + +func TestListGateways_MultipleGateways(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"prod", "staging", "dev"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 3) + + names := make(map[string]bool) + for _, gw := range gateways { + names[gw.Name] = true + assert.Equal(t, SourceUser, gw.Source) + } + assert.True(t, names["prod"]) + assert.True(t, names["staging"]) + assert.True(t, names["dev"]) +} + +func TestListGateways_EmptyDirs(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Empty(t, gateways) +} + +func TestListGateways_ActiveStatus(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"alpha", "beta"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "beta") + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 2) + + for _, gw := range gateways { + if gw.Name == "beta" { + assert.True(t, gw.Active) + } else { + assert.False(t, gw.Active) + } + } +} diff --git a/sdk/go/openshell/v1/gateway/options.go b/sdk/go/openshell/v1/gateway/options.go new file mode 100644 index 0000000000..dfd16e89ca --- /dev/null +++ b/sdk/go/openshell/v1/gateway/options.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// clientConfig holds resolved options applied after gateway config +// resolution but before the v1.Client is created. +type clientConfig struct { + logger types.Logger + timeout time.Duration + tls *types.TLSConfig + auth types.AuthProvider + retryPolicy *types.RetryPolicy +} + +// ClientOption configures the behavior of [NewClient]. Options are +// applied after gateway configuration is resolved but before the +// underlying SDK client is created. +type ClientOption func(*clientConfig) + +// WithLogger sets the logger on the SDK client configuration. +func WithLogger(l types.Logger) ClientOption { + return func(c *clientConfig) { + c.logger = l + } +} + +// WithTimeout sets the connection timeout for the SDK client. +func WithTimeout(d time.Duration) ClientOption { + return func(c *clientConfig) { + c.timeout = d + } +} + +// WithTLS overrides the TLS settings derived from the gateway's auth mode. +// Use this to provide custom certificates or force insecure connections. +func WithTLS(cfg *types.TLSConfig) ClientOption { + return func(c *clientConfig) { + c.tls = cfg + } +} + +// WithAuth overrides the auth provider that would normally be resolved +// from the gateway's auth_mode. When set, the gateway package skips +// its own auth resolution and uses the provided provider directly. +func WithAuth(provider types.AuthProvider) ClientOption { + return func(c *clientConfig) { + c.auth = provider + } +} + +// WithRetryPolicy sets the retry policy on the SDK client configuration. +func WithRetryPolicy(p *types.RetryPolicy) ClientOption { + return func(c *clientConfig) { + c.retryPolicy = p + } +} diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go new file mode 100644 index 0000000000..fa0e1373c2 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +const ( + // appName is the application directory name used in XDG paths. + appName = "openshell" + + // gatewaySubdir is the subdirectory within the app config holding + // per-gateway directories. + gatewaySubdir = "gateways" + + // activeGatewayFile is the filename that stores the active gateway name. + activeGatewayFile = "active_gateway" + + // systemConfigBase is the system-wide config directory. + systemConfigBase = "/etc/openshell" +) + +// userConfigDir returns the user-specific configuration directory for +// OpenShell, following XDG Base Directory specification: +// +// $XDG_CONFIG_HOME/openshell (if XDG_CONFIG_HOME is set) +// ~/.config/openshell (fallback) +func userConfigDir() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if !filepath.IsAbs(xdg) { + return "", fmt.Errorf("XDG_CONFIG_HOME must be an absolute path, got %q", xdg) + } + return filepath.Join(xdg, appName), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + + return filepath.Join(home, ".config", appName), nil +} + +// systemGatewayDir returns the system-wide gateway config directory. +func systemGatewayDir() string { + return filepath.Join(systemConfigBase, gatewaySubdir) +} + +// resolveGatewayDir searches for a gateway directory by name, checking the +// user directory first, then the system directory. Returns the absolute +// directory path, the config source, or ErrGatewayNotFound. +func resolveGatewayDir(name string) (string, ConfigSource, error) { + if err := validateGatewayName(name); err != nil { + return "", "", err + } + + // Check user config dir first. + userBase, err := userConfigDir() + if err == nil { + userDir := filepath.Join(userBase, gatewaySubdir, name) + if info, statErr := os.Stat(userDir); statErr == nil && info.IsDir() { + return userDir, SourceUser, nil + } + } + + // Check system config dir. + sysDir := filepath.Join(systemGatewayDir(), name) + if info, statErr := os.Stat(sysDir); statErr == nil && info.IsDir() { + return sysDir, SourceSystem, nil + } + + return "", "", fmt.Errorf("%w: %q", ErrGatewayNotFound, name) +} + +// validateGatewayName checks that a gateway name is safe for use as a +// directory component. It rejects: +// - empty names +// - names containing path separators (/ or \) +// - names that are "." or ".." (directory traversal) +// - names containing "." (prevents hidden files and extension confusion) +// - names with characters outside ASCII alphanumerics, dashes, underscores +func validateGatewayName(name string) error { + if name == "" { + return fmt.Errorf("%w: name must not be empty", ErrInvalidGatewayName) + } + + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("%w: name must not contain path separators", ErrInvalidGatewayName) + } + + if strings.Contains(name, ".") { + return fmt.Errorf("%w: name must not contain dots", ErrInvalidGatewayName) + } + + for _, r := range name { + if !isValidNameRune(r) { + return fmt.Errorf("%w: name contains invalid character %q", ErrInvalidGatewayName, string(r)) + } + } + + return nil +} + +// resolveActiveGateway reads the active_gateway file from the user +// config directory and returns the validated gateway name. Returns +// ErrNoActiveGateway if the file is missing or empty. +func resolveActiveGateway() (string, error) { + userBase, err := userConfigDir() + if err != nil { + return "", fmt.Errorf("%w: cannot determine config directory: %v", ErrNoActiveGateway, err) + } + + path := filepath.Join(userBase, activeGatewayFile) + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("%w", ErrNoActiveGateway) + } + + name := strings.TrimSpace(string(data)) + if name == "" { + return "", fmt.Errorf("%w: active_gateway file is empty", ErrNoActiveGateway) + } + + if err := validateGatewayName(name); err != nil { + return "", fmt.Errorf("%w: active_gateway contains invalid name %q: %v", ErrNoActiveGateway, name, err) + } + + return name, nil +} + +// listGatewayDirs returns a list of gateway directories found under the +// given base path. Each entry is just the directory name (gateway name). +func listGatewayDirs(base string) ([]string, error) { + gatewaysDir := filepath.Join(base, gatewaySubdir) + entries, err := os.ReadDir(gatewaysDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var names []string + for _, e := range entries { + if e.IsDir() && validateGatewayName(e.Name()) == nil { + names = append(names, e.Name()) + } + } + return names, nil +} + +// isValidNameRune returns true if the rune is an ASCII letter, digit, +// dash, or underscore. +func isValidNameRune(r rune) bool { + if r > unicode.MaxASCII { + return false + } + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' +} diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go new file mode 100644 index 0000000000..547bd3fc3c --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T007: XDG resolution tests --- + +func TestUserConfigDir_XDGSet(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + dir, err := userConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmp, "openshell"), dir) +} + +func TestUserConfigDir_XDGUnset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + + dir, err := userConfigDir() + require.NoError(t, err) + + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "openshell"), dir) +} + +func TestSystemGatewayDir(t *testing.T) { + dir := systemGatewayDir() + assert.Equal(t, "/etc/openshell/gateways", dir) +} + +func TestResolveGatewayDir_UserDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + // Create a gateway directory in user config. + gwDir := filepath.Join(tmp, "openshell", "gateways", "prod") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("prod") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +func TestResolveGatewayDir_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, _, err := resolveGatewayDir("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestResolveGatewayDir_InvalidName(t *testing.T) { + _, _, err := resolveGatewayDir("../etc") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestResolveGatewayDir_UserPrecedenceOverSystem(t *testing.T) { + // This test verifies the search order: user dir is checked before + // system dir. We can only test the user dir path since we cannot + // write to /etc in tests. The logic is verified by the successful + // user dir resolution above. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", "shared") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("shared") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +// --- T008: Name validation tests --- + +func TestValidateGatewayName_ValidNames(t *testing.T) { + validNames := []string{ + "prod", + "staging", + "my-gateway", + "gateway_1", + "PROD", + "a", + "test-gateway-01", + "A_B_C", + } + + for _, name := range validNames { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + assert.NoError(t, err, "expected %q to be valid", name) + }) + } +} + +func TestValidateGatewayName_Empty(t *testing.T) { + err := validateGatewayName("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + assert.Contains(t, err.Error(), "empty") +} + +func TestValidateGatewayName_PathSeparators(t *testing.T) { + cases := []string{ + "../etc", + "foo/bar", + "foo\\bar", + "/absolute", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +func TestValidateGatewayName_Dots(t *testing.T) { + cases := []string{ + ".", + "..", + ".hidden", + "foo.bar", + "config.json", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +// --- T021: Active gateway resolution tests --- + +func TestResolveActiveGateway_ValidName(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte("my-gateway"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_WhitespaceHandling(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" my-gateway \n"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_FileMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestResolveActiveGateway_EmptyFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" \n"), 0o644)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestValidateGatewayName_SpecialCharacters(t *testing.T) { + cases := []struct { + name string + desc string + }{ + {"hello world", "space"}, + {"foo@bar", "at sign"}, + {"foo#bar", "hash"}, + {"café", "non-ASCII"}, + {"日本語", "unicode"}, + {"foo bar", "tab (space)"}, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + err := validateGatewayName(tc.name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/token.go b/sdk/go/openshell/v1/gateway/token.go new file mode 100644 index 0000000000..b4e13c359e --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // edgeTokenFile is the primary edge token filename. + edgeTokenFile = "edge_token" + + // cfTokenFile is the legacy Cloudflare token filename, used as + // fallback when edge_token does not exist. + cfTokenFile = "cf_token" + + // oidcTokenFile is the OIDC token bundle filename. + oidcTokenFile = "oidc_token.json" +) + +// edgeTokenLoader provides lazy, thread-safe loading of the edge token +// from disk. The token is read on the first call to load() and cached +// for subsequent calls via sync.Once. +type edgeTokenLoader struct { + dir string + once sync.Once + token string + err error +} + +// load returns the edge token string, reading it from disk on the first +// call. It tries edge_token first, falling back to cf_token for legacy +// compatibility. The result (success or failure) is cached. +func (l *edgeTokenLoader) load() (string, error) { + l.once.Do(func() { + l.token, l.err = readEdgeToken(l.dir) + }) + return l.token, l.err +} + +// readEdgeToken reads the edge token from the given directory. It tries +// edge_token first, then cf_token as a legacy fallback. The file content +// is trimmed of surrounding whitespace. +func readEdgeToken(dir string) (string, error) { + // Try primary edge_token file. + primary := filepath.Join(dir, edgeTokenFile) + data, err := os.ReadFile(primary) + if err == nil { + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: edge_token file is empty", ErrTokenLoad) + } + return token, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, edgeTokenFile, err) + } + + // Fallback to legacy cf_token file (only when edge_token is absent). + legacy := filepath.Join(dir, cfTokenFile) + data, err = os.ReadFile(legacy) + if err != nil { + return "", fmt.Errorf("%w: neither edge_token nor cf_token found in gateway directory", ErrTokenLoad) + } + + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: cf_token file is empty", ErrTokenLoad) + } + + return token, nil +} + +// oidcBundle is the on-disk representation of oidc_token.json. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// diskTokenSource implements oauth2.TokenSource by reading oidc_token.json +// from disk on every Token() call. This allows the source to pick up +// tokens refreshed by the Rust CLI without process restart. +type diskTokenSource struct { + dir string +} + +// newDiskTokenSource returns an oauth2.TokenSource that reads +// oidc_token.json from the given gateway directory on each Token() call. +func newDiskTokenSource(dir string) oauth2.TokenSource { + return &diskTokenSource{dir: dir} +} + +// Token reads and parses oidc_token.json, returning an oauth2.Token. +// The file is read on every call to pick up CLI-refreshed tokens. +func (d *diskTokenSource) Token() (*oauth2.Token, error) { + path := filepath.Join(d.dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, oidcTokenFile, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s", ErrTokenLoad, oidcTokenFile) + } + + if bundle.AccessToken == "" { + return nil, fmt.Errorf("%w: missing access_token in %s", ErrTokenLoad, oidcTokenFile) + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry: prefer explicit "expiry" field, fall back to + // "expires_in" (seconds from now). + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr == nil { + tok.Expiry = expiry + } + // If expiry can't be parsed, leave it zero (token treated as + // non-expiring by oauth2). + } else if bundle.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(bundle.ExpiresIn) * time.Second) + } + + return tok, nil +} + +// lazyEdgeAuth implements types.AuthProvider for the cloudflare_jwt auth +// mode. Token loading is deferred to GetRequestMetadata so that +// NewClient succeeds even when the token file is missing on disk (FR-007). +// The error surfaces on the first authentication attempt. +type lazyEdgeAuth struct { + loader *edgeTokenLoader +} + +// GetRequestMetadata loads the edge token lazily and returns it as a +// Bearer authorization header. The first load is cached by the +// underlying edgeTokenLoader. +func (a *lazyEdgeAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + token, err := a.loader.load() + if err != nil { + return nil, err + } + return map[string]string{ + "authorization": "Bearer " + token, + }, nil +} + +// RequireTransportSecurity returns true because Bearer tokens must not +// be sent over plaintext connections. +func (a *lazyEdgeAuth) RequireTransportSecurity() bool { + return true +} diff --git a/sdk/go/openshell/v1/gateway/token_test.go b/sdk/go/openshell/v1/gateway/token_test.go new file mode 100644 index 0000000000..952c1e7d44 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token_test.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T014: Edge token loading tests --- + +func TestReadEdgeToken_PrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "my-edge-token-123") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "my-edge-token-123", token) +} + +func TestReadEdgeToken_PrimaryFileWithWhitespace(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " token-with-whitespace \n") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "token-with-whitespace", token) +} + +func TestReadEdgeToken_CfTokenFallback(t *testing.T) { + dir := t.TempDir() + // No edge_token file, only cf_token. + writeFile(t, dir, cfTokenFile, "legacy-cf-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "legacy-cf-token", token) +} + +func TestReadEdgeToken_PrimaryTakesPrecedence(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "primary-token") + writeFile(t, dir, cfTokenFile, "legacy-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "primary-token", token) +} + +func TestReadEdgeToken_MissingBothFiles(t *testing.T) { + dir := t.TempDir() + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "neither edge_token nor cf_token") +} + +func TestReadEdgeToken_EmptyPrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " \n") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestReadEdgeToken_EmptyFallbackFile(t *testing.T) { + dir := t.TempDir() + // No edge_token, only empty cf_token. + writeFile(t, dir, cfTokenFile, "") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestEdgeTokenLoader_Lazy(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "lazy-token") + + loader := &edgeTokenLoader{dir: dir} + + // First call reads from disk. + token1, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token1) + + // Remove the file. Second call should return cached value. + require.NoError(t, os.Remove(filepath.Join(dir, edgeTokenFile))) + + token2, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token2, "should return cached token") +} + +func TestEdgeTokenLoader_LazyError(t *testing.T) { + dir := t.TempDir() + // No token files exist. + + loader := &edgeTokenLoader{dir: dir} + + // First call fails. + _, err1 := loader.load() + require.Error(t, err1) + + // Write the file now. Second call should return the cached error. + writeFile(t, dir, edgeTokenFile, "late-token") + + _, err2 := loader.load() + require.Error(t, err2, "should return cached error from first attempt") +} + +// --- T015: diskTokenSource tests --- + +func TestDiskTokenSource_ValidBundle(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "`+expiry+`" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestDiskTokenSource_ExpiresIn(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-789", + "expires_in": 3600 + }`) + + before := time.Now() + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-789", tok.AccessToken) + // Expiry should be approximately 1 hour from now. + assert.WithinDuration(t, before.Add(time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_ExpiryPrecedence(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-abc", + "expiry": "`+expiry+`", + "expires_in": 60 + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + // "expiry" field takes precedence over "expires_in". + assert.WithinDuration(t, time.Now().Add(2*time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_NoExpiry(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "no-expiry-token"}`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "no-expiry-token", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "should have zero expiry when not set") +} + +func TestDiskTokenSource_MissingFile(t *testing.T) { + dir := t.TempDir() + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) +} + +func TestDiskTokenSource_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{not valid json}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestDiskTokenSource_MissingAccessToken(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"refresh_token": "only-refresh"}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "missing access_token") +} + +func TestDiskTokenSource_ReReadsOnEachCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v1"}`) + + src := newDiskTokenSource(dir) + tok1, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v1", tok1.AccessToken) + + // Update the file. Next call should read the new value. + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v2"}`) + + tok2, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v2", tok2.AccessToken) +} + +func TestDiskTokenSource_InvalidExpiryFormat(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "token-xyz", + "expiry": "not-a-date" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-xyz", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "unparseable expiry should result in zero time") +} + +// writeFile is a test helper that writes a file with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/grpc_errors.go b/sdk/go/openshell/v1/grpc_errors.go new file mode 100644 index 0000000000..660ff8ef2d --- /dev/null +++ b/sdk/go/openshell/v1/grpc_errors.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package v1 provides the OpenShell SDK client. +// gRPC error conversion is handled by the internal/converter package. +package v1 diff --git a/sdk/go/openshell/v1/health.go b/sdk/go/openshell/v1/health.go new file mode 100644 index 0000000000..c5e62eaa32 --- /dev/null +++ b/sdk/go/openshell/v1/health.go @@ -0,0 +1,18 @@ +// 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" +) + +// HealthResult holds the result of a health check. +type HealthResult = types.HealthResult + +// HealthInterface defines health check operations. +type HealthInterface interface { + Check(ctx context.Context) (*HealthResult, error) +} diff --git a/sdk/go/openshell/v1/health_client.go b/sdk/go/openshell/v1/health_client.go new file mode 100644 index 0000000000..1722f8520c --- /dev/null +++ b/sdk/go/openshell/v1/health_client.go @@ -0,0 +1,32 @@ +// 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 healthClient struct { + client pb.OpenShellClient +} + +func newHealthClient(conn grpc.ClientConnInterface) *healthClient { + return &healthClient{client: pb.NewOpenShellClient(conn)} +} + +func (h *healthClient) Check(ctx context.Context) (*HealthResult, error) { + resp, err := h.client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + return &HealthResult{ + Healthy: resp.GetStatus() == pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + Version: resp.GetVersion(), + }, nil +} diff --git a/sdk/go/openshell/v1/health_client_test.go b/sdk/go/openshell/v1/health_client_test.go new file mode 100644 index 0000000000..34cf1d6ece --- /dev/null +++ b/sdk/go/openshell/v1/health_client_test.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + 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" +) + +const bufSize = 1024 * 1024 + +type mockHealthServer struct { + pb.UnimplementedOpenShellServer + status pb.ServiceStatus + version string + err error +} + +func (s *mockHealthServer) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + if s.err != nil { + return nil, s.err + } + return &pb.HealthResponse{ + Status: s.status, + Version: s.version, + }, nil +} + +func newMockHealthServer(s pb.ServiceStatus, version string, err error) (*grpc.ClientConn, func()) { + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, &mockHealthServer{status: s, version: version, err: err}) + + go func() { _ = srv.Serve(lis) }() + + conn, err2 := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + + if err2 != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err2.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestHealthCheck_Success(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_HEALTHY, "1.2.3", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "1.2.3", result.Version) +} + +func TestHealthCheck_Degraded(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_DEGRADED, "2.0.0", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "2.0.0", result.Version) +} + +func TestHealthCheck_Unhealthy(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_UNHEALTHY, "3.0.0", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "3.0.0", result.Version) +} + +func TestHealthCheck_Unavailable(t *testing.T) { + conn, cleanup := newMockHealthServer(0, "", status.Error(codes.Unavailable, "service down")) + defer cleanup() + + h := newHealthClient(conn) + _, err := h.Check(context.Background()) + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/integration_test.go b/sdk/go/openshell/v1/integration_test.go new file mode 100644 index 0000000000..314f1aee91 --- /dev/null +++ b/sdk/go/openshell/v1/integration_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package v1 + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func gatewayAddress(t *testing.T) string { + t.Helper() + addr := os.Getenv("OPENSHELL_GATEWAY_ADDRESS") + if addr == "" { + t.Skip("OPENSHELL_GATEWAY_ADDRESS not set") + } + return addr +} + +func TestIntegration_HealthCheck(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + _, err = client.Health().Check(context.Background()) + require.NoError(t, err) +} + +func TestIntegration_ProviderLifecycle(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement provider create/get/list/delete integration test") +} + +func TestIntegration_SandboxLifecycle(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement sandbox create/wait-ready/delete integration test") +} + +func TestIntegration_ExecRun(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement exec run integration test") +} + +func TestIntegration_FileTransfer(t *testing.T) { + addr := gatewayAddress(t) + + client, err := NewClient(Config{Address: addr}) + require.NoError(t, err) + defer client.Close() + + t.Skip("TODO: implement file upload/download integration test") +} diff --git a/sdk/go/openshell/v1/internal/converter/copy.go b/sdk/go/openshell/v1/internal/converter/copy.go new file mode 100644 index 0000000000..e2523a61c3 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/copy.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +// CopyStringMap returns a shallow copy of a string-to-string map. +// Returns nil for nil input. +func CopyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + c := make(map[string]string, len(m)) + for k, v := range m { + c[k] = v + } + return c +} + +// CopyBoolPtr returns a copy of a *bool pointer. +// Returns nil for nil input. +func CopyBoolPtr(p *bool) *bool { + if p == nil { + return nil + } + v := *p + return &v +} + +// CopyStringSlice returns a copy of a string slice. +// Returns nil for nil input. +func CopyStringSlice(s []string) []string { + if s == nil { + return nil + } + c := make([]string, len(s)) + copy(c, s) + return c +} + +// CopyByteSlice returns a copy of a byte slice. +// Returns nil for nil input. +func CopyByteSlice(b []byte) []byte { + if b == nil { + return nil + } + c := make([]byte, len(b)) + copy(c, b) + return c +} + +func boolCount(flags ...bool) int { + n := 0 + for _, f := range flags { + if f { + n++ + } + } + return n +} diff --git a/sdk/go/openshell/v1/internal/converter/errors.go b/sdk/go/openshell/v1/internal/converter/errors.go new file mode 100644 index 0000000000..01d2b569b1 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/errors.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package converter maps between gRPC/proto types and SDK domain types. +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var grpcToSDK = map[codes.Code]types.ErrorCode{ + codes.NotFound: types.ErrorNotFound, + codes.AlreadyExists: types.ErrorAlreadyExists, + codes.Unavailable: types.ErrorUnavailable, + codes.PermissionDenied: types.ErrorPermissionDenied, + codes.InvalidArgument: types.ErrorInvalidArgument, + codes.DeadlineExceeded: types.ErrorDeadlineExceeded, + codes.Canceled: types.ErrorCancelled, + codes.Internal: types.ErrorInternal, + codes.Unimplemented: types.ErrorUnimplemented, + codes.Aborted: types.ErrorConflict, +} + +// FromGRPCError converts a gRPC error to a typed StatusError. +// Returns nil for nil errors and OK status. Non-gRPC errors pass through unchanged. +func FromGRPCError(err error) error { + if err == nil { + return nil + } + + st, ok := status.FromError(err) + if !ok { + return err + } + + if st.Code() == codes.OK { + return nil + } + + code, mapped := grpcToSDK[st.Code()] + if !mapped { + code = types.ErrorInternal + } + + return &types.StatusError{ + Code: code, + Message: st.Message(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/errors_test.go b/sdk/go/openshell/v1/internal/converter/errors_test.go new file mode 100644 index 0000000000..c7238eaae5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/errors_test.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestFromGRPCError_NotFound(t *testing.T) { + grpcErr := status.Error(codes.NotFound, "sandbox not found") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsNotFound(err)) +} + +func TestFromGRPCError_AlreadyExists(t *testing.T) { + grpcErr := status.Error(codes.AlreadyExists, "already exists") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsAlreadyExists(err)) +} + +func TestFromGRPCError_Unavailable(t *testing.T) { + grpcErr := status.Error(codes.Unavailable, "service down") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsUnavailable(err)) +} + +func TestFromGRPCError_PermissionDenied(t *testing.T) { + grpcErr := status.Error(codes.PermissionDenied, "denied") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsPermissionDenied(err)) +} + +func TestFromGRPCError_InvalidArgument(t *testing.T) { + grpcErr := status.Error(codes.InvalidArgument, "bad arg") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsInvalidArgument(err)) +} + +func TestFromGRPCError_DeadlineExceeded(t *testing.T) { + grpcErr := status.Error(codes.DeadlineExceeded, "timeout") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsDeadlineExceeded(err)) +} + +func TestFromGRPCError_Cancelled(t *testing.T) { + grpcErr := status.Error(codes.Canceled, "cancelled") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsCancelled(err)) +} + +func TestFromGRPCError_Internal(t *testing.T) { + grpcErr := status.Error(codes.Internal, "internal error") + err := FromGRPCError(grpcErr) + require.Error(t, err) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorInternal, se.Code) +} + +func TestFromGRPCError_Unimplemented(t *testing.T) { + grpcErr := status.Error(codes.Unimplemented, "not implemented") + err := FromGRPCError(grpcErr) + require.Error(t, err) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorUnimplemented, se.Code) +} + +func TestFromGRPCError_Aborted(t *testing.T) { + grpcErr := status.Error(codes.Aborted, "version conflict") + err := FromGRPCError(grpcErr) + require.Error(t, err) + assert.True(t, v1.IsConflict(err)) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorConflict, se.Code) + assert.Equal(t, "version conflict", se.Message) +} + +func TestFromGRPCError_UnmappedCode(t *testing.T) { + grpcErr := status.Error(codes.DataLoss, "data loss") + err := FromGRPCError(grpcErr) + require.Error(t, err) + + var se *v1.StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, v1.ErrorInternal, se.Code) +} + +func TestFromGRPCError_NilError(t *testing.T) { + err := FromGRPCError(nil) + assert.NoError(t, err) +} + +func TestFromGRPCError_NonGRPCError(t *testing.T) { + err := FromGRPCError(assert.AnError) + require.Error(t, err) + assert.Equal(t, assert.AnError, err) +} + +func TestFromGRPCError_OKStatus(t *testing.T) { + grpcErr := status.Error(codes.OK, "") + err := FromGRPCError(grpcErr) + assert.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/internal/converter/exec.go b/sdk/go/openshell/v1/internal/converter/exec.go new file mode 100644 index 0000000000..bb509cd613 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/exec.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "fmt" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// ExecChunkFromEvent converts a proto ExecSandboxEvent to an ExecChunk and/or exit code. +// For stdout/stderr events, returns the chunk with exitCode -1. +// For exit events, returns nil chunk with the exit code. +func ExecChunkFromEvent(event *pb.ExecSandboxEvent) (*types.ExecChunk, int, error) { + if event == nil { + return nil, -1, fmt.Errorf("nil exec event") + } + + switch p := event.Payload.(type) { + case *pb.ExecSandboxEvent_Stdout: + return &types.ExecChunk{ + Stream: types.StreamStdout, + Data: append([]byte(nil), p.Stdout.GetData()...), + }, -1, nil + case *pb.ExecSandboxEvent_Stderr: + return &types.ExecChunk{ + Stream: types.StreamStderr, + Data: append([]byte(nil), p.Stderr.GetData()...), + }, -1, nil + case *pb.ExecSandboxEvent_Exit: + return nil, int(p.Exit.GetExitCode()), nil + default: + return nil, -1, fmt.Errorf("unknown exec event payload type: %T", p) + } +} + +// ExecRequestToProto builds a proto ExecSandboxRequest for Run/Stream modes. +func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := &pb.ExecSandboxRequest{ + SandboxId: sandboxID, + Command: command, + } + if opts != nil { + req.Workdir = opts.WorkDir + req.Environment = opts.Env + } + return req +} + +// ExecInteractiveRequestToProto builds a proto ExecSandboxRequest for Interactive mode. +func ExecInteractiveRequestToProto(sandboxID string, command []string, cols, rows uint32, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := ExecRequestToProto(sandboxID, command, opts) + req.Tty = true + req.Cols = cols + req.Rows = rows + return req +} + +// ExecResultFromEvents collects a sequence of ExecSandboxEvents into an ExecResult. +func ExecResultFromEvents(events []*pb.ExecSandboxEvent) (*types.ExecResult, error) { + if len(events) == 0 { + return nil, fmt.Errorf("no exec events received") + } + + var stdout, stderr []byte + exitCode := -1 + + for _, event := range events { + chunk, code, err := ExecChunkFromEvent(event) + if err != nil { + return nil, err + } + if chunk != nil { + switch chunk.Stream { + case types.StreamStdout: + stdout = append(stdout, chunk.Data...) + case types.StreamStderr: + stderr = append(stderr, chunk.Data...) + } + } else { + exitCode = code + } + } + + if exitCode == -1 { + return nil, fmt.Errorf("no exit event received") + } + + return &types.ExecResult{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + }, nil +} diff --git a/sdk/go/openshell/v1/internal/converter/exec_test.go b/sdk/go/openshell/v1/internal/converter/exec_test.go new file mode 100644 index 0000000000..078a72d4d1 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/exec_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecChunkFromEvent_Stdout(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stdout{ + Stdout: &pb.ExecSandboxStdout{ + Data: []byte("hello world"), + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStdout, chunk.Stream) + assert.Equal(t, []byte("hello world"), chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecChunkFromEvent_Stderr(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stderr{ + Stderr: &pb.ExecSandboxStderr{ + Data: []byte("error output"), + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStderr, chunk.Stream) + assert.Equal(t, []byte("error output"), chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecChunkFromEvent_Exit(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Exit{ + Exit: &pb.ExecSandboxExit{ + ExitCode: 42, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + assert.Nil(t, chunk) + assert.Equal(t, 42, exitCode) +} + +func TestExecChunkFromEvent_ExitZero(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Exit{ + Exit: &pb.ExecSandboxExit{ + ExitCode: 0, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + assert.Nil(t, chunk) + assert.Equal(t, 0, exitCode) +} + +func TestExecChunkFromEvent_NilEvent(t *testing.T) { + _, _, err := ExecChunkFromEvent(nil) + assert.Error(t, err) +} + +func TestExecChunkFromEvent_NilPayload(t *testing.T) { + event := &pb.ExecSandboxEvent{} + + _, _, err := ExecChunkFromEvent(event) + assert.Error(t, err) +} + +func TestExecChunkFromEvent_EmptyStdout(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stdout{ + Stdout: &pb.ExecSandboxStdout{ + Data: []byte{}, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStdout, chunk.Stream) + assert.Empty(t, chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecRequestToProto(t *testing.T) { + req := ExecRequestToProto("sb-1", []string{"ls", "-la"}, &v1.ExecOptions{ + Env: map[string]string{"FOO": "bar"}, + WorkDir: "/home/user", + }) + + require.NotNil(t, req) + assert.Equal(t, "sb-1", req.SandboxId) + assert.Equal(t, []string{"ls", "-la"}, req.Command) + assert.Equal(t, "/home/user", req.Workdir) + assert.Equal(t, map[string]string{"FOO": "bar"}, req.Environment) + assert.False(t, req.Tty) +} + +func TestExecRequestToProto_NilOptions(t *testing.T) { + req := ExecRequestToProto("sb-2", []string{"echo", "hi"}, nil) + + require.NotNil(t, req) + assert.Equal(t, "sb-2", req.SandboxId) + assert.Equal(t, []string{"echo", "hi"}, req.Command) + assert.Empty(t, req.Workdir) + assert.Nil(t, req.Environment) +} + +func TestExecRequestToProto_Interactive(t *testing.T) { + req := ExecInteractiveRequestToProto("sb-3", []string{"/bin/bash"}, 80, 24, &v1.ExecOptions{ + Env: map[string]string{"TERM": "xterm"}, + WorkDir: "/root", + }) + + require.NotNil(t, req) + assert.Equal(t, "sb-3", req.SandboxId) + assert.Equal(t, []string{"/bin/bash"}, req.Command) + assert.Equal(t, "/root", req.Workdir) + assert.Equal(t, map[string]string{"TERM": "xterm"}, req.Environment) + assert.True(t, req.Tty) + assert.Equal(t, uint32(80), req.Cols) + assert.Equal(t, uint32(24), req.Rows) +} + +func TestExecResultFromEvents(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + + result, err := ExecResultFromEvents(events) + + require.NoError(t, err) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, []byte("line1\nline2\n"), result.Stdout) + assert.Equal(t, []byte("warn\n"), result.Stderr) +} + +func TestExecResultFromEvents_NoExit(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("data")}}}, + } + + _, err := ExecResultFromEvents(events) + assert.Error(t, err) +} + +func TestExecResultFromEvents_Empty(t *testing.T) { + _, err := ExecResultFromEvents(nil) + assert.Error(t, err) +} + +func TestExecResultFromEvents_OnlyExit(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + } + + result, err := ExecResultFromEvents(events) + + require.NoError(t, err) + assert.Equal(t, 1, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Empty(t, result.Stderr) +} diff --git a/sdk/go/openshell/v1/internal/converter/log.go b/sdk/go/openshell/v1/internal/converter/log.go new file mode 100644 index 0000000000..42f530fb1c --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/log.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// --- LogLine --- + +// LogLineFromProto converts a proto SandboxLogLine to an SDK LogLine. +func LogLineFromProto(l *pb.SandboxLogLine) *types.LogLine { + if l == nil { + return nil + } + return &types.LogLine{ + Timestamp: TimeFromMillis(l.GetTimestampMs()), + Level: l.GetLevel(), + Target: l.GetTarget(), + Message: l.GetMessage(), + Source: l.GetSource(), + Fields: CopyStringMap(l.GetFields()), + } +} + +// --- LogResult --- + +// LogResultFromProto converts a proto GetSandboxLogsResponse to an SDK LogResult. +func LogResultFromProto(r *pb.GetSandboxLogsResponse) *types.LogResult { + if r == nil { + return nil + } + result := &types.LogResult{ + BufferTotal: r.GetBufferTotal(), + } + if logs := r.GetLogs(); len(logs) > 0 { + result.Lines = make([]types.LogLine, 0, len(logs)) + for _, l := range logs { + if converted := LogLineFromProto(l); converted != nil { + result.Lines = append(result.Lines, *converted) + } + } + } + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/log_test.go b/sdk/go/openshell/v1/internal/converter/log_test.go new file mode 100644 index 0000000000..7462396262 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/log_test.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- LogLine --- + +func TestLogLineFromProto(t *testing.T) { + proto := &pb.SandboxLogLine{ + SandboxId: "sbx-1", + TimestampMs: 1700000000000, + Level: "INFO", + Target: "network", + Message: "Connection established", + Source: "sandbox-agent", + Fields: map[string]string{ + "host": "api.example.com", + "port": "443", + }, + } + + line := LogLineFromProto(proto) + + require.NotNil(t, line) + assert.False(t, line.Timestamp.IsZero()) + assert.Equal(t, "INFO", line.Level) + assert.Equal(t, "network", line.Target) + assert.Equal(t, "Connection established", line.Message) + assert.Equal(t, "sandbox-agent", line.Source) + assert.Equal(t, "api.example.com", line.Fields["host"]) + assert.Equal(t, "443", line.Fields["port"]) +} + +func TestLogLineFromProto_Nil(t *testing.T) { + assert.Nil(t, LogLineFromProto(nil)) +} + +func TestLogLineDeepCopy(t *testing.T) { + proto := &pb.SandboxLogLine{ + TimestampMs: 1700000000000, + Level: "WARN", + Message: "test", + Fields: map[string]string{ + "key": "value", + }, + } + + line := LogLineFromProto(proto) + proto.Fields["key"] = "changed" + + assert.Equal(t, "value", line.Fields["key"]) +} + +// --- LogResult --- + +func TestLogResultFromProto(t *testing.T) { + proto := &pb.GetSandboxLogsResponse{ + Logs: []*pb.SandboxLogLine{ + {TimestampMs: 1700000000000, Level: "INFO", Message: "first"}, + {TimestampMs: 1700000001000, Level: "DEBUG", Message: "second"}, + }, + BufferTotal: 100, + } + + result := LogResultFromProto(proto) + + require.NotNil(t, result) + assert.Len(t, result.Lines, 2) + assert.Equal(t, "INFO", result.Lines[0].Level) + assert.Equal(t, "first", result.Lines[0].Message) + assert.Equal(t, "DEBUG", result.Lines[1].Level) + assert.Equal(t, "second", result.Lines[1].Message) + assert.Equal(t, uint32(100), result.BufferTotal) +} + +func TestLogResultFromProto_Nil(t *testing.T) { + assert.Nil(t, LogResultFromProto(nil)) +} + +func TestLogResultFromProto_EmptyLogs(t *testing.T) { + proto := &pb.GetSandboxLogsResponse{ + BufferTotal: 0, + } + + result := LogResultFromProto(proto) + require.NotNil(t, result) + assert.Empty(t, result.Lines) + assert.Equal(t, uint32(0), result.BufferTotal) +} diff --git a/sdk/go/openshell/v1/internal/converter/network_policy.go b/sdk/go/openshell/v1/internal/converter/network_policy.go new file mode 100644 index 0000000000..1bf7d30d7e --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/network_policy.go @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- NetworkPolicyRule --- + +// NetworkPolicyRuleFromProto converts a proto NetworkPolicyRule to an SDK NetworkPolicyRule. +func NetworkPolicyRuleFromProto(r *sbv1.NetworkPolicyRule) *types.NetworkPolicyRule { + if r == nil { + return nil + } + result := &types.NetworkPolicyRule{ + Name: r.GetName(), + } + if eps := r.GetEndpoints(); len(eps) > 0 { + result.Endpoints = make([]types.PolicyNetworkEndpoint, len(eps)) + for i, ep := range eps { + if ep != nil { + result.Endpoints[i] = policyNetworkEndpointFromProto(ep) + } + } + } + if bins := r.GetBinaries(); len(bins) > 0 { + result.Binaries = make([]types.PolicyNetworkBinary, len(bins)) + for i, b := range bins { + if b != nil { + result.Binaries[i] = types.PolicyNetworkBinary{Path: b.GetPath()} + } + } + } + return result +} + +// NetworkPolicyRuleToProto converts an SDK NetworkPolicyRule to a proto NetworkPolicyRule. +func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *sbv1.NetworkPolicyRule { + if r == nil { + return nil + } + result := &sbv1.NetworkPolicyRule{ + Name: r.Name, + } + if len(r.Endpoints) > 0 { + result.Endpoints = make([]*sbv1.NetworkEndpoint, len(r.Endpoints)) + for i := range r.Endpoints { + result.Endpoints[i] = policyNetworkEndpointToProto(&r.Endpoints[i]) + } + } + if len(r.Binaries) > 0 { + result.Binaries = make([]*sbv1.NetworkBinary, len(r.Binaries)) + for i := range r.Binaries { + result.Binaries[i] = &sbv1.NetworkBinary{Path: r.Binaries[i].Path} + } + } + return result +} + +// --- PolicyNetworkEndpoint --- + +func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetworkEndpoint { + result := types.PolicyNetworkEndpoint{ + Host: ep.GetHost(), + Port: ep.GetPort(), + Protocol: ep.GetProtocol(), + TLS: ep.GetTls(), + Enforcement: ep.GetEnforcement(), + Access: ep.GetAccess(), + AllowEncodedSlash: ep.GetAllowEncodedSlash(), + PersistedQueries: ep.GetPersistedQueries(), + GraphqlMaxBodyBytes: ep.GetGraphqlMaxBodyBytes(), + Path: ep.GetPath(), + WebsocketCredentialRewrite: ep.GetWebsocketCredentialRewrite(), + RequestBodyCredentialRewrite: ep.GetRequestBodyCredentialRewrite(), + AdvisorProposed: ep.GetAdvisorProposed(), + } + if ports := ep.GetPorts(); len(ports) > 0 { + result.Ports = make([]uint32, len(ports)) + copy(result.Ports, ports) + } + if ips := ep.GetAllowedIps(); len(ips) > 0 { + result.AllowedIPs = CopyStringSlice(ips) + } + if rules := ep.GetRules(); len(rules) > 0 { + result.Rules = make([]types.L7Rule, len(rules)) + for i, r := range rules { + if r != nil { + result.Rules[i] = l7RuleFromProto(r) + } + } + } + if deny := ep.GetDenyRules(); len(deny) > 0 { + result.DenyRules = make([]types.L7DenyRule, len(deny)) + for i, r := range deny { + if r != nil { + result.DenyRules[i] = l7DenyRuleFromProto(r) + } + } + } + if gql := ep.GetGraphqlPersistedQueries(); len(gql) > 0 { + result.GraphqlPersistedQueries = make(map[string]types.GraphqlOperation, len(gql)) + for k, v := range gql { + if v != nil { + result.GraphqlPersistedQueries[k] = graphqlOperationFromProto(v) + } + } + } + return result +} + +func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.NetworkEndpoint { + result := &sbv1.NetworkEndpoint{ + Host: ep.Host, + Port: ep.Port, + Protocol: ep.Protocol, + Tls: ep.TLS, + Enforcement: ep.Enforcement, + Access: ep.Access, + AllowEncodedSlash: ep.AllowEncodedSlash, + PersistedQueries: ep.PersistedQueries, + GraphqlMaxBodyBytes: ep.GraphqlMaxBodyBytes, + Path: ep.Path, + WebsocketCredentialRewrite: ep.WebsocketCredentialRewrite, + RequestBodyCredentialRewrite: ep.RequestBodyCredentialRewrite, + AdvisorProposed: ep.AdvisorProposed, + } + if len(ep.Ports) > 0 { + result.Ports = make([]uint32, len(ep.Ports)) + copy(result.Ports, ep.Ports) + } + if len(ep.AllowedIPs) > 0 { + result.AllowedIps = CopyStringSlice(ep.AllowedIPs) + } + if len(ep.Rules) > 0 { + result.Rules = make([]*sbv1.L7Rule, len(ep.Rules)) + for i := range ep.Rules { + result.Rules[i] = l7RuleToProto(&ep.Rules[i]) + } + } + if len(ep.DenyRules) > 0 { + result.DenyRules = make([]*sbv1.L7DenyRule, len(ep.DenyRules)) + for i := range ep.DenyRules { + result.DenyRules[i] = l7DenyRuleToProto(&ep.DenyRules[i]) + } + } + if len(ep.GraphqlPersistedQueries) > 0 { + result.GraphqlPersistedQueries = make(map[string]*sbv1.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + for k, v := range ep.GraphqlPersistedQueries { + result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v) + } + } + return result +} + +// --- L7Rule --- + +func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { + result := types.L7Rule{} + if a := r.GetAllow(); a != nil { + result.Allow = &types.L7Allow{ + Method: a.GetMethod(), + Path: a.GetPath(), + Command: a.GetCommand(), + OperationType: a.GetOperationType(), + OperationName: a.GetOperationName(), + Fields: CopyStringSlice(a.GetFields()), + } + if q := a.GetQuery(); len(q) > 0 { + result.Allow.Query = l7QueryMapFromProto(q) + } + } + return result +} + +func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { + result := &sbv1.L7Rule{} + if r.Allow != nil { + result.Allow = &sbv1.L7Allow{ + Method: r.Allow.Method, + Path: r.Allow.Path, + Command: r.Allow.Command, + OperationType: r.Allow.OperationType, + OperationName: r.Allow.OperationName, + Fields: CopyStringSlice(r.Allow.Fields), + } + if len(r.Allow.Query) > 0 { + result.Allow.Query = l7QueryMapToProto(r.Allow.Query) + } + } + return result +} + +// --- L7DenyRule --- + +func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule { + return types.L7DenyRule{ + Method: r.GetMethod(), + Path: r.GetPath(), + Command: r.GetCommand(), + OperationType: r.GetOperationType(), + OperationName: r.GetOperationName(), + Fields: CopyStringSlice(r.GetFields()), + Query: l7QueryMapFromProtoDeny(r.GetQuery()), + } +} + +func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { + result := &sbv1.L7DenyRule{ + Method: r.Method, + Path: r.Path, + Command: r.Command, + OperationType: r.OperationType, + OperationName: r.OperationName, + Fields: CopyStringSlice(r.Fields), + } + if len(r.Query) > 0 { + result.Query = l7QueryMapToProtoDeny(r.Query) + } + return result +} + +// --- L7QueryMatcher helpers --- + +func l7QueryMapFromProto(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher { + if len(m) == 0 { + return nil + } + result := make(map[string]types.L7QueryMatcher, len(m)) + for k, v := range m { + if v != nil { + result[k] = types.L7QueryMatcher{ + Glob: v.GetGlob(), + Any: CopyStringSlice(v.GetAny()), + } + } + } + return result +} + +func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher { + if len(m) == 0 { + return nil + } + result := make(map[string]*sbv1.L7QueryMatcher, len(m)) + for k, v := range m { + result[k] = &sbv1.L7QueryMatcher{ + Glob: v.Glob, + Any: CopyStringSlice(v.Any), + } + } + return result +} + +// L7DenyRule uses the same L7QueryMatcher proto type but on a different message. +func l7QueryMapFromProtoDeny(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher { + return l7QueryMapFromProto(m) +} + +func l7QueryMapToProtoDeny(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher { + return l7QueryMapToProto(m) +} + +// --- GraphqlOperation --- + +func graphqlOperationFromProto(op *sbv1.GraphqlOperation) types.GraphqlOperation { + return types.GraphqlOperation{ + OperationType: op.GetOperationType(), + OperationName: op.GetOperationName(), + Fields: CopyStringSlice(op.GetFields()), + } +} + +func graphqlOperationToProto(op *types.GraphqlOperation) *sbv1.GraphqlOperation { + return &sbv1.GraphqlOperation{ + OperationType: op.OperationType, + OperationName: op.OperationName, + Fields: CopyStringSlice(op.Fields), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/network_policy_test.go b/sdk/go/openshell/v1/internal/converter/network_policy_test.go new file mode 100644 index 0000000000..8916bccb9b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/network_policy_test.go @@ -0,0 +1,272 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- NetworkPolicyRule round-trip --- + +func TestNetworkPolicyRuleFromProto(t *testing.T) { + proto := &sbv1.NetworkPolicyRule{ + Name: "web-api", + Endpoints: []*sbv1.NetworkEndpoint{ + { + Host: "api.example.com", + Port: 443, + Protocol: "rest", + Tls: "strict", + Enforcement: "enforce", + Access: "allow", + Ports: []uint32{80, 443}, + AllowedIps: []string{"10.0.0.1", "10.0.0.2"}, + AllowEncodedSlash: true, + PersistedQueries: "allow", + GraphqlMaxBodyBytes: 1024, + Path: "/api/v1", + WebsocketCredentialRewrite: true, + RequestBodyCredentialRewrite: false, + AdvisorProposed: true, + Rules: []*sbv1.L7Rule{ + { + Allow: &sbv1.L7Allow{ + Method: "GET", + Path: "/users", + Command: "list", + Query: map[string]*sbv1.L7QueryMatcher{ + "page": {Glob: "[0-9]*", Any: []string{"1", "2"}}, + }, + OperationType: "query", + OperationName: "GetUsers", + Fields: []string{"id", "name"}, + }, + }, + }, + DenyRules: []*sbv1.L7DenyRule{ + { + Method: "DELETE", + Path: "/admin", + Command: "rm", + OperationType: "mutation", + OperationName: "DeleteAll", + Fields: []string{"*"}, + Query: map[string]*sbv1.L7QueryMatcher{ + "force": {Glob: "true"}, + }, + }, + }, + GraphqlPersistedQueries: map[string]*sbv1.GraphqlOperation{ + "abc123": { + OperationType: "query", + OperationName: "GetUser", + Fields: []string{"id", "email"}, + }, + }, + }, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + } + + rule := NetworkPolicyRuleFromProto(proto) + + require.NotNil(t, rule) + assert.Equal(t, "web-api", rule.Name) + require.Len(t, rule.Endpoints, 1) + ep := rule.Endpoints[0] + assert.Equal(t, "api.example.com", ep.Host) + assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, "rest", ep.Protocol) + assert.Equal(t, "strict", ep.TLS) + assert.Equal(t, "enforce", ep.Enforcement) + assert.Equal(t, "allow", ep.Access) + assert.Equal(t, []uint32{80, 443}, ep.Ports) + assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, ep.AllowedIPs) + assert.True(t, ep.AllowEncodedSlash) + assert.Equal(t, "allow", ep.PersistedQueries) + assert.Equal(t, uint32(1024), ep.GraphqlMaxBodyBytes) + assert.Equal(t, "/api/v1", ep.Path) + assert.True(t, ep.WebsocketCredentialRewrite) + assert.False(t, ep.RequestBodyCredentialRewrite) + assert.True(t, ep.AdvisorProposed) + + // L7 rules + require.Len(t, ep.Rules, 1) + allow := ep.Rules[0].Allow + require.NotNil(t, allow) + assert.Equal(t, "GET", allow.Method) + assert.Equal(t, "/users", allow.Path) + assert.Equal(t, "list", allow.Command) + assert.Equal(t, "query", allow.OperationType) + assert.Equal(t, "GetUsers", allow.OperationName) + assert.Equal(t, []string{"id", "name"}, allow.Fields) + require.Contains(t, allow.Query, "page") + assert.Equal(t, "[0-9]*", allow.Query["page"].Glob) + assert.Equal(t, []string{"1", "2"}, allow.Query["page"].Any) + + // Deny rules + require.Len(t, ep.DenyRules, 1) + deny := ep.DenyRules[0] + assert.Equal(t, "DELETE", deny.Method) + assert.Equal(t, "/admin", deny.Path) + assert.Equal(t, "rm", deny.Command) + assert.Equal(t, "mutation", deny.OperationType) + assert.Equal(t, "DeleteAll", deny.OperationName) + assert.Equal(t, []string{"*"}, deny.Fields) + require.Contains(t, deny.Query, "force") + assert.Equal(t, "true", deny.Query["force"].Glob) + + // GraphQL persisted queries + require.Contains(t, ep.GraphqlPersistedQueries, "abc123") + gql := ep.GraphqlPersistedQueries["abc123"] + assert.Equal(t, "query", gql.OperationType) + assert.Equal(t, "GetUser", gql.OperationName) + assert.Equal(t, []string{"id", "email"}, gql.Fields) + + // Binaries + require.Len(t, rule.Binaries, 1) + assert.Equal(t, "/usr/bin/curl", rule.Binaries[0].Path) +} + +func TestNetworkPolicyRuleFromProto_Nil(t *testing.T) { + assert.Nil(t, NetworkPolicyRuleFromProto(nil)) +} + +func TestNetworkPolicyRuleRoundTrip(t *testing.T) { + original := &v1.NetworkPolicyRule{ + Name: "graphql-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + { + Host: "gql.example.com", + Port: 8080, + Protocol: "graphql", + TLS: "permissive", + Enforcement: "audit", + Access: "allow", + Ports: []uint32{8080, 8443}, + AllowedIPs: []string{"192.168.1.0/24"}, + AllowEncodedSlash: false, + PersistedQueries: "enforce", + GraphqlMaxBodyBytes: 2048, + Path: "/graphql", + WebsocketCredentialRewrite: false, + RequestBodyCredentialRewrite: true, + AdvisorProposed: false, + Rules: []v1.L7Rule{ + { + Allow: &v1.L7Allow{ + Method: "POST", + Path: "/graphql", + OperationType: "query", + OperationName: "ListItems", + Fields: []string{"id"}, + Query: map[string]v1.L7QueryMatcher{ + "limit": {Glob: "[0-9]+"}, + }, + }, + }, + }, + DenyRules: []v1.L7DenyRule{ + { + Method: "POST", + Path: "/graphql", + OperationType: "mutation", + OperationName: "DropDB", + }, + }, + GraphqlPersistedQueries: map[string]v1.GraphqlOperation{ + "hash1": { + OperationType: "query", + OperationName: "Safe", + Fields: []string{"f1"}, + }, + }, + }, + }, + Binaries: []v1.PolicyNetworkBinary{ + {Path: "/usr/bin/wget"}, + }, + } + + proto := NetworkPolicyRuleToProto(original) + require.NotNil(t, proto) + + roundTrip := NetworkPolicyRuleFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Name, roundTrip.Name) + require.Len(t, roundTrip.Endpoints, 1) + assert.Equal(t, original.Endpoints[0].Host, roundTrip.Endpoints[0].Host) + assert.Equal(t, original.Endpoints[0].Port, roundTrip.Endpoints[0].Port) + assert.Equal(t, original.Endpoints[0].Protocol, roundTrip.Endpoints[0].Protocol) + assert.Equal(t, original.Endpoints[0].TLS, roundTrip.Endpoints[0].TLS) + assert.Equal(t, original.Endpoints[0].Enforcement, roundTrip.Endpoints[0].Enforcement) + assert.Equal(t, original.Endpoints[0].Access, roundTrip.Endpoints[0].Access) + assert.Equal(t, original.Endpoints[0].Ports, roundTrip.Endpoints[0].Ports) + assert.Equal(t, original.Endpoints[0].AllowedIPs, roundTrip.Endpoints[0].AllowedIPs) + assert.Equal(t, original.Endpoints[0].AllowEncodedSlash, roundTrip.Endpoints[0].AllowEncodedSlash) + assert.Equal(t, original.Endpoints[0].GraphqlMaxBodyBytes, roundTrip.Endpoints[0].GraphqlMaxBodyBytes) + assert.Equal(t, original.Endpoints[0].AdvisorProposed, roundTrip.Endpoints[0].AdvisorProposed) + + // L7 rules round-trip + require.Len(t, roundTrip.Endpoints[0].Rules, 1) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Method, roundTrip.Endpoints[0].Rules[0].Allow.Method) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.OperationName, roundTrip.Endpoints[0].Rules[0].Allow.OperationName) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Query["limit"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Query["limit"].Glob) + + // Deny rules round-trip + require.Len(t, roundTrip.Endpoints[0].DenyRules, 1) + assert.Equal(t, original.Endpoints[0].DenyRules[0].OperationName, roundTrip.Endpoints[0].DenyRules[0].OperationName) + + // GraphQL persisted queries round-trip + require.Contains(t, roundTrip.Endpoints[0].GraphqlPersistedQueries, "hash1") + + // Binaries round-trip + require.Len(t, roundTrip.Binaries, 1) + assert.Equal(t, original.Binaries[0].Path, roundTrip.Binaries[0].Path) +} + +func TestNetworkPolicyRuleToProto_Nil(t *testing.T) { + assert.Nil(t, NetworkPolicyRuleToProto(nil)) +} + +func TestNetworkPolicyRuleDeepCopy(t *testing.T) { + proto := &sbv1.NetworkPolicyRule{ + Name: "test", + Endpoints: []*sbv1.NetworkEndpoint{ + { + AllowedIps: []string{"1.2.3.4"}, + Ports: []uint32{80}, + Rules: []*sbv1.L7Rule{ + {Allow: &sbv1.L7Allow{Fields: []string{"f1"}}}, + }, + }, + }, + } + + rule := NetworkPolicyRuleFromProto(proto) + + // Mutate proto source + proto.Endpoints[0].AllowedIps[0] = "changed" + proto.Endpoints[0].Ports[0] = 9999 + proto.Endpoints[0].Rules[0].Allow.Fields[0] = "changed" + + // SDK type should be unaffected + assert.Equal(t, "1.2.3.4", rule.Endpoints[0].AllowedIPs[0]) + assert.Equal(t, uint32(80), rule.Endpoints[0].Ports[0]) + assert.Equal(t, "f1", rule.Endpoints[0].Rules[0].Allow.Fields[0]) +} + +func TestL7RuleFromProto_NilAllow(t *testing.T) { + proto := &sbv1.L7Rule{Allow: nil} + result := l7RuleFromProto(proto) + assert.Nil(t, result.Allow) +} diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go new file mode 100644 index 0000000000..780fadb56c --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- PolicyLoadStatus enum mapping --- + +// PolicyLoadStatusFromProto converts a proto PolicyStatus to an SDK PolicyLoadStatus. +func PolicyLoadStatusFromProto(s pb.PolicyStatus) types.PolicyLoadStatus { + switch s { + case pb.PolicyStatus_POLICY_STATUS_PENDING: + return types.PolicyLoadStatusPending + case pb.PolicyStatus_POLICY_STATUS_LOADED: + return types.PolicyLoadStatusLoaded + case pb.PolicyStatus_POLICY_STATUS_FAILED: + return types.PolicyLoadStatusFailed + case pb.PolicyStatus_POLICY_STATUS_SUPERSEDED: + return types.PolicyLoadStatusSuperseded + default: + return types.PolicyLoadStatusUnspecified + } +} + +// PolicyLoadStatusToProto converts an SDK PolicyLoadStatus to a proto PolicyStatus. +func PolicyLoadStatusToProto(s types.PolicyLoadStatus) pb.PolicyStatus { + switch s { + case types.PolicyLoadStatusPending: + return pb.PolicyStatus_POLICY_STATUS_PENDING + case types.PolicyLoadStatusLoaded: + return pb.PolicyStatus_POLICY_STATUS_LOADED + case types.PolicyLoadStatusFailed: + return pb.PolicyStatus_POLICY_STATUS_FAILED + case types.PolicyLoadStatusSuperseded: + return pb.PolicyStatus_POLICY_STATUS_SUPERSEDED + default: + return pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED + } +} + +// --- PolicyChunk --- + +// PolicyChunkFromProto converts a proto PolicyChunk to an SDK PolicyChunk. +func PolicyChunkFromProto(c *pb.PolicyChunk) *types.PolicyChunk { + if c == nil { + return nil + } + return &types.PolicyChunk{ + ID: c.GetId(), + Status: c.GetStatus(), + RuleName: c.GetRuleName(), + ProposedRule: NetworkPolicyRuleFromProto(c.GetProposedRule()), + Rationale: c.GetRationale(), + SecurityNotes: c.GetSecurityNotes(), + Confidence: c.GetConfidence(), + DenialSummaryIDs: CopyStringSlice(c.GetDenialSummaryIds()), + CreatedAt: TimeFromMillis(c.GetCreatedAtMs()), + DecidedAt: TimeFromMillis(c.GetDecidedAtMs()), + Stage: c.GetStage(), + SupersedesChunkID: c.GetSupersedesChunkId(), + HitCount: c.GetHitCount(), + FirstSeen: TimeFromMillis(c.GetFirstSeenMs()), + LastSeen: TimeFromMillis(c.GetLastSeenMs()), + Binary: c.GetBinary(), + ValidationResult: c.GetValidationResult(), + RejectionReason: c.GetRejectionReason(), + } +} + +// --- DraftPolicy --- + +// DraftPolicyFromProto converts a proto GetDraftPolicyResponse to an SDK DraftPolicy. +func DraftPolicyFromProto(r *pb.GetDraftPolicyResponse) *types.DraftPolicy { + if r == nil { + return nil + } + result := &types.DraftPolicy{ + RollingSummary: r.GetRollingSummary(), + DraftVersion: r.GetDraftVersion(), + LastAnalyzedAt: TimeFromMillis(r.GetLastAnalyzedAtMs()), + } + if chunks := r.GetChunks(); len(chunks) > 0 { + result.Chunks = make([]types.PolicyChunk, 0, len(chunks)) + for _, c := range chunks { + if converted := PolicyChunkFromProto(c); converted != nil { + result.Chunks = append(result.Chunks, *converted) + } + } + } + return result +} + +// --- SandboxPolicy --- + +// SandboxPolicyFromProto converts a proto SandboxPolicy to an SDK SandboxPolicy. +// Returns nil for nil input. All slice and map fields are deep-copied. +func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { + if p == nil { + return nil + } + result := &types.SandboxPolicy{ + Version: p.GetVersion(), + Filesystem: filesystemPolicyFromProto(p.GetFilesystem()), + Landlock: landlockPolicyFromProto(p.GetLandlock()), + Process: processPolicyFromProto(p.GetProcess()), + } + if np := p.GetNetworkPolicies(); np != nil { + result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np)) + for k, v := range np { + if converted := NetworkPolicyRuleFromProto(v); converted != nil { + result.NetworkPolicies[k] = *converted + } + } + } + return result +} + +// SandboxPolicyToProto converts an SDK SandboxPolicy to a proto SandboxPolicy. +// Returns nil for nil input. All slice and map fields are deep-copied. +func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { + if p == nil { + return nil + } + result := &sbv1.SandboxPolicy{ + Version: p.Version, + Filesystem: filesystemPolicyToProto(p.Filesystem), + Landlock: landlockPolicyToProto(p.Landlock), + Process: processPolicyToProto(p.Process), + } + if p.NetworkPolicies != nil { + result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, v := range p.NetworkPolicies { + result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v) + } + } + return result +} + +func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy { + if f == nil { + return nil + } + return &types.FilesystemPolicy{ + IncludeWorkdir: f.GetIncludeWorkdir(), + ReadOnly: CopyStringSlice(f.GetReadOnly()), + ReadWrite: CopyStringSlice(f.GetReadWrite()), + } +} + +func filesystemPolicyToProto(f *types.FilesystemPolicy) *sbv1.FilesystemPolicy { + if f == nil { + return nil + } + return &sbv1.FilesystemPolicy{ + IncludeWorkdir: f.IncludeWorkdir, + ReadOnly: CopyStringSlice(f.ReadOnly), + ReadWrite: CopyStringSlice(f.ReadWrite), + } +} + +func landlockPolicyFromProto(l *sbv1.LandlockPolicy) *types.LandlockPolicy { + if l == nil { + return nil + } + return &types.LandlockPolicy{ + Compatibility: l.GetCompatibility(), + } +} + +func landlockPolicyToProto(l *types.LandlockPolicy) *sbv1.LandlockPolicy { + if l == nil { + return nil + } + return &sbv1.LandlockPolicy{ + Compatibility: l.Compatibility, + } +} + +func processPolicyFromProto(p *sbv1.ProcessPolicy) *types.ProcessPolicy { + if p == nil { + return nil + } + return &types.ProcessPolicy{ + RunAsUser: p.GetRunAsUser(), + RunAsGroup: p.GetRunAsGroup(), + } +} + +func processPolicyToProto(p *types.ProcessPolicy) *sbv1.ProcessPolicy { + if p == nil { + return nil + } + return &sbv1.ProcessPolicy{ + RunAsUser: p.RunAsUser, + RunAsGroup: p.RunAsGroup, + } +} + +// --- SandboxPolicyRevision --- + +// SandboxPolicyRevisionFromProto converts a proto SandboxPolicyRevision to an SDK SandboxPolicyRevision. +func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxPolicyRevision { + if r == nil { + return nil + } + return &types.SandboxPolicyRevision{ + Version: r.GetVersion(), + PolicyHash: r.GetPolicyHash(), + Status: PolicyLoadStatusFromProto(r.GetStatus()), + LoadError: r.GetLoadError(), + CreatedAt: TimeFromMillis(r.GetCreatedAtMs()), + LoadedAt: TimeFromMillis(r.GetLoadedAtMs()), + Policy: SandboxPolicyFromProto(r.GetPolicy()), + } +} + +// --- PolicyStatusResult --- + +// PolicyStatusResultFromProto converts a proto GetSandboxPolicyStatusResponse to an SDK PolicyStatusResult. +func PolicyStatusResultFromProto(r *pb.GetSandboxPolicyStatusResponse) *types.PolicyStatusResult { + if r == nil { + return nil + } + result := &types.PolicyStatusResult{ + ActiveVersion: r.GetActiveVersion(), + } + if rev := SandboxPolicyRevisionFromProto(r.GetRevision()); rev != nil { + result.Revision = *rev + } + return result +} + +// --- ApproveResult --- + +// ApproveResultFromProto converts a proto ApproveDraftChunkResponse to an SDK ApproveResult. +func ApproveResultFromProto(r *pb.ApproveDraftChunkResponse) *types.ApproveResult { + if r == nil { + return nil + } + return &types.ApproveResult{ + PolicyVersion: r.GetPolicyVersion(), + PolicyHash: r.GetPolicyHash(), + } +} + +// --- ApproveAllResult --- + +// ApproveAllResultFromProto converts a proto ApproveAllDraftChunksResponse to an SDK ApproveAllResult. +func ApproveAllResultFromProto(r *pb.ApproveAllDraftChunksResponse) *types.ApproveAllResult { + if r == nil { + return nil + } + return &types.ApproveAllResult{ + PolicyVersion: r.GetPolicyVersion(), + PolicyHash: r.GetPolicyHash(), + ChunksApproved: r.GetChunksApproved(), + ChunksSkipped: r.GetChunksSkipped(), + } +} + +// --- UndoResult --- + +// UndoResultFromProto converts a proto UndoDraftChunkResponse to an SDK UndoResult. +func UndoResultFromProto(r *pb.UndoDraftChunkResponse) *types.UndoResult { + if r == nil { + return nil + } + return &types.UndoResult{ + PolicyVersion: r.GetPolicyVersion(), + PolicyHash: r.GetPolicyHash(), + } +} + +// --- ClearResult --- + +// ClearResultFromProto converts a proto ClearDraftChunksResponse to an SDK ClearResult. +func ClearResultFromProto(r *pb.ClearDraftChunksResponse) *types.ClearResult { + if r == nil { + return nil + } + return &types.ClearResult{ + ChunksCleared: r.GetChunksCleared(), + } +} + +// --- DraftHistoryEntry --- + +// DraftHistoryEntryFromProto converts a proto DraftHistoryEntry to an SDK DraftHistoryEntry. +func DraftHistoryEntryFromProto(e *pb.DraftHistoryEntry) *types.DraftHistoryEntry { + if e == nil { + return nil + } + return &types.DraftHistoryEntry{ + Timestamp: TimeFromMillis(e.GetTimestampMs()), + EventType: e.GetEventType(), + Description: e.GetDescription(), + ChunkID: e.GetChunkId(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go new file mode 100644 index 0000000000..f83dac59c5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -0,0 +1,608 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- PolicyLoadStatus --- + +func TestPolicyLoadStatusFromProto(t *testing.T) { + tests := []struct { + proto pb.PolicyStatus + want v1.PolicyLoadStatus + }{ + {pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED, v1.PolicyLoadStatusUnspecified}, + {pb.PolicyStatus_POLICY_STATUS_PENDING, v1.PolicyLoadStatusPending}, + {pb.PolicyStatus_POLICY_STATUS_LOADED, v1.PolicyLoadStatusLoaded}, + {pb.PolicyStatus_POLICY_STATUS_FAILED, v1.PolicyLoadStatusFailed}, + {pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, v1.PolicyLoadStatusSuperseded}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicyLoadStatusFromProto(tt.proto)) + }) + } +} + +func TestPolicyLoadStatusToProto(t *testing.T) { + tests := []struct { + sdk v1.PolicyLoadStatus + want pb.PolicyStatus + }{ + {v1.PolicyLoadStatusUnspecified, pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED}, + {v1.PolicyLoadStatusPending, pb.PolicyStatus_POLICY_STATUS_PENDING}, + {v1.PolicyLoadStatusLoaded, pb.PolicyStatus_POLICY_STATUS_LOADED}, + {v1.PolicyLoadStatusFailed, pb.PolicyStatus_POLICY_STATUS_FAILED}, + {v1.PolicyLoadStatusSuperseded, pb.PolicyStatus_POLICY_STATUS_SUPERSEDED}, + } + for _, tt := range tests { + t.Run(tt.sdk.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicyLoadStatusToProto(tt.sdk)) + }) + } +} + +func TestPolicyLoadStatusRoundTrip(t *testing.T) { + for _, s := range []v1.PolicyLoadStatus{ + v1.PolicyLoadStatusUnspecified, + v1.PolicyLoadStatusPending, + v1.PolicyLoadStatusLoaded, + v1.PolicyLoadStatusFailed, + v1.PolicyLoadStatusSuperseded, + } { + assert.Equal(t, s, PolicyLoadStatusFromProto(PolicyLoadStatusToProto(s))) + } +} + +// --- PolicyChunk --- + +func TestPolicyChunkFromProto(t *testing.T) { + proto := &pb.PolicyChunk{ + Id: "chunk-1", + Status: "pending", + RuleName: "web-api", + Rationale: "Observed DNS resolution", + SecurityNotes: "No concerns", + Confidence: 0.95, + DenialSummaryIds: []string{"d1", "d2"}, + CreatedAtMs: 1700000000000, + DecidedAtMs: 1700000001000, + Stage: "initial", + SupersedesChunkId: "chunk-0", + HitCount: 5, + FirstSeenMs: 1699999999000, + LastSeenMs: 1700000000500, + Binary: "/usr/bin/curl", + ValidationResult: "valid", + RejectionReason: "", + ProposedRule: &sbv1.NetworkPolicyRule{ + Name: "web-api", + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + } + + chunk := PolicyChunkFromProto(proto) + + require.NotNil(t, chunk) + assert.Equal(t, "chunk-1", chunk.ID) + assert.Equal(t, "pending", chunk.Status) + assert.Equal(t, "web-api", chunk.RuleName) + assert.Equal(t, "Observed DNS resolution", chunk.Rationale) + assert.Equal(t, "No concerns", chunk.SecurityNotes) + assert.InDelta(t, float32(0.95), chunk.Confidence, 0.001) + assert.Equal(t, []string{"d1", "d2"}, chunk.DenialSummaryIDs) + assert.False(t, chunk.CreatedAt.IsZero()) + assert.False(t, chunk.DecidedAt.IsZero()) + assert.Equal(t, "initial", chunk.Stage) + assert.Equal(t, "chunk-0", chunk.SupersedesChunkID) + assert.Equal(t, int32(5), chunk.HitCount) + assert.False(t, chunk.FirstSeen.IsZero()) + assert.False(t, chunk.LastSeen.IsZero()) + assert.Equal(t, "/usr/bin/curl", chunk.Binary) + assert.Equal(t, "valid", chunk.ValidationResult) + assert.Empty(t, chunk.RejectionReason) + + require.NotNil(t, chunk.ProposedRule) + assert.Equal(t, "web-api", chunk.ProposedRule.Name) + require.Len(t, chunk.ProposedRule.Endpoints, 1) + assert.Equal(t, "api.example.com", chunk.ProposedRule.Endpoints[0].Host) +} + +func TestPolicyChunkFromProto_Nil(t *testing.T) { + assert.Nil(t, PolicyChunkFromProto(nil)) +} + +func TestPolicyChunkDeepCopy(t *testing.T) { + proto := &pb.PolicyChunk{ + Id: "c1", + DenialSummaryIds: []string{"d1"}, + } + + chunk := PolicyChunkFromProto(proto) + proto.DenialSummaryIds[0] = "changed" + + assert.Equal(t, "d1", chunk.DenialSummaryIDs[0]) +} + +// --- DraftPolicy --- + +func TestDraftPolicyFromProto(t *testing.T) { + proto := &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + {Id: "c1", Status: "pending", RuleName: "rule1"}, + {Id: "c2", Status: "approved", RuleName: "rule2"}, + }, + RollingSummary: "Analysis summary", + DraftVersion: 42, + LastAnalyzedAtMs: 1700000000000, + } + + draft := DraftPolicyFromProto(proto) + + require.NotNil(t, draft) + assert.Len(t, draft.Chunks, 2) + assert.Equal(t, "c1", draft.Chunks[0].ID) + assert.Equal(t, "c2", draft.Chunks[1].ID) + assert.Equal(t, "Analysis summary", draft.RollingSummary) + assert.Equal(t, uint64(42), draft.DraftVersion) + assert.False(t, draft.LastAnalyzedAt.IsZero()) +} + +func TestDraftPolicyFromProto_Nil(t *testing.T) { + assert.Nil(t, DraftPolicyFromProto(nil)) +} + +func TestDraftPolicyFromProto_EmptyChunks(t *testing.T) { + proto := &pb.GetDraftPolicyResponse{ + RollingSummary: "empty", + DraftVersion: 1, + } + + draft := DraftPolicyFromProto(proto) + require.NotNil(t, draft) + assert.Empty(t, draft.Chunks) +} + +// --- SandboxPolicy --- + +func TestSandboxPolicyFromProtoNil(t *testing.T) { + assert.Nil(t, SandboxPolicyFromProto(nil)) +} + +func TestSandboxPolicyToProtoNil(t *testing.T) { + assert.Nil(t, SandboxPolicyToProto(nil)) +} + +func TestSandboxPolicyRoundTrip(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 5, + Filesystem: &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp", "/workspace"}, + }, + Landlock: &v1.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &v1.ProcessPolicy{ + RunAsUser: "sandbox-user", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "web-api": { + Name: "web-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + "db": { + Name: "db", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "db.internal", Port: 5432, Protocol: "tcp"}, + }, + }, + }, + } + + proto := SandboxPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := SandboxPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Version, roundTrip.Version) + + // Filesystem + require.NotNil(t, roundTrip.Filesystem) + assert.Equal(t, original.Filesystem.IncludeWorkdir, roundTrip.Filesystem.IncludeWorkdir) + assert.Equal(t, original.Filesystem.ReadOnly, roundTrip.Filesystem.ReadOnly) + assert.Equal(t, original.Filesystem.ReadWrite, roundTrip.Filesystem.ReadWrite) + + // Landlock + require.NotNil(t, roundTrip.Landlock) + assert.Equal(t, original.Landlock.Compatibility, roundTrip.Landlock.Compatibility) + + // Process + require.NotNil(t, roundTrip.Process) + assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) + assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + + // NetworkPolicies + require.Len(t, roundTrip.NetworkPolicies, 2) + webAPI, ok := roundTrip.NetworkPolicies["web-api"] + require.True(t, ok) + assert.Equal(t, "web-api", webAPI.Name) + require.Len(t, webAPI.Endpoints, 1) + assert.Equal(t, "api.example.com", webAPI.Endpoints[0].Host) + + db, ok := roundTrip.NetworkPolicies["db"] + require.True(t, ok) + assert.Equal(t, "db", db.Name) +} + +func TestSandboxPolicyDeepCopy(t *testing.T) { + // Build a proto, convert to SDK, mutate proto, verify SDK is isolated. + proto := &sbv1.SandboxPolicy{ + Version: 1, + Filesystem: &sbv1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/original"}, + ReadWrite: []string{"/tmp"}, + }, + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ + "rule1": { + Name: "rule1", + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "original.host", Port: 80}, + }, + }, + }, + } + + sdk := SandboxPolicyFromProto(proto) + require.NotNil(t, sdk) + + // Mutate proto source after conversion. + proto.Version = 99 + proto.Filesystem.ReadOnly[0] = "mutated" + proto.Filesystem.ReadWrite[0] = "mutated" + proto.NetworkPolicies["rule1"].Name = "mutated" + proto.NetworkPolicies["rule1"].Endpoints[0].Host = "mutated.host" + + // SDK values must be unaffected. + assert.Equal(t, uint32(1), sdk.Version) + assert.Equal(t, "/original", sdk.Filesystem.ReadOnly[0]) + assert.Equal(t, "/tmp", sdk.Filesystem.ReadWrite[0]) + assert.Equal(t, "rule1", sdk.NetworkPolicies["rule1"].Name) + assert.Equal(t, "original.host", sdk.NetworkPolicies["rule1"].Endpoints[0].Host) + + // Also test ToProto deep-copy isolation. + protoOut := SandboxPolicyToProto(sdk) + require.NotNil(t, protoOut) + + // Mutate SDK after ToProto conversion. + sdk.Filesystem.ReadOnly[0] = "sdk-mutated" + + // Proto output must be unaffected. + assert.Equal(t, "/original", protoOut.Filesystem.ReadOnly[0]) +} + +func TestSandboxPolicyPartialSubPolicies(t *testing.T) { + t.Run("only filesystem", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 1, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + assert.Nil(t, roundTrip.Process) + assert.Nil(t, roundTrip.NetworkPolicies) + }) + + t.Run("only landlock", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 2, + Landlock: &v1.LandlockPolicy{ + Compatibility: "hard_requirement", + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + require.NotNil(t, roundTrip.Landlock) + assert.Equal(t, "hard_requirement", roundTrip.Landlock.Compatibility) + assert.Nil(t, roundTrip.Process) + assert.Nil(t, roundTrip.NetworkPolicies) + }) + + t.Run("only process", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Process: &v1.ProcessPolicy{ + RunAsUser: "nobody", + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + require.NotNil(t, roundTrip.Process) + assert.Equal(t, "nobody", roundTrip.Process.RunAsUser) + }) + + t.Run("only network policies", func(t *testing.T) { + original := &v1.SandboxPolicy{ + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "r1": {Name: "r1"}, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + assert.Nil(t, roundTrip.Process) + require.Len(t, roundTrip.NetworkPolicies, 1) + }) + + t.Run("empty network policies map preserved", func(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{}, + } + // Proto empty map is non-nil, so converter creates an empty SDK map. + sdk := SandboxPolicyFromProto(proto) + require.NotNil(t, sdk) + require.NotNil(t, sdk.NetworkPolicies) + assert.Empty(t, sdk.NetworkPolicies) + }) +} + +func TestFilesystemPolicyRoundTrip(t *testing.T) { + original := &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/lib"}, + ReadWrite: []string{"/tmp", "/var/run"}, + } + + proto := filesystemPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := filesystemPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.IncludeWorkdir, roundTrip.IncludeWorkdir) + assert.Equal(t, original.ReadOnly, roundTrip.ReadOnly) + assert.Equal(t, original.ReadWrite, roundTrip.ReadWrite) +} + +func TestFilesystemPolicyNil(t *testing.T) { + assert.Nil(t, filesystemPolicyFromProto(nil)) + assert.Nil(t, filesystemPolicyToProto(nil)) +} + +func TestLandlockPolicyRoundTrip(t *testing.T) { + original := &v1.LandlockPolicy{ + Compatibility: "best_effort", + } + + proto := landlockPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := landlockPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Compatibility, roundTrip.Compatibility) +} + +func TestLandlockPolicyNil(t *testing.T) { + assert.Nil(t, landlockPolicyFromProto(nil)) + assert.Nil(t, landlockPolicyToProto(nil)) +} + +func TestProcessPolicyRoundTrip(t *testing.T) { + original := &v1.ProcessPolicy{ + RunAsUser: "app-user", + RunAsGroup: "app-group", + } + + proto := processPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := processPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.RunAsUser, roundTrip.RunAsUser) + assert.Equal(t, original.RunAsGroup, roundTrip.RunAsGroup) +} + +func TestProcessPolicyNil(t *testing.T) { + assert.Nil(t, processPolicyFromProto(nil)) + assert.Nil(t, processPolicyToProto(nil)) +} + +// --- SandboxPolicyRevision --- + +func TestSandboxPolicyRevisionFromProto(t *testing.T) { + proto := &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:abc123", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + LoadError: "", + CreatedAtMs: 1700000000000, + LoadedAtMs: 1700000001000, + } + + rev := SandboxPolicyRevisionFromProto(proto) + + require.NotNil(t, rev) + assert.Equal(t, uint32(3), rev.Version) + assert.Equal(t, "sha256:abc123", rev.PolicyHash) + assert.Equal(t, v1.PolicyLoadStatusLoaded, rev.Status) + assert.Empty(t, rev.LoadError) + assert.False(t, rev.CreatedAt.IsZero()) + assert.False(t, rev.LoadedAt.IsZero()) +} + +func TestSandboxPolicyRevisionFromProto_Nil(t *testing.T) { + assert.Nil(t, SandboxPolicyRevisionFromProto(nil)) +} + +func TestSandboxPolicyRevisionFromProto_WithPolicy(t *testing.T) { + proto := &pb.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:def", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + Policy: &sbv1.SandboxPolicy{ + Version: 2, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + rev := SandboxPolicyRevisionFromProto(proto) + require.NotNil(t, rev) + require.NotNil(t, rev.Policy, "typed SandboxPolicy should be populated when proto policy is set") + assert.Equal(t, uint32(2), rev.Policy.Version) + require.NotNil(t, rev.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, rev.Policy.Filesystem.ReadOnly) +} + +// --- PolicyStatusResult --- + +func TestPolicyStatusResultFromProto(t *testing.T) { + proto := &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 5, + PolicyHash: "sha256:xyz", + Status: pb.PolicyStatus_POLICY_STATUS_PENDING, + }, + ActiveVersion: 4, + } + + result := PolicyStatusResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(5), result.Revision.Version) + assert.Equal(t, "sha256:xyz", result.Revision.PolicyHash) + assert.Equal(t, v1.PolicyLoadStatusPending, result.Revision.Status) + assert.Equal(t, uint32(4), result.ActiveVersion) +} + +func TestPolicyStatusResultFromProto_Nil(t *testing.T) { + assert.Nil(t, PolicyStatusResultFromProto(nil)) +} + +// --- ApproveResult --- + +func TestApproveResultFromProto(t *testing.T) { + proto := &pb.ApproveDraftChunkResponse{ + PolicyVersion: 7, + PolicyHash: "sha256:merged", + } + + result := ApproveResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(7), result.PolicyVersion) + assert.Equal(t, "sha256:merged", result.PolicyHash) +} + +func TestApproveResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ApproveResultFromProto(nil)) +} + +// --- ApproveAllResult --- + +func TestApproveAllResultFromProto(t *testing.T) { + proto := &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 8, + PolicyHash: "sha256:all", + ChunksApproved: 10, + ChunksSkipped: 2, + } + + result := ApproveAllResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(8), result.PolicyVersion) + assert.Equal(t, "sha256:all", result.PolicyHash) + assert.Equal(t, uint32(10), result.ChunksApproved) + assert.Equal(t, uint32(2), result.ChunksSkipped) +} + +func TestApproveAllResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ApproveAllResultFromProto(nil)) +} + +// --- UndoResult --- + +func TestUndoResultFromProto(t *testing.T) { + proto := &pb.UndoDraftChunkResponse{ + PolicyVersion: 6, + PolicyHash: "sha256:reverted", + } + + result := UndoResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(6), result.PolicyVersion) + assert.Equal(t, "sha256:reverted", result.PolicyHash) +} + +func TestUndoResultFromProto_Nil(t *testing.T) { + assert.Nil(t, UndoResultFromProto(nil)) +} + +// --- ClearResult --- + +func TestClearResultFromProto(t *testing.T) { + proto := &pb.ClearDraftChunksResponse{ + ChunksCleared: 15, + } + + result := ClearResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(15), result.ChunksCleared) +} + +func TestClearResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ClearResultFromProto(nil)) +} + +// --- DraftHistoryEntry --- + +func TestDraftHistoryEntryFromProto(t *testing.T) { + proto := &pb.DraftHistoryEntry{ + TimestampMs: 1700000000000, + EventType: "approved", + Description: "Chunk c1 approved", + ChunkId: "c1", + } + + entry := DraftHistoryEntryFromProto(proto) + + require.NotNil(t, entry) + assert.False(t, entry.Timestamp.IsZero()) + assert.Equal(t, "approved", entry.EventType) + assert.Equal(t, "Chunk c1 approved", entry.Description) + assert.Equal(t, "c1", entry.ChunkID) +} + +func TestDraftHistoryEntryFromProto_Nil(t *testing.T) { + assert.Nil(t, DraftHistoryEntryFromProto(nil)) +} diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go new file mode 100644 index 0000000000..21fb83306b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- ProfileCategory enum mapping --- + +// ProfileCategoryFromProto converts a proto ProviderProfileCategory to an SDK ProfileCategory. +func ProfileCategoryFromProto(c pb.ProviderProfileCategory) types.ProfileCategory { + switch c { + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER: + return types.ProfileCategoryOther + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE: + return types.ProfileCategoryInference + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT: + return types.ProfileCategoryAgent + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL: + return types.ProfileCategorySourceControl + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING: + return types.ProfileCategoryMessaging + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA: + return types.ProfileCategoryData + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE: + return types.ProfileCategoryKnowledge + default: + return types.ProfileCategory("") + } +} + +// ProfileCategoryToProto converts an SDK ProfileCategory to a proto ProviderProfileCategory. +func ProfileCategoryToProto(c types.ProfileCategory) pb.ProviderProfileCategory { + switch c { + case types.ProfileCategoryOther: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER + case types.ProfileCategoryInference: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE + case types.ProfileCategoryAgent: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT + case types.ProfileCategorySourceControl: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL + case types.ProfileCategoryMessaging: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING + case types.ProfileCategoryData: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA + case types.ProfileCategoryKnowledge: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE + default: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED + } +} + +// --- NetworkEndpoint --- + +// NetworkEndpointFromProto converts a proto NetworkEndpoint to an SDK NetworkEndpoint. +// Only Host, Port, and Protocol are mapped; additional proto fields are ignored. +func NetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) *types.NetworkEndpoint { + if ep == nil { + return nil + } + return &types.NetworkEndpoint{ + Host: ep.GetHost(), + Port: ep.GetPort(), + Protocol: ep.GetProtocol(), + } +} + +// NetworkEndpointToProto converts an SDK NetworkEndpoint to a proto NetworkEndpoint. +func NetworkEndpointToProto(ep *types.NetworkEndpoint) *sbv1.NetworkEndpoint { + if ep == nil { + return nil + } + return &sbv1.NetworkEndpoint{ + Host: ep.Host, + Port: ep.Port, + Protocol: ep.Protocol, + } +} + +// --- NetworkBinary --- + +// NetworkBinaryFromProto converts a proto NetworkBinary to an SDK NetworkBinary. +func NetworkBinaryFromProto(b *sbv1.NetworkBinary) *types.NetworkBinary { + if b == nil { + return nil + } + return &types.NetworkBinary{ + Path: b.GetPath(), + } +} + +// NetworkBinaryToProto converts an SDK NetworkBinary to a proto NetworkBinary. +func NetworkBinaryToProto(b *types.NetworkBinary) *sbv1.NetworkBinary { + if b == nil { + return nil + } + return &sbv1.NetworkBinary{ + Path: b.Path, + } +} + +// --- ProfileCredential --- + +// ProfileCredentialFromProto converts a proto ProviderProfileCredential to an SDK ProfileCredential. +// The SDK maps 4 fields from the 10-field proto: Name, Description, Required, and Secret. +// Secret is derived from whether the proto has a Refresh configuration. +func ProfileCredentialFromProto(c *pb.ProviderProfileCredential) *types.ProfileCredential { + if c == nil { + return nil + } + return &types.ProfileCredential{ + Name: c.GetName(), + Description: c.GetDescription(), + Required: c.GetRequired(), + Secret: c.GetRefresh() != nil, + } +} + +// ProfileCredentialToProto converts an SDK ProfileCredential to a proto ProviderProfileCredential. +// Only Name, Description, and Required are mapped. Secret is not round-trippable +// because it is derived from the Refresh field in the proto. +func ProfileCredentialToProto(c *types.ProfileCredential) *pb.ProviderProfileCredential { + if c == nil { + return nil + } + return &pb.ProviderProfileCredential{ + Name: c.Name, + Description: c.Description, + Required: c.Required, + } +} + +// --- ProfileDiagnostic --- + +// ProfileDiagnosticFromProto converts a proto ProviderProfileDiagnostic to an SDK ProfileDiagnostic. +func ProfileDiagnosticFromProto(d *pb.ProviderProfileDiagnostic) *types.ProfileDiagnostic { + if d == nil { + return nil + } + return &types.ProfileDiagnostic{ + Source: d.GetSource(), + ProfileID: d.GetProfileId(), + Field: d.GetField(), + Message: d.GetMessage(), + Severity: d.GetSeverity(), + } +} + +// --- ProviderProfile --- + +// ProviderProfileFromProto converts a proto ProviderProfile to an SDK ProviderProfile. +func ProviderProfileFromProto(p *pb.ProviderProfile) *types.ProviderProfile { + if p == nil { + return nil + } + + result := &types.ProviderProfile{ + ID: p.GetId(), + DisplayName: p.GetDisplayName(), + Description: p.GetDescription(), + Category: ProfileCategoryFromProto(p.GetCategory()), + InferenceCapable: p.GetInferenceCapable(), + ResourceVersion: p.GetResourceVersion(), + } + + // Credentials + if creds := p.GetCredentials(); len(creds) > 0 { + result.Credentials = make([]types.ProfileCredential, len(creds)) + for i, c := range creds { + if converted := ProfileCredentialFromProto(c); converted != nil { + result.Credentials[i] = *converted + } + } + } + + // Endpoints + if eps := p.GetEndpoints(); len(eps) > 0 { + result.Endpoints = make([]types.NetworkEndpoint, len(eps)) + for i, ep := range eps { + if converted := NetworkEndpointFromProto(ep); converted != nil { + result.Endpoints[i] = *converted + } + } + } + + // Binaries + if bins := p.GetBinaries(); len(bins) > 0 { + result.Binaries = make([]types.NetworkBinary, len(bins)) + for i, b := range bins { + if converted := NetworkBinaryFromProto(b); converted != nil { + result.Binaries[i] = *converted + } + } + } + + // Discovery + if d := p.GetDiscovery(); d != nil { + result.Discovery = types.ProfileDiscovery{ + Credentials: CopyStringSlice(d.GetCredentials()), + } + } + + return result +} + +// ProviderProfileToProto converts an SDK ProviderProfile to a proto ProviderProfile. +func ProviderProfileToProto(p *types.ProviderProfile) *pb.ProviderProfile { + if p == nil { + return nil + } + + result := &pb.ProviderProfile{ + Id: p.ID, + DisplayName: p.DisplayName, + Description: p.Description, + Category: ProfileCategoryToProto(p.Category), + InferenceCapable: p.InferenceCapable, + ResourceVersion: p.ResourceVersion, + } + + // Credentials + if len(p.Credentials) > 0 { + result.Credentials = make([]*pb.ProviderProfileCredential, len(p.Credentials)) + for i := range p.Credentials { + result.Credentials[i] = ProfileCredentialToProto(&p.Credentials[i]) + } + } + + // Endpoints + if len(p.Endpoints) > 0 { + result.Endpoints = make([]*sbv1.NetworkEndpoint, len(p.Endpoints)) + for i := range p.Endpoints { + result.Endpoints[i] = NetworkEndpointToProto(&p.Endpoints[i]) + } + } + + // Binaries + if len(p.Binaries) > 0 { + result.Binaries = make([]*sbv1.NetworkBinary, len(p.Binaries)) + for i := range p.Binaries { + result.Binaries[i] = NetworkBinaryToProto(&p.Binaries[i]) + } + } + + // Discovery + if len(p.Discovery.Credentials) > 0 { + result.Discovery = &pb.ProviderProfileDiscovery{ + Credentials: CopyStringSlice(p.Discovery.Credentials), + } + } + + return result +} + +// --- ProfileImportItem --- + +// ProfileImportItemToProto converts an SDK ProfileImportItem to a proto ProviderProfileImportItem. +func ProfileImportItemToProto(item *types.ProfileImportItem) *pb.ProviderProfileImportItem { + if item == nil { + return nil + } + return &pb.ProviderProfileImportItem{ + Profile: ProviderProfileToProto(&item.Profile), + Source: item.Source, + } +} + +// ProfileImportItemFromProto converts a proto ProviderProfileImportItem to an SDK ProfileImportItem. +func ProfileImportItemFromProto(item *pb.ProviderProfileImportItem) *types.ProfileImportItem { + if item == nil { + return nil + } + + result := &types.ProfileImportItem{ + Source: item.GetSource(), + } + + if p := ProviderProfileFromProto(item.GetProfile()); p != nil { + result.Profile = *p + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go new file mode 100644 index 0000000000..b3bf266995 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- ProfileCategory --- + +func TestProfileCategoryFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderProfileCategory + want v1.ProfileCategory + }{ + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER, v1.ProfileCategoryOther}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, v1.ProfileCategoryInference}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT, v1.ProfileCategoryAgent}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL, v1.ProfileCategorySourceControl}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING, v1.ProfileCategoryMessaging}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA, v1.ProfileCategoryData}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE, v1.ProfileCategoryKnowledge}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED, v1.ProfileCategory("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, ProfileCategoryFromProto(tt.proto)) + }) + } +} + +func TestProfileCategoryToProto(t *testing.T) { + tests := []struct { + sdk v1.ProfileCategory + want pb.ProviderProfileCategory + }{ + {v1.ProfileCategoryOther, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER}, + {v1.ProfileCategoryInference, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE}, + {v1.ProfileCategoryAgent, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT}, + {v1.ProfileCategorySourceControl, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL}, + {v1.ProfileCategoryMessaging, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING}, + {v1.ProfileCategoryData, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA}, + {v1.ProfileCategoryKnowledge, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE}, + {v1.ProfileCategory(""), pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED}, + {v1.ProfileCategory("Unknown"), pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, ProfileCategoryToProto(tt.sdk)) + }) + } +} + +// --- NetworkEndpoint --- + +func TestNetworkEndpointFromProto(t *testing.T) { + proto := &sbv1.NetworkEndpoint{ + Host: "api.example.com", + Port: 443, + Protocol: "rest", + } + + ep := NetworkEndpointFromProto(proto) + + require.NotNil(t, ep) + assert.Equal(t, "api.example.com", ep.Host) + assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, "rest", ep.Protocol) +} + +func TestNetworkEndpointFromProto_Nil(t *testing.T) { + ep := NetworkEndpointFromProto(nil) + assert.Nil(t, ep) +} + +func TestNetworkEndpointToProto(t *testing.T) { + ep := &v1.NetworkEndpoint{ + Host: "api.example.com", + Port: 443, + Protocol: "rest", + } + + proto := NetworkEndpointToProto(ep) + + require.NotNil(t, proto) + assert.Equal(t, "api.example.com", proto.Host) + assert.Equal(t, uint32(443), proto.Port) + assert.Equal(t, "rest", proto.Protocol) +} + +func TestNetworkEndpointToProto_Nil(t *testing.T) { + proto := NetworkEndpointToProto(nil) + assert.Nil(t, proto) +} + +// --- NetworkBinary --- + +func TestNetworkBinaryFromProto(t *testing.T) { + proto := &sbv1.NetworkBinary{ + Path: "/usr/local/bin/tool", + } + + bin := NetworkBinaryFromProto(proto) + + require.NotNil(t, bin) + assert.Equal(t, "/usr/local/bin/tool", bin.Path) +} + +func TestNetworkBinaryFromProto_Nil(t *testing.T) { + bin := NetworkBinaryFromProto(nil) + assert.Nil(t, bin) +} + +func TestNetworkBinaryToProto(t *testing.T) { + bin := &v1.NetworkBinary{ + Path: "/usr/local/bin/tool", + } + + proto := NetworkBinaryToProto(bin) + + require.NotNil(t, proto) + assert.Equal(t, "/usr/local/bin/tool", proto.Path) +} + +func TestNetworkBinaryToProto_Nil(t *testing.T) { + proto := NetworkBinaryToProto(nil) + assert.Nil(t, proto) +} + +// --- ProfileCredential --- + +func TestProfileCredentialFromProto(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "API_KEY", + Description: "API key for auth", + Required: true, + Refresh: &pb.ProviderCredentialRefresh{ + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, + }, + } + + cred := ProfileCredentialFromProto(proto) + + require.NotNil(t, cred) + assert.Equal(t, "API_KEY", cred.Name) + assert.Equal(t, "API key for auth", cred.Description) + assert.True(t, cred.Required) + assert.True(t, cred.Secret, "credential with refresh config is secret") +} + +func TestProfileCredentialFromProto_NotSecret(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "ENDPOINT_URL", + Required: false, + } + + cred := ProfileCredentialFromProto(proto) + + require.NotNil(t, cred) + assert.Equal(t, "ENDPOINT_URL", cred.Name) + assert.False(t, cred.Required) + assert.False(t, cred.Secret, "credential without refresh config is not secret") +} + +func TestProfileCredentialFromProto_Nil(t *testing.T) { + cred := ProfileCredentialFromProto(nil) + assert.Nil(t, cred) +} + +// --- ProfileDiagnostic --- + +func TestProfileDiagnosticFromProto(t *testing.T) { + proto := &pb.ProviderProfileDiagnostic{ + Source: "import", + ProfileId: "prof-1", + Field: "credentials", + Message: "missing required field", + Severity: "error", + } + + diag := ProfileDiagnosticFromProto(proto) + + require.NotNil(t, diag) + assert.Equal(t, "import", diag.Source) + assert.Equal(t, "prof-1", diag.ProfileID) + assert.Equal(t, "credentials", diag.Field) + assert.Equal(t, "missing required field", diag.Message) + assert.Equal(t, "error", diag.Severity) +} + +func TestProfileDiagnosticFromProto_Nil(t *testing.T) { + diag := ProfileDiagnosticFromProto(nil) + assert.Nil(t, diag) +} + +// --- ProviderProfile --- + +func TestProviderProfileFromProto(t *testing.T) { + proto := &pb.ProviderProfile{ + Id: "prof-1", + DisplayName: "Claude Provider", + Description: "Anthropic Claude", + Category: pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, + Credentials: []*pb.ProviderProfileCredential{ + {Name: "API_KEY", Description: "key", Required: true}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/claude"}, + }, + InferenceCapable: true, + Discovery: &pb.ProviderProfileDiscovery{ + Credentials: []string{"API_KEY"}, + }, + ResourceVersion: 7, + } + + profile := ProviderProfileFromProto(proto) + + require.NotNil(t, profile) + assert.Equal(t, "prof-1", profile.ID) + assert.Equal(t, "Claude Provider", profile.DisplayName) + assert.Equal(t, "Anthropic Claude", profile.Description) + assert.Equal(t, v1.ProfileCategoryInference, profile.Category) + assert.True(t, profile.InferenceCapable) + assert.Equal(t, uint64(7), profile.ResourceVersion) + + require.Len(t, profile.Credentials, 1) + assert.Equal(t, "API_KEY", profile.Credentials[0].Name) + assert.True(t, profile.Credentials[0].Required) + + require.Len(t, profile.Endpoints, 1) + assert.Equal(t, "api.anthropic.com", profile.Endpoints[0].Host) + assert.Equal(t, uint32(443), profile.Endpoints[0].Port) + + require.Len(t, profile.Binaries, 1) + assert.Equal(t, "/usr/bin/claude", profile.Binaries[0].Path) + + assert.Equal(t, []string{"API_KEY"}, profile.Discovery.Credentials) +} + +func TestProviderProfileFromProto_NilDiscovery(t *testing.T) { + proto := &pb.ProviderProfile{ + Id: "prof-2", + } + + profile := ProviderProfileFromProto(proto) + + require.NotNil(t, profile) + assert.Nil(t, profile.Discovery.Credentials) +} + +func TestProviderProfileFromProto_Nil(t *testing.T) { + profile := ProviderProfileFromProto(nil) + assert.Nil(t, profile) +} + +func TestProviderProfileToProto(t *testing.T) { + profile := &v1.ProviderProfile{ + ID: "prof-1", + DisplayName: "Claude Provider", + Description: "Anthropic Claude", + Category: v1.ProfileCategoryInference, + Credentials: []v1.ProfileCredential{ + {Name: "API_KEY", Description: "key", Required: true, Secret: true}, + }, + Endpoints: []v1.NetworkEndpoint{ + {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + }, + Binaries: []v1.NetworkBinary{ + {Path: "/usr/bin/claude"}, + }, + InferenceCapable: true, + Discovery: v1.ProfileDiscovery{ + Credentials: []string{"API_KEY"}, + }, + ResourceVersion: 7, + } + + proto := ProviderProfileToProto(profile) + + require.NotNil(t, proto) + assert.Equal(t, "prof-1", proto.Id) + assert.Equal(t, "Claude Provider", proto.DisplayName) + assert.Equal(t, "Anthropic Claude", proto.Description) + assert.Equal(t, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, proto.Category) + assert.True(t, proto.InferenceCapable) + assert.Equal(t, uint64(7), proto.ResourceVersion) + + require.Len(t, proto.Credentials, 1) + assert.Equal(t, "API_KEY", proto.Credentials[0].Name) + + require.Len(t, proto.Endpoints, 1) + assert.Equal(t, "api.anthropic.com", proto.Endpoints[0].Host) + + require.Len(t, proto.Binaries, 1) + assert.Equal(t, "/usr/bin/claude", proto.Binaries[0].Path) + + require.NotNil(t, proto.Discovery) + assert.Equal(t, []string{"API_KEY"}, proto.Discovery.Credentials) +} + +func TestProviderProfileToProto_Nil(t *testing.T) { + proto := ProviderProfileToProto(nil) + assert.Nil(t, proto) +} + +// --- ProfileImportItem --- + +func TestProfileImportItemToProto(t *testing.T) { + item := &v1.ProfileImportItem{ + Profile: v1.ProviderProfile{ + ID: "prof-1", + DisplayName: "Test", + Category: v1.ProfileCategoryOther, + }, + Source: "file:///profiles/test.yaml", + } + + proto := ProfileImportItemToProto(item) + + require.NotNil(t, proto) + assert.Equal(t, "file:///profiles/test.yaml", proto.Source) + require.NotNil(t, proto.Profile) + assert.Equal(t, "prof-1", proto.Profile.Id) + assert.Equal(t, "Test", proto.Profile.DisplayName) +} + +func TestProfileImportItemToProto_Nil(t *testing.T) { + proto := ProfileImportItemToProto(nil) + assert.Nil(t, proto) +} + +func TestProfileImportItemFromProto(t *testing.T) { + proto := &pb.ProviderProfileImportItem{ + Profile: &pb.ProviderProfile{ + Id: "prof-1", + DisplayName: "Test", + }, + Source: "file:///profiles/test.yaml", + } + + item := ProfileImportItemFromProto(proto) + + require.NotNil(t, item) + assert.Equal(t, "file:///profiles/test.yaml", item.Source) + assert.Equal(t, "prof-1", item.Profile.ID) +} + +func TestProfileImportItemFromProto_Nil(t *testing.T) { + item := ProfileImportItemFromProto(nil) + assert.Nil(t, item) +} + +// --- ProviderProfile round-trip --- + +func TestProviderProfileRoundTrip(t *testing.T) { + original := &v1.ProviderProfile{ + ID: "prof-rt", + DisplayName: "Round Trip", + Description: "Testing round trip", + Category: v1.ProfileCategoryAgent, + Credentials: []v1.ProfileCredential{ + {Name: "TOKEN", Description: "auth token", Required: true, Secret: false}, + }, + Endpoints: []v1.NetworkEndpoint{ + {Host: "agent.example.com", Port: 8080, Protocol: "websocket"}, + }, + Binaries: []v1.NetworkBinary{ + {Path: "/bin/agent"}, + }, + InferenceCapable: false, + Discovery: v1.ProfileDiscovery{ + Credentials: []string{"TOKEN"}, + }, + ResourceVersion: 42, + } + + proto := ProviderProfileToProto(original) + back := ProviderProfileFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.DisplayName, back.DisplayName) + assert.Equal(t, original.Description, back.Description) + assert.Equal(t, original.Category, back.Category) + assert.Equal(t, original.InferenceCapable, back.InferenceCapable) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + + require.Len(t, back.Credentials, 1) + assert.Equal(t, original.Credentials[0].Name, back.Credentials[0].Name) + assert.Equal(t, original.Credentials[0].Required, back.Credentials[0].Required) + + require.Len(t, back.Endpoints, 1) + assert.Equal(t, original.Endpoints[0].Host, back.Endpoints[0].Host) + assert.Equal(t, original.Endpoints[0].Port, back.Endpoints[0].Port) + + require.Len(t, back.Binaries, 1) + assert.Equal(t, original.Binaries[0].Path, back.Binaries[0].Path) + + assert.Equal(t, original.Discovery.Credentials, back.Discovery.Credentials) +} diff --git a/sdk/go/openshell/v1/internal/converter/provider.go b/sdk/go/openshell/v1/internal/converter/provider.go new file mode 100644 index 0000000000..52fca5433d --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/provider.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" +) + +// ProviderFromProto converts a proto Provider to an SDK Provider. +func ProviderFromProto(p *dm.Provider) *types.Provider { + if p == nil { + return nil + } + + result := &types.Provider{ + Type: p.GetType(), + Spec: types.ProviderSpec{ + Config: CopyStringMap(p.GetConfig()), + }, + } + + if m := p.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Name = m.GetName() + result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.Labels = CopyStringMap(m.GetLabels()) + result.ResourceVersion = m.GetResourceVersion() + } + + if expires := p.GetCredentialExpiresAtMs(); len(expires) > 0 { + result.Spec.CredentialExpiresAt = make(map[string]time.Time, len(expires)) + for k, ms := range expires { + result.Spec.CredentialExpiresAt[k] = TimeFromMillis(ms) + } + } + + return result +} + +// ProviderToProto converts an SDK Provider to a proto Provider. +func ProviderToProto(p *types.Provider) *dm.Provider { + if p == nil { + return nil + } + + result := &dm.Provider{ + Metadata: &dm.ObjectMeta{ + Id: p.ID, + Name: p.Name, + CreatedAtMs: MillisFromTime(p.CreatedAt), + Labels: CopyStringMap(p.Labels), + ResourceVersion: p.ResourceVersion, + }, + Type: p.Type, + Credentials: CopyStringMap(p.Spec.Credentials), + Config: CopyStringMap(p.Spec.Config), + } + + if len(p.Spec.CredentialExpiresAt) > 0 { + result.CredentialExpiresAtMs = make(map[string]int64, len(p.Spec.CredentialExpiresAt)) + for k, t := range p.Spec.CredentialExpiresAt { + result.CredentialExpiresAtMs[k] = MillisFromTime(t) + } + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/provider_test.go b/sdk/go/openshell/v1/internal/converter/provider_test.go new file mode 100644 index 0000000000..f8f0c940e9 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/provider_test.go @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProviderFromProto(t *testing.T) { + proto := &dm.Provider{ + Metadata: &dm.ObjectMeta{ + Id: "prov-1", + Name: "my-claude", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"env": "prod"}, + ResourceVersion: 5, + }, + Type: "claude", + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + CredentialExpiresAtMs: map[string]int64{ + "API_KEY": 1700100000000, + }, + } + + p := ProviderFromProto(proto) + + require.NotNil(t, p) + assert.Equal(t, "prov-1", p.ID) + assert.Equal(t, "my-claude", p.Name) + assert.Equal(t, "claude", p.Type) + assert.Equal(t, time.UnixMilli(1700000000000).UTC(), p.CreatedAt) + assert.Equal(t, map[string]string{"env": "prod"}, p.Labels) + assert.Equal(t, uint64(5), p.ResourceVersion) + assert.Nil(t, p.Spec.Credentials, "credentials are write-only") + assert.Equal(t, map[string]string{"region": "us-east-1"}, p.Spec.Config) + assert.Equal(t, time.UnixMilli(1700100000000).UTC(), p.Spec.CredentialExpiresAt["API_KEY"]) +} + +func TestProviderFromProto_NilMetadata(t *testing.T) { + proto := &dm.Provider{ + Type: "gitlab", + } + + p := ProviderFromProto(proto) + + require.NotNil(t, p) + assert.Empty(t, p.ID) + assert.Empty(t, p.Name) + assert.Equal(t, "gitlab", p.Type) + assert.True(t, p.CreatedAt.IsZero()) +} + +func TestProviderFromProto_Nil(t *testing.T) { + p := ProviderFromProto(nil) + assert.Nil(t, p) +} + +func TestProviderToProto(t *testing.T) { + p := &v1.Provider{ + ID: "prov-1", + Name: "my-claude", + Type: "claude", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"env": "prod"}, + ResourceVersion: 5, + Spec: v1.ProviderSpec{ + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + CredentialExpiresAt: map[string]time.Time{ + "API_KEY": time.UnixMilli(1700100000000).UTC(), + }, + }, + } + + proto := ProviderToProto(p) + + require.NotNil(t, proto) + require.NotNil(t, proto.Metadata) + assert.Equal(t, "prov-1", proto.Metadata.Id) + assert.Equal(t, "my-claude", proto.Metadata.Name) + assert.Equal(t, int64(1700000000000), proto.Metadata.CreatedAtMs) + assert.Equal(t, map[string]string{"env": "prod"}, proto.Metadata.Labels) + assert.Equal(t, uint64(5), proto.Metadata.ResourceVersion) + assert.Equal(t, "claude", proto.Type) + assert.Equal(t, map[string]string{"API_KEY": "secret"}, proto.Credentials) + assert.Equal(t, map[string]string{"region": "us-east-1"}, proto.Config) + assert.Equal(t, int64(1700100000000), proto.CredentialExpiresAtMs["API_KEY"]) +} + +func TestProviderToProto_Nil(t *testing.T) { + proto := ProviderToProto(nil) + assert.Nil(t, proto) +} + +func TestProviderRoundTrip(t *testing.T) { + original := &v1.Provider{ + ID: "prov-rt", + Name: "round-trip", + Type: "github", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"team": "infra"}, + ResourceVersion: 42, + Spec: v1.ProviderSpec{ + Credentials: map[string]string{"TOKEN": "abc123"}, + Config: map[string]string{"org": "myorg"}, + CredentialExpiresAt: map[string]time.Time{ + "TOKEN": time.UnixMilli(1700200000000).UTC(), + }, + }, + } + + proto := ProviderToProto(original) + back := ProviderFromProto(proto) + + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.Name, back.Name) + assert.Equal(t, original.Type, back.Type) + assert.Equal(t, original.CreatedAt, back.CreatedAt) + assert.Equal(t, original.Labels, back.Labels) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + assert.Nil(t, back.Spec.Credentials, "credentials are write-only and should not be returned") + assert.Equal(t, original.Spec.Config, back.Spec.Config) + assert.Equal(t, original.Spec.CredentialExpiresAt, back.Spec.CredentialExpiresAt) +} diff --git a/sdk/go/openshell/v1/internal/converter/refresh.go b/sdk/go/openshell/v1/internal/converter/refresh.go new file mode 100644 index 0000000000..cd6aced7bc --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/refresh.go @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// --- RefreshStrategy enum mapping --- + +// RefreshStrategyFromProto converts a proto ProviderCredentialRefreshStrategy to an SDK RefreshStrategy. +func RefreshStrategyFromProto(s pb.ProviderCredentialRefreshStrategy) types.RefreshStrategy { + switch s { + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC: + return types.RefreshStrategyStatic + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL: + return types.RefreshStrategyExternal + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN: + return types.RefreshStrategyOAuth2RefreshToken + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS: + return types.RefreshStrategyOAuth2ClientCredentials + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT: + return types.RefreshStrategyGoogleServiceAccountJWT + default: + return types.RefreshStrategy("") + } +} + +// RefreshStrategyToProto converts an SDK RefreshStrategy to a proto ProviderCredentialRefreshStrategy. +func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefreshStrategy { + switch s { + case types.RefreshStrategyStatic: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC + case types.RefreshStrategyExternal: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL + case types.RefreshStrategyOAuth2RefreshToken: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN + case types.RefreshStrategyOAuth2ClientCredentials: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS + case types.RefreshStrategyGoogleServiceAccountJWT: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT + default: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED + } +} + +// --- RefreshStatus --- + +// RefreshStatusFromProto converts a proto ProviderCredentialRefreshStatus to an SDK RefreshStatus. +func RefreshStatusFromProto(s *pb.ProviderCredentialRefreshStatus) *types.RefreshStatus { + if s == nil { + 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(), + } +} + +// --- RefreshConfig --- + +// RefreshConfigToProto converts an SDK RefreshConfig to a proto ConfigureProviderRefreshRequest. +// Material and SecretMaterialKeys are deep-copied. +func RefreshConfigToProto(c *types.RefreshConfig) *pb.ConfigureProviderRefreshRequest { + if c == nil { + return nil + } + + result := &pb.ConfigureProviderRefreshRequest{ + Provider: c.Provider, + CredentialKey: c.CredentialKey, + Strategy: RefreshStrategyToProto(c.Strategy), + Material: CopyStringMap(c.Material), + SecretMaterialKeys: CopyStringSlice(c.SecretMaterialKeys), + } + + if c.ExpiresAt != nil { + ms := MillisFromTime(*c.ExpiresAt) + result.ExpiresAtMs = &ms + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/refresh_test.go b/sdk/go/openshell/v1/internal/converter/refresh_test.go new file mode 100644 index 0000000000..ab7e3b018b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/refresh_test.go @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- RefreshStrategy --- + +func TestRefreshStrategyFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderCredentialRefreshStrategy + want v1.RefreshStrategy + }{ + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, v1.RefreshStrategyStatic}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL, v1.RefreshStrategyExternal}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, v1.RefreshStrategyOAuth2RefreshToken}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, v1.RefreshStrategyOAuth2ClientCredentials}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT, v1.RefreshStrategyGoogleServiceAccountJWT}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED, v1.RefreshStrategy("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, RefreshStrategyFromProto(tt.proto)) + }) + } +} + +func TestRefreshStrategyToProto(t *testing.T) { + tests := []struct { + sdk v1.RefreshStrategy + want pb.ProviderCredentialRefreshStrategy + }{ + {v1.RefreshStrategyStatic, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC}, + {v1.RefreshStrategyExternal, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL}, + {v1.RefreshStrategyOAuth2RefreshToken, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN}, + {v1.RefreshStrategyOAuth2ClientCredentials, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS}, + {v1.RefreshStrategyGoogleServiceAccountJWT, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT}, + {v1.RefreshStrategy(""), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, + {v1.RefreshStrategy("Unknown"), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, RefreshStrategyToProto(tt.sdk)) + }) + } +} + +// --- RefreshStatus --- + +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", + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.Equal(t, "anthropic", status.ProviderName) + assert.Equal(t, "prov-1", status.ProviderID) + assert.Equal(t, "API_KEY", status.CredentialKey) + assert.Equal(t, v1.RefreshStrategyOAuth2RefreshToken, status.Strategy) + assert.Equal(t, "active", status.Status) + assert.Equal(t, TimeFromMillis(1700000000000), status.ExpiresAt) + assert.Equal(t, TimeFromMillis(1699999000000), status.NextRefreshAt) + assert.Equal(t, TimeFromMillis(1699998000000), status.LastRefreshAt) + assert.Equal(t, "none", status.LastError) +} + +func TestRefreshStatusFromProto_Nil(t *testing.T) { + status := RefreshStatusFromProto(nil) + assert.Nil(t, status) +} + +func TestRefreshStatusFromProto_ZeroTimestamps(t *testing.T) { + proto := &pb.ProviderCredentialRefreshStatus{ + ProviderName: "test", + CredentialKey: "KEY", + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.Equal(t, v1.RefreshStrategyStatic, status.Strategy) + assert.True(t, status.ExpiresAt.IsZero()) + assert.True(t, status.NextRefreshAt.IsZero()) + assert.True(t, status.LastRefreshAt.IsZero()) +} + +// --- RefreshConfig --- + +func TestRefreshConfigToProto(t *testing.T) { + expiresAt := time.Unix(1700000000, 0) + config := &v1.RefreshConfig{ + Provider: "anthropic", + CredentialKey: "API_KEY", + Strategy: v1.RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{ + "client_id": "my-id", + "client_secret": "my-secret", + }, + SecretMaterialKeys: []string{"client_secret"}, + ExpiresAt: &expiresAt, + } + + proto := RefreshConfigToProto(config) + + require.NotNil(t, proto) + assert.Equal(t, "anthropic", proto.Provider) + assert.Equal(t, "API_KEY", proto.CredentialKey) + assert.Equal(t, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, proto.Strategy) + + // Material is deep-copied + require.Len(t, proto.Material, 2) + assert.Equal(t, "my-id", proto.Material["client_id"]) + assert.Equal(t, "my-secret", proto.Material["client_secret"]) + + // Verify deep copy by mutating original + config.Material["client_id"] = "mutated" + assert.Equal(t, "my-id", proto.Material["client_id"], "material must be deep copied") + + assert.Equal(t, []string{"client_secret"}, proto.SecretMaterialKeys) + + // Verify SecretMaterialKeys deep copy + config.SecretMaterialKeys[0] = "mutated" + assert.Equal(t, "client_secret", proto.SecretMaterialKeys[0], "secret keys must be deep copied") + + // ExpiresAt conversion + require.NotNil(t, proto.ExpiresAtMs) + assert.Equal(t, MillisFromTime(expiresAt), *proto.ExpiresAtMs) +} + +func TestRefreshConfigToProto_NilExpiresAt(t *testing.T) { + config := &v1.RefreshConfig{ + Provider: "test", + CredentialKey: "KEY", + Strategy: v1.RefreshStrategyStatic, + } + + proto := RefreshConfigToProto(config) + + require.NotNil(t, proto) + assert.Nil(t, proto.ExpiresAtMs) +} + +func TestRefreshConfigToProto_Nil(t *testing.T) { + proto := RefreshConfigToProto(nil) + assert.Nil(t, proto) +} diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go new file mode 100644 index 0000000000..3ba031365d --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "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" +) + +// SandboxFromProto converts a proto Sandbox to an SDK Sandbox. +func SandboxFromProto(s *pb.Sandbox) *types.Sandbox { + if s == nil { + return nil + } + + result := &types.Sandbox{} + + if m := s.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Name = m.GetName() + result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.Labels = CopyStringMap(m.GetLabels()) + result.ResourceVersion = m.GetResourceVersion() + } + + if spec := s.GetSpec(); spec != nil { + result.Spec = sandboxSpecFromProto(spec) + } + + if status := s.GetStatus(); status != nil { + result.Status = sandboxStatusFromProto(status) + } else { + result.Status.Phase = types.SandboxUnknown + } + + return result +} + +func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { + result := types.SandboxSpec{ + LogLevel: spec.GetLogLevel(), + Environment: CopyStringMap(spec.GetEnvironment()), + Providers: CopyStringSlice(spec.GetProviders()), + Policy: SandboxPolicyFromProto(spec.GetPolicy()), + } + + if tmpl := spec.GetTemplate(); tmpl != nil { + result.Template = &types.SandboxTemplate{ + Image: tmpl.GetImage(), + RuntimeClassName: tmpl.GetRuntimeClassName(), + AgentSocket: tmpl.GetAgentSocket(), + Labels: CopyStringMap(tmpl.GetLabels()), + Annotations: CopyStringMap(tmpl.GetAnnotations()), + Environment: CopyStringMap(tmpl.GetEnvironment()), + UserNamespaces: CopyBoolPtr(tmpl.UserNamespaces), + } + } + + if rr := spec.GetResourceRequirements(); rr != nil { + if gpu := rr.GetGpu(); gpu != nil && gpu.Count != nil { + result.GPUCount = gpu.Count + } + } + + return result +} + +func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus { + result := types.SandboxStatus{ + SandboxName: status.GetSandboxName(), + AgentPod: status.GetAgentPod(), + AgentFd: status.GetAgentFd(), + SandboxFd: status.GetSandboxFd(), + Phase: SandboxPhaseFromProto(status.GetPhase()), + CurrentPolicyVersion: status.GetCurrentPolicyVersion(), + } + + for _, c := range status.GetConditions() { + result.Conditions = append(result.Conditions, types.SandboxCondition{ + Type: c.GetType(), + Status: c.GetStatus(), + Reason: c.GetReason(), + Message: c.GetMessage(), + LastTransitionTime: c.GetLastTransitionTime(), + }) + } + + return result +} + +// SandboxPhaseFromProto converts a proto SandboxPhase to an SDK SandboxPhase. +func SandboxPhaseFromProto(phase pb.SandboxPhase) types.SandboxPhase { + switch phase { + case pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING: + return types.SandboxProvisioning + case pb.SandboxPhase_SANDBOX_PHASE_READY: + return types.SandboxReady + case pb.SandboxPhase_SANDBOX_PHASE_ERROR: + return types.SandboxError + case pb.SandboxPhase_SANDBOX_PHASE_DELETING: + return types.SandboxDeleting + case pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN: + return types.SandboxUnknown + default: + return types.SandboxUnknown + } +} + +// SandboxPhaseToProto converts an SDK SandboxPhase to a proto SandboxPhase. +func SandboxPhaseToProto(phase types.SandboxPhase) pb.SandboxPhase { + switch phase { + case types.SandboxProvisioning: + return pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING + case types.SandboxReady: + return pb.SandboxPhase_SANDBOX_PHASE_READY + case types.SandboxError: + return pb.SandboxPhase_SANDBOX_PHASE_ERROR + case types.SandboxDeleting: + return pb.SandboxPhase_SANDBOX_PHASE_DELETING + case types.SandboxUnknown: + return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN + default: + return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN + } +} + +// SandboxToProto converts an SDK Sandbox to a proto Sandbox. +func SandboxToProto(s *types.Sandbox) *pb.Sandbox { + if s == nil { + return nil + } + + return &pb.Sandbox{ + Metadata: &dm.ObjectMeta{ + Id: s.ID, + Name: s.Name, + CreatedAtMs: MillisFromTime(s.CreatedAt), + Labels: CopyStringMap(s.Labels), + ResourceVersion: s.ResourceVersion, + }, + Spec: SandboxSpecToProto(&s.Spec), + } +} + +// SandboxSpecToProto converts an SDK SandboxSpec to a proto SandboxSpec. +func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { + if spec == nil { + return nil + } + + result := &pb.SandboxSpec{ + LogLevel: spec.LogLevel, + Environment: CopyStringMap(spec.Environment), + Providers: CopyStringSlice(spec.Providers), + Policy: SandboxPolicyToProto(spec.Policy), + } + + if spec.Template != nil { + result.Template = &pb.SandboxTemplate{ + Image: spec.Template.Image, + RuntimeClassName: spec.Template.RuntimeClassName, + AgentSocket: spec.Template.AgentSocket, + Labels: CopyStringMap(spec.Template.Labels), + Annotations: CopyStringMap(spec.Template.Annotations), + Environment: CopyStringMap(spec.Template.Environment), + UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces), + } + } + + if spec.GPUCount != nil { + result.ResourceRequirements = &pb.ResourceRequirements{ + Gpu: &pb.GpuResourceRequirements{ + Count: spec.GPUCount, + }, + } + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go new file mode 100644 index 0000000000..06a7075245 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestSandboxFromProto(t *testing.T) { + userNS := true + gpuCount := uint32(2) + proto := &pb.Sandbox{ + Metadata: &dm.ObjectMeta{ + Id: "sb-1", + Name: "my-sandbox", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"env": "dev"}, + ResourceVersion: 3, + }, + Spec: &pb.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"FOO": "bar"}, + Template: &pb.SandboxTemplate{ + Image: "nvidia/sandbox:latest", + RuntimeClassName: "kata", + AgentSocket: "/var/run/agent.sock", + Labels: map[string]string{"app": "test"}, + Annotations: map[string]string{"note": "hello"}, + Environment: map[string]string{"TMPL_VAR": "val"}, + UserNamespaces: &userNS, + }, + Providers: []string{"claude", "github"}, + ResourceRequirements: &pb.ResourceRequirements{ + Gpu: &pb.GpuResourceRequirements{ + Count: &gpuCount, + }, + }, + }, + Status: &pb.SandboxStatus{ + SandboxName: "sb-compute-1", + AgentPod: "agent-pod-xyz", + AgentFd: "fd-agent", + SandboxFd: "fd-sandbox", + Phase: pb.SandboxPhase_SANDBOX_PHASE_READY, + CurrentPolicyVersion: 7, + Conditions: []*pb.SandboxCondition{ + { + Type: "Ready", + Status: "True", + Reason: "AllGood", + Message: "Sandbox is ready", + LastTransitionTime: "2024-01-01T00:00:00Z", + }, + }, + }, + } + + s := SandboxFromProto(proto) + + require.NotNil(t, s) + assert.Equal(t, "sb-1", s.ID) + assert.Equal(t, "my-sandbox", s.Name) + assert.Equal(t, time.UnixMilli(1700000000000).UTC(), s.CreatedAt) + assert.Equal(t, map[string]string{"env": "dev"}, s.Labels) + assert.Equal(t, uint64(3), s.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) + require.NotNil(t, s.Spec.GPUCount) + assert.Equal(t, uint32(2), *s.Spec.GPUCount) + + // Template + require.NotNil(t, s.Spec.Template) + assert.Equal(t, "nvidia/sandbox:latest", s.Spec.Template.Image) + assert.Equal(t, "kata", s.Spec.Template.RuntimeClassName) + assert.Equal(t, "/var/run/agent.sock", s.Spec.Template.AgentSocket) + assert.Equal(t, map[string]string{"app": "test"}, s.Spec.Template.Labels) + assert.Equal(t, map[string]string{"note": "hello"}, s.Spec.Template.Annotations) + assert.Equal(t, map[string]string{"TMPL_VAR": "val"}, s.Spec.Template.Environment) + require.NotNil(t, s.Spec.Template.UserNamespaces) + assert.True(t, *s.Spec.Template.UserNamespaces) + + // Status + assert.Equal(t, "sb-compute-1", s.Status.SandboxName) + assert.Equal(t, "agent-pod-xyz", s.Status.AgentPod) + assert.Equal(t, "fd-agent", s.Status.AgentFd) + assert.Equal(t, "fd-sandbox", s.Status.SandboxFd) + assert.Equal(t, v1.SandboxReady, s.Status.Phase) + assert.Equal(t, uint32(7), s.Status.CurrentPolicyVersion) + require.Len(t, s.Status.Conditions, 1) + assert.Equal(t, "Ready", s.Status.Conditions[0].Type) + assert.Equal(t, "True", s.Status.Conditions[0].Status) + assert.Equal(t, "AllGood", s.Status.Conditions[0].Reason) + assert.Equal(t, "Sandbox is ready", s.Status.Conditions[0].Message) + assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime) +} + +func TestSandboxFromProto_NilFields(t *testing.T) { + proto := &pb.Sandbox{} + + s := SandboxFromProto(proto) + + require.NotNil(t, s) + assert.Empty(t, s.ID) + assert.Empty(t, s.Name) + assert.True(t, s.CreatedAt.IsZero()) + assert.Nil(t, s.Spec.Template) + assert.Nil(t, s.Spec.GPUCount) + assert.Equal(t, v1.SandboxUnknown, s.Status.Phase) +} + +func TestSandboxFromProto_Nil(t *testing.T) { + s := SandboxFromProto(nil) + assert.Nil(t, s) +} + +func TestSandboxPhaseFromProto(t *testing.T) { + tests := []struct { + proto pb.SandboxPhase + expected v1.SandboxPhase + }{ + {pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING, v1.SandboxProvisioning}, + {pb.SandboxPhase_SANDBOX_PHASE_READY, v1.SandboxReady}, + {pb.SandboxPhase_SANDBOX_PHASE_ERROR, v1.SandboxError}, + {pb.SandboxPhase_SANDBOX_PHASE_DELETING, v1.SandboxDeleting}, + {pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN, v1.SandboxUnknown}, + {pb.SandboxPhase_SANDBOX_PHASE_UNSPECIFIED, v1.SandboxUnknown}, + {pb.SandboxPhase(999), v1.SandboxUnknown}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, SandboxPhaseFromProto(tt.proto), "phase %v", tt.proto) + } +} + +func TestSandboxPhaseToProto(t *testing.T) { + tests := []struct { + sdk v1.SandboxPhase + expected pb.SandboxPhase + }{ + {v1.SandboxProvisioning, pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + {v1.SandboxReady, pb.SandboxPhase_SANDBOX_PHASE_READY}, + {v1.SandboxError, pb.SandboxPhase_SANDBOX_PHASE_ERROR}, + {v1.SandboxDeleting, pb.SandboxPhase_SANDBOX_PHASE_DELETING}, + {v1.SandboxUnknown, pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN}, + {v1.SandboxPhase("bogus"), pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, SandboxPhaseToProto(tt.sdk), "phase %v", tt.sdk) + } +} + +func TestSandboxToProto(t *testing.T) { + userNS := true + gpuCount := uint32(4) + s := &v1.Sandbox{ + ID: "sb-1", + Name: "my-sandbox", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"env": "dev"}, + ResourceVersion: 3, + Spec: v1.SandboxSpec{ + LogLevel: "info", + Environment: map[string]string{"KEY": "val"}, + Template: &v1.SandboxTemplate{ + Image: "img:v1", + RuntimeClassName: "runc", + AgentSocket: "/sock", + Labels: map[string]string{"l": "v"}, + Annotations: map[string]string{"a": "v"}, + Environment: map[string]string{"E": "V"}, + UserNamespaces: &userNS, + }, + Providers: []string{"prov-a"}, + GPUCount: &gpuCount, + }, + } + + p := SandboxToProto(s) + + require.NotNil(t, p) + require.NotNil(t, p.Metadata) + assert.Equal(t, "sb-1", p.Metadata.Id) + assert.Equal(t, "my-sandbox", p.Metadata.Name) + assert.Equal(t, int64(1700000000000), p.Metadata.CreatedAtMs) + assert.Equal(t, map[string]string{"env": "dev"}, p.Metadata.Labels) + assert.Equal(t, uint64(3), p.Metadata.ResourceVersion) + + require.NotNil(t, p.Spec) + assert.Equal(t, "info", p.Spec.LogLevel) + assert.Equal(t, map[string]string{"KEY": "val"}, p.Spec.Environment) + assert.Equal(t, []string{"prov-a"}, p.Spec.Providers) + + require.NotNil(t, p.Spec.ResourceRequirements) + require.NotNil(t, p.Spec.ResourceRequirements.Gpu) + assert.Equal(t, uint32(4), p.Spec.ResourceRequirements.Gpu.GetCount()) + + require.NotNil(t, p.Spec.Template) + assert.Equal(t, "img:v1", p.Spec.Template.Image) + assert.Equal(t, "runc", p.Spec.Template.RuntimeClassName) + assert.Equal(t, "/sock", p.Spec.Template.AgentSocket) + assert.Equal(t, map[string]string{"l": "v"}, p.Spec.Template.Labels) + assert.Equal(t, map[string]string{"a": "v"}, p.Spec.Template.Annotations) + assert.Equal(t, map[string]string{"E": "V"}, p.Spec.Template.Environment) + require.NotNil(t, p.Spec.Template.UserNamespaces) + assert.True(t, *p.Spec.Template.UserNamespaces) +} + +func TestSandboxToProto_Nil(t *testing.T) { + p := SandboxToProto(nil) + assert.Nil(t, p) +} + +func TestSandboxToProto_NilTemplate(t *testing.T) { + s := &v1.Sandbox{ + Spec: v1.SandboxSpec{ + LogLevel: "warn", + }, + } + + p := SandboxToProto(s) + + require.NotNil(t, p) + require.NotNil(t, p.Spec) + assert.Nil(t, p.Spec.Template) + assert.Nil(t, p.Spec.ResourceRequirements) +} + +func TestSandboxRoundTrip(t *testing.T) { + userNS := false + gpuCount := uint32(1) + original := &v1.Sandbox{ + ID: "sb-rt", + Name: "round-trip", + CreatedAt: time.UnixMilli(1700000000000).UTC(), + Labels: map[string]string{"team": "platform"}, + ResourceVersion: 10, + Spec: v1.SandboxSpec{ + LogLevel: "trace", + Environment: map[string]string{"A": "B"}, + Template: &v1.SandboxTemplate{ + Image: "img:rt", + UserNamespaces: &userNS, + }, + Providers: []string{"p1", "p2"}, + GPUCount: &gpuCount, + Policy: &v1.SandboxPolicy{ + Version: 3, + Filesystem: &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp"}, + }, + Landlock: &v1.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &v1.ProcessPolicy{ + RunAsUser: "sandbox", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "web": { + Name: "web", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + }, + }, + }, + } + + p := SandboxToProto(original) + back := SandboxFromProto(p) + + 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.ResourceVersion, back.ResourceVersion) + 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) + require.NotNil(t, back.Spec.GPUCount) + assert.Equal(t, *original.Spec.GPUCount, *back.Spec.GPUCount) + require.NotNil(t, back.Spec.Template) + assert.Equal(t, original.Spec.Template.Image, back.Spec.Template.Image) + require.NotNil(t, back.Spec.Template.UserNamespaces) + assert.Equal(t, *original.Spec.Template.UserNamespaces, *back.Spec.Template.UserNamespaces) + + // Policy round-trip + require.NotNil(t, back.Spec.Policy) + assert.Equal(t, uint32(3), back.Spec.Policy.Version) + require.NotNil(t, back.Spec.Policy.Filesystem) + assert.True(t, back.Spec.Policy.Filesystem.IncludeWorkdir) + assert.Equal(t, []string{"/etc", "/usr/share"}, back.Spec.Policy.Filesystem.ReadOnly) + assert.Equal(t, []string{"/tmp"}, back.Spec.Policy.Filesystem.ReadWrite) + require.NotNil(t, back.Spec.Policy.Landlock) + assert.Equal(t, "best_effort", back.Spec.Policy.Landlock.Compatibility) + require.NotNil(t, back.Spec.Policy.Process) + assert.Equal(t, "sandbox", back.Spec.Policy.Process.RunAsUser) + assert.Equal(t, "sandbox-group", back.Spec.Policy.Process.RunAsGroup) + require.Len(t, back.Spec.Policy.NetworkPolicies, 1) + webRule, ok := back.Spec.Policy.NetworkPolicies["web"] + require.True(t, ok) + assert.Equal(t, "web", webRule.Name) + require.Len(t, webRule.Endpoints, 1) + assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) +} + +func TestSandboxSpecToProto(t *testing.T) { + gpuCount := uint32(3) + spec := &v1.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"X": "Y"}, + Template: &v1.SandboxTemplate{ + Image: "img:spec", + }, + Providers: []string{"prov"}, + GPUCount: &gpuCount, + Policy: &v1.SandboxPolicy{ + Version: 2, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + p := SandboxSpecToProto(spec) + + require.NotNil(t, p) + assert.Equal(t, "debug", p.LogLevel) + assert.Equal(t, map[string]string{"X": "Y"}, p.Environment) + assert.Equal(t, []string{"prov"}, p.Providers) + require.NotNil(t, p.ResourceRequirements) + assert.Equal(t, uint32(3), p.ResourceRequirements.Gpu.GetCount()) + require.NotNil(t, p.Template) + assert.Equal(t, "img:spec", p.Template.Image) + + // Policy conversion + require.NotNil(t, p.Policy) + assert.Equal(t, uint32(2), p.Policy.Version) + require.NotNil(t, p.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, p.Policy.Filesystem.ReadOnly) +} + +func TestSandboxSpecToProto_Nil(t *testing.T) { + p := SandboxSpecToProto(nil) + assert.Nil(t, p) +} + +// Verify proto import is used (suppress unused import warning). +var _ = proto.Marshal diff --git a/sdk/go/openshell/v1/internal/converter/service.go b/sdk/go/openshell/v1/internal/converter/service.go new file mode 100644 index 0000000000..20949016b7 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "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" +) + +// ServiceEndpointFromProto converts a proto ServiceEndpointResponse to an SDK ServiceEndpoint. +// The response flattens the nested Endpoint and top-level URL into a single SDK type. +func ServiceEndpointFromProto(resp *pb.ServiceEndpointResponse) *types.ServiceEndpoint { + if resp == nil { + return nil + } + + result := &types.ServiceEndpoint{ + URL: resp.GetUrl(), + } + + if ep := resp.GetEndpoint(); ep != nil { + result.SandboxID = ep.GetSandboxId() + result.SandboxName = ep.GetSandboxName() + result.ServiceName = ep.GetServiceName() + result.TargetPort = ep.GetTargetPort() + result.Domain = ep.GetDomain() + + if m := ep.GetMetadata(); m != nil { + result.ID = m.GetId() + } + } + + return result +} + +// ServiceEndpointToProto converts an SDK ServiceEndpoint to a proto ServiceEndpointResponse. +func ServiceEndpointToProto(se *types.ServiceEndpoint) *pb.ServiceEndpointResponse { + if se == nil { + return nil + } + + return &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: se.ID, + }, + SandboxId: se.SandboxID, + SandboxName: se.SandboxName, + ServiceName: se.ServiceName, + TargetPort: se.TargetPort, + Domain: se.Domain, + }, + Url: se.URL, + } +} diff --git a/sdk/go/openshell/v1/internal/converter/service_test.go b/sdk/go/openshell/v1/internal/converter/service_test.go new file mode 100644 index 0000000000..c04ddcd552 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service_test.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceEndpointFromProto(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: "svc-1", + }, + SandboxId: "sb-1", + SandboxName: "my-sandbox", + ServiceName: "http-server", + TargetPort: 8080, + Domain: true, + }, + Url: "https://svc-1.example.com", + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Equal(t, "svc-1", se.ID) + assert.Equal(t, "sb-1", se.SandboxID) + assert.Equal(t, "my-sandbox", se.SandboxName) + assert.Equal(t, "http-server", se.ServiceName) + assert.Equal(t, uint32(8080), se.TargetPort) + assert.True(t, se.Domain) + assert.Equal(t, "https://svc-1.example.com", se.URL) +} + +func TestServiceEndpointFromProto_NilEndpoint(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Url: "https://orphan.example.com", + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Empty(t, se.ID) + assert.Empty(t, se.SandboxID) + assert.Equal(t, "https://orphan.example.com", se.URL) +} + +func TestServiceEndpointFromProto_NilMetadata(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + SandboxId: "sb-2", + ServiceName: "api", + TargetPort: 3000, + }, + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Empty(t, se.ID) + assert.Equal(t, "sb-2", se.SandboxID) + assert.Equal(t, "api", se.ServiceName) + assert.Equal(t, uint32(3000), se.TargetPort) +} + +func TestServiceEndpointFromProto_Nil(t *testing.T) { + se := ServiceEndpointFromProto(nil) + assert.Nil(t, se) +} + +func TestServiceEndpointToProto(t *testing.T) { + se := &v1.ServiceEndpoint{ + ID: "svc-1", + SandboxID: "sb-1", + SandboxName: "my-sandbox", + ServiceName: "http-server", + TargetPort: 8080, + Domain: true, + URL: "https://svc-1.example.com", + } + + resp := ServiceEndpointToProto(se) + + require.NotNil(t, resp) + require.NotNil(t, resp.Endpoint) + require.NotNil(t, resp.Endpoint.Metadata) + assert.Equal(t, "svc-1", resp.Endpoint.Metadata.Id) + assert.Equal(t, "sb-1", resp.Endpoint.SandboxId) + assert.Equal(t, "my-sandbox", resp.Endpoint.SandboxName) + assert.Equal(t, "http-server", resp.Endpoint.ServiceName) + assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort) + assert.True(t, resp.Endpoint.Domain) + assert.Equal(t, "https://svc-1.example.com", resp.Url) +} + +func TestServiceEndpointToProto_Nil(t *testing.T) { + resp := ServiceEndpointToProto(nil) + assert.Nil(t, resp) +} + +func TestServiceEndpointRoundTrip(t *testing.T) { + original := &v1.ServiceEndpoint{ + ID: "svc-rt", + SandboxID: "sb-rt", + SandboxName: "round-trip", + ServiceName: "web", + TargetPort: 9090, + Domain: false, + URL: "http://localhost:9090", + } + + proto := ServiceEndpointToProto(original) + back := ServiceEndpointFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.SandboxID, back.SandboxID) + assert.Equal(t, original.SandboxName, back.SandboxName) + assert.Equal(t, original.ServiceName, back.ServiceName) + assert.Equal(t, original.TargetPort, back.TargetPort) + assert.Equal(t, original.Domain, back.Domain) + assert.Equal(t, original.URL, back.URL) +} diff --git a/sdk/go/openshell/v1/internal/converter/setting.go b/sdk/go/openshell/v1/internal/converter/setting.go new file mode 100644 index 0000000000..2c31185e59 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/setting.go @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- SettingValue oneof conversion --- + +// SettingValueFromProto converts a proto SettingValue (oneof) to an SDK SettingValue. +func SettingValueFromProto(pv *sbv1.SettingValue) *v1.SettingValue { + if pv == nil { + return nil + } + sv := &v1.SettingValue{} + switch v := pv.GetValue().(type) { + case *sbv1.SettingValue_StringValue: + sv.Type = v1.SettingValueString + sv.StringVal = v.StringValue + case *sbv1.SettingValue_BoolValue: + sv.Type = v1.SettingValueBool + sv.BoolVal = v.BoolValue + case *sbv1.SettingValue_IntValue: + sv.Type = v1.SettingValueInt + sv.IntVal = v.IntValue + case *sbv1.SettingValue_BytesValue: + sv.Type = v1.SettingValueBytes + sv.BytesVal = CopyByteSlice(v.BytesValue) + } + return sv +} + +// SettingValueToProto converts an SDK SettingValue to a proto SettingValue (oneof). +func SettingValueToProto(sv *v1.SettingValue) *sbv1.SettingValue { + if sv == nil { + return nil + } + pv := &sbv1.SettingValue{} + switch sv.Type { + case v1.SettingValueString: + pv.Value = &sbv1.SettingValue_StringValue{StringValue: sv.StringVal} + case v1.SettingValueBool: + pv.Value = &sbv1.SettingValue_BoolValue{BoolValue: sv.BoolVal} + case v1.SettingValueInt: + pv.Value = &sbv1.SettingValue_IntValue{IntValue: sv.IntVal} + case v1.SettingValueBytes: + pv.Value = &sbv1.SettingValue_BytesValue{BytesValue: CopyByteSlice(sv.BytesVal)} + } + return pv +} + +// --- Enum conversions --- + +// SettingScopeFromProto converts a proto SettingScope enum to an SDK SettingScope. +func SettingScopeFromProto(ps sbv1.SettingScope) v1.SettingScope { + switch ps { + case sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED: + return v1.SettingScopeUnspecified + case sbv1.SettingScope_SETTING_SCOPE_SANDBOX: + return v1.SettingScopeSandbox + case sbv1.SettingScope_SETTING_SCOPE_GLOBAL: + return v1.SettingScopeGlobal + default: + return v1.SettingScope("") + } +} + +// SettingScopeToProto converts an SDK SettingScope to a proto SettingScope enum. +func SettingScopeToProto(s v1.SettingScope) sbv1.SettingScope { + switch s { + case v1.SettingScopeUnspecified: + return sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED + case v1.SettingScopeSandbox: + return sbv1.SettingScope_SETTING_SCOPE_SANDBOX + case v1.SettingScopeGlobal: + return sbv1.SettingScope_SETTING_SCOPE_GLOBAL + default: + return sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED + } +} + +// PolicySourceFromProto converts a proto PolicySource enum to an SDK PolicySource. +func PolicySourceFromProto(ps sbv1.PolicySource) v1.PolicySource { + switch ps { + case sbv1.PolicySource_POLICY_SOURCE_UNSPECIFIED: + return v1.PolicySourceUnspecified + case sbv1.PolicySource_POLICY_SOURCE_SANDBOX: + return v1.PolicySourceSandbox + case sbv1.PolicySource_POLICY_SOURCE_GLOBAL: + return v1.PolicySourceGlobal + default: + return v1.PolicySource("") + } +} + +// --- EffectiveSetting --- + +// EffectiveSettingFromProto converts a proto EffectiveSetting to an SDK EffectiveSetting. +func EffectiveSettingFromProto(pv *sbv1.EffectiveSetting) *v1.EffectiveSetting { + if pv == nil { + return nil + } + es := &v1.EffectiveSetting{ + Scope: SettingScopeFromProto(pv.GetScope()), + } + if sv := SettingValueFromProto(pv.GetValue()); sv != nil { + es.Value = *sv + } + return es +} + +// --- SandboxConfig --- + +// SandboxConfigFromProto converts a GetSandboxConfigResponse to an SDK SandboxConfig. +func SandboxConfigFromProto(resp *sbv1.GetSandboxConfigResponse) *v1.SandboxConfig { + if resp == nil { + return nil + } + sc := &v1.SandboxConfig{ + PolicyVersion: resp.GetVersion(), + PolicyHash: resp.GetPolicyHash(), + ConfigRevision: resp.GetConfigRevision(), + PolicySource: PolicySourceFromProto(resp.GetPolicySource()), + GlobalPolicyVersion: resp.GetGlobalPolicyVersion(), + ProviderEnvRevision: resp.GetProviderEnvRevision(), + } + + // Convert proto SandboxPolicy to typed SDK SandboxPolicy. + sc.Policy = SandboxPolicyFromProto(resp.GetPolicy()) + + // Deep-copy settings map. + if m := resp.GetSettings(); len(m) > 0 { + sc.Settings = make(map[string]v1.EffectiveSetting, len(m)) + for k, v := range m { + if es := EffectiveSettingFromProto(v); es != nil { + sc.Settings[k] = *es + } + } + } + + return sc +} + +// --- GatewayConfig --- + +// GatewayConfigFromProto converts a GetGatewayConfigResponse to an SDK GatewayConfig. +func GatewayConfigFromProto(resp *sbv1.GetGatewayConfigResponse) *v1.GatewayConfig { + if resp == nil { + return nil + } + gc := &v1.GatewayConfig{ + SettingsRevision: resp.GetSettingsRevision(), + } + + // Deep-copy settings map. + if m := resp.GetSettings(); len(m) > 0 { + gc.Settings = make(map[string]v1.SettingValue, len(m)) + for k, v := range m { + if sv := SettingValueFromProto(v); sv != nil { + gc.Settings[k] = *sv + } + } + } + + return gc +} + +// --- ConfigUpdate --- + +// ConfigUpdateToProto converts an SDK ConfigUpdate to an UpdateConfigRequest. +func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { + if cu == nil { + return nil, nil + } + req := &pb.UpdateConfigRequest{ + Name: cu.Name, + SettingKey: cu.SettingKey, + SettingValue: SettingValueToProto(cu.SettingValue), + DeleteSetting: cu.DeleteSetting, + Global: cu.Global, + ExpectedResourceVersion: cu.ExpectedResourceVersion, + } + + // Convert typed SDK SandboxPolicy to proto SandboxPolicy. + req.Policy = SandboxPolicyToProto(cu.Policy) + + // Convert typed merge operations with validation. + if len(cu.MergeOperations) > 0 { + req.MergeOperations = make([]*pb.PolicyMergeOperation, len(cu.MergeOperations)) + for i := range cu.MergeOperations { + converted, err := PolicyMergeOperationToProto(&cu.MergeOperations[i]) + if err != nil { + return nil, fmt.Errorf("merge operation [%d]: %w", i, err) + } + req.MergeOperations[i] = converted + } + } + + return req, nil +} + +// --- PolicyMergeOperation --- + +// PolicyMergeOperationToProto converts an SDK PolicyMergeOperation to a proto PolicyMergeOperation. +// Exactly one of the pointer fields must be non-nil. Returns an error if zero or multiple are set. +func PolicyMergeOperationToProto(op *v1.PolicyMergeOperation) (*pb.PolicyMergeOperation, error) { + if op == nil { + return nil, nil + } + set := boolCount(op.AddRule != nil, op.RemoveEndpoint != nil, op.RemoveRule != nil, + op.AddDenyRules != nil, op.AddAllowRules != nil, op.RemoveBinary != nil) + if set != 1 { + return nil, fmt.Errorf("PolicyMergeOperation: exactly one variant must be set, got %d", set) + } + pmo := &pb.PolicyMergeOperation{} + switch { + case op.AddRule != nil: + rule := NetworkPolicyRuleToProto(&op.AddRule.Rule) + pmo.Operation = &pb.PolicyMergeOperation_AddRule{ + AddRule: &pb.AddNetworkRule{ + RuleName: op.AddRule.RuleName, + Rule: rule, + }, + } + case op.RemoveEndpoint != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveEndpoint{ + RemoveEndpoint: &pb.RemoveNetworkEndpoint{ + RuleName: op.RemoveEndpoint.RuleName, + Host: op.RemoveEndpoint.Host, + Port: op.RemoveEndpoint.Port, + }, + } + case op.RemoveRule != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveRule{ + RemoveRule: &pb.RemoveNetworkRule{ + RuleName: op.RemoveRule.RuleName, + }, + } + case op.AddDenyRules != nil: + var denyRules []*sbv1.L7DenyRule + if len(op.AddDenyRules.DenyRules) > 0 { + denyRules = make([]*sbv1.L7DenyRule, len(op.AddDenyRules.DenyRules)) + for i := range op.AddDenyRules.DenyRules { + denyRules[i] = l7DenyRuleToProto(&op.AddDenyRules.DenyRules[i]) + } + } + pmo.Operation = &pb.PolicyMergeOperation_AddDenyRules{ + AddDenyRules: &pb.AddDenyRules{ + Host: op.AddDenyRules.Host, + Port: op.AddDenyRules.Port, + DenyRules: denyRules, + }, + } + case op.AddAllowRules != nil: + var rules []*sbv1.L7Rule + if len(op.AddAllowRules.Rules) > 0 { + rules = make([]*sbv1.L7Rule, len(op.AddAllowRules.Rules)) + for i := range op.AddAllowRules.Rules { + rules[i] = l7RuleToProto(&op.AddAllowRules.Rules[i]) + } + } + pmo.Operation = &pb.PolicyMergeOperation_AddAllowRules{ + AddAllowRules: &pb.AddAllowRules{ + Host: op.AddAllowRules.Host, + Port: op.AddAllowRules.Port, + Rules: rules, + }, + } + case op.RemoveBinary != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveBinary{ + RemoveBinary: &pb.RemoveNetworkBinary{ + RuleName: op.RemoveBinary.RuleName, + BinaryPath: op.RemoveBinary.BinaryPath, + }, + } + } + return pmo, nil +} + +// --- ConfigUpdateResult --- + +// ConfigUpdateResultFromProto converts an UpdateConfigResponse to an SDK ConfigUpdateResult. +func ConfigUpdateResultFromProto(resp *pb.UpdateConfigResponse) *v1.ConfigUpdateResult { + if resp == nil { + return nil + } + return &v1.ConfigUpdateResult{ + Version: resp.GetVersion(), + PolicyHash: resp.GetPolicyHash(), + SettingsRevision: resp.GetSettingsRevision(), + Deleted: resp.GetDeleted(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/setting_test.go b/sdk/go/openshell/v1/internal/converter/setting_test.go new file mode 100644 index 0000000000..122e0f514b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/setting_test.go @@ -0,0 +1,825 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- SettingValue oneof mapping --- + +func TestSettingValueFromProto_StringValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "hello"}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueString, sv.Type) + assert.Equal(t, "hello", sv.StringVal) + assert.False(t, sv.BoolVal) + assert.Zero(t, sv.IntVal) + assert.Nil(t, sv.BytesVal) +} + +func TestSettingValueFromProto_BoolValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueBool, sv.Type) + assert.True(t, sv.BoolVal) + assert.Empty(t, sv.StringVal) +} + +func TestSettingValueFromProto_IntValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 42}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueInt, sv.Type) + assert.Equal(t, int64(42), sv.IntVal) +} + +func TestSettingValueFromProto_BytesValue(t *testing.T) { + data := []byte{0xDE, 0xAD, 0xBE, 0xEF} + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: data}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueBytes, sv.Type) + assert.Equal(t, data, sv.BytesVal) +} + +func TestSettingValueFromProto_BytesDeepCopy(t *testing.T) { + data := []byte{0x01, 0x02, 0x03} + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: data}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + // Mutate original data — SDK copy must not be affected. + data[0] = 0xFF + assert.Equal(t, byte(0x01), sv.BytesVal[0], "deep copy must isolate SDK from proto") +} + +func TestSettingValueFromProto_NilOneof(t *testing.T) { + pv := &sbv1.SettingValue{} + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueType(""), sv.Type) +} + +func TestSettingValueFromProto_Nil(t *testing.T) { + sv := SettingValueFromProto(nil) + assert.Nil(t, sv) +} + +func TestSettingValueToProto_StringValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "world", + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, "world", pv.GetStringValue()) +} + +func TestSettingValueToProto_BoolValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueBool, + BoolVal: true, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.True(t, pv.GetBoolValue()) +} + +func TestSettingValueToProto_IntValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueInt, + IntVal: 99, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, int64(99), pv.GetIntValue()) +} + +func TestSettingValueToProto_BytesValue(t *testing.T) { + data := []byte{0xCA, 0xFE} + sv := &v1.SettingValue{ + Type: v1.SettingValueBytes, + BytesVal: data, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, data, pv.GetBytesValue()) +} + +func TestSettingValueToProto_Nil(t *testing.T) { + pv := SettingValueToProto(nil) + assert.Nil(t, pv) +} + +// --- SettingScope enum mapping --- + +func TestSettingScopeFromProto(t *testing.T) { + tests := []struct { + proto sbv1.SettingScope + want v1.SettingScope + }{ + {sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED, v1.SettingScopeUnspecified}, + {sbv1.SettingScope_SETTING_SCOPE_SANDBOX, v1.SettingScopeSandbox}, + {sbv1.SettingScope_SETTING_SCOPE_GLOBAL, v1.SettingScopeGlobal}, + {sbv1.SettingScope(999), v1.SettingScope("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, SettingScopeFromProto(tt.proto)) + }) + } +} + +func TestSettingScopeToProto(t *testing.T) { + tests := []struct { + sdk v1.SettingScope + want sbv1.SettingScope + }{ + {v1.SettingScopeUnspecified, sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED}, + {v1.SettingScopeSandbox, sbv1.SettingScope_SETTING_SCOPE_SANDBOX}, + {v1.SettingScopeGlobal, sbv1.SettingScope_SETTING_SCOPE_GLOBAL}, + {v1.SettingScope("unknown"), sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, SettingScopeToProto(tt.sdk)) + }) + } +} + +// --- PolicySource enum mapping --- + +func TestPolicySourceFromProto(t *testing.T) { + tests := []struct { + proto sbv1.PolicySource + want v1.PolicySource + }{ + {sbv1.PolicySource_POLICY_SOURCE_UNSPECIFIED, v1.PolicySourceUnspecified}, + {sbv1.PolicySource_POLICY_SOURCE_SANDBOX, v1.PolicySourceSandbox}, + {sbv1.PolicySource_POLICY_SOURCE_GLOBAL, v1.PolicySourceGlobal}, + {sbv1.PolicySource(999), v1.PolicySource("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicySourceFromProto(tt.proto)) + }) + } +} + +// --- EffectiveSetting --- + +func TestEffectiveSettingFromProto(t *testing.T) { + pv := &sbv1.EffectiveSetting{ + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "val"}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + } + + es := EffectiveSettingFromProto(pv) + + require.NotNil(t, es) + assert.Equal(t, v1.SettingValueString, es.Value.Type) + assert.Equal(t, "val", es.Value.StringVal) + assert.Equal(t, v1.SettingScopeSandbox, es.Scope) +} + +func TestEffectiveSettingFromProto_NilValue(t *testing.T) { + pv := &sbv1.EffectiveSetting{ + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + } + + es := EffectiveSettingFromProto(pv) + + require.NotNil(t, es) + assert.Equal(t, v1.SettingValueType(""), es.Value.Type) + assert.Equal(t, v1.SettingScopeGlobal, es.Scope) +} + +func TestEffectiveSettingFromProto_Nil(t *testing.T) { + es := EffectiveSettingFromProto(nil) + assert.Nil(t, es) +} + +// --- SandboxConfig (GetSandboxConfigResponse → SandboxConfig) --- + +func TestSandboxConfigFromProto(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Policy: &sbv1.SandboxPolicy{ + Version: 7, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + Version: 3, + PolicyHash: "sha256:abc", + Settings: map[string]*sbv1.EffectiveSetting{ + "timeout": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 30}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + "debug": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + }, + }, + ConfigRevision: 100, + PolicySource: sbv1.PolicySource_POLICY_SOURCE_SANDBOX, + GlobalPolicyVersion: 5, + ProviderEnvRevision: 200, + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + require.NotNil(t, sc.Policy, "typed SandboxPolicy must be populated") + assert.Equal(t, uint32(7), sc.Policy.Version) + require.NotNil(t, sc.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) + assert.Equal(t, uint32(3), sc.PolicyVersion) + assert.Equal(t, "sha256:abc", sc.PolicyHash) + assert.Equal(t, uint64(100), sc.ConfigRevision) + assert.Equal(t, v1.PolicySourceSandbox, sc.PolicySource) + assert.Equal(t, uint32(5), sc.GlobalPolicyVersion) + assert.Equal(t, uint64(200), sc.ProviderEnvRevision) + + require.Len(t, sc.Settings, 2) + + timeout := sc.Settings["timeout"] + assert.Equal(t, v1.SettingValueInt, timeout.Value.Type) + assert.Equal(t, int64(30), timeout.Value.IntVal) + assert.Equal(t, v1.SettingScopeSandbox, timeout.Scope) + + debug := sc.Settings["debug"] + assert.Equal(t, v1.SettingValueBool, debug.Value.Type) + assert.True(t, debug.Value.BoolVal) + assert.Equal(t, v1.SettingScopeGlobal, debug.Scope) +} + +func TestSandboxConfigFromProto_NilPolicy(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Version: 1, + PolicyHash: "sha256:empty", + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + assert.Nil(t, sc.Policy) + assert.Equal(t, uint32(1), sc.PolicyVersion) + assert.Empty(t, sc.Settings) +} + +func TestSandboxConfigFromProto_Nil(t *testing.T) { + sc := SandboxConfigFromProto(nil) + assert.Nil(t, sc) +} + +func TestSandboxConfigFromProto_SettingsDeepCopy(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Settings: map[string]*sbv1.EffectiveSetting{ + "key1": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "original"}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + }, + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + // Mutate the proto map — SDK map must not be affected. + resp.Settings["key1"].Value.Value = &sbv1.SettingValue_StringValue{StringValue: "mutated"} + assert.Equal(t, "original", sc.Settings["key1"].Value.StringVal, + "deep copy must isolate SDK settings from proto") +} + +// --- GatewayConfig (GetGatewayConfigResponse → GatewayConfig) --- + +func TestGatewayConfigFromProto(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + Settings: map[string]*sbv1.SettingValue{ + "region": { + Value: &sbv1.SettingValue_StringValue{StringValue: "us-west-2"}, + }, + "max_sandboxes": { + Value: &sbv1.SettingValue_IntValue{IntValue: 100}, + }, + }, + SettingsRevision: 42, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + assert.Equal(t, uint64(42), gc.SettingsRevision) + require.Len(t, gc.Settings, 2) + + region := gc.Settings["region"] + assert.Equal(t, v1.SettingValueString, region.Type) + assert.Equal(t, "us-west-2", region.StringVal) + + maxSb := gc.Settings["max_sandboxes"] + assert.Equal(t, v1.SettingValueInt, maxSb.Type) + assert.Equal(t, int64(100), maxSb.IntVal) +} + +func TestGatewayConfigFromProto_EmptySettings(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + SettingsRevision: 1, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + assert.Equal(t, uint64(1), gc.SettingsRevision) + assert.Empty(t, gc.Settings) +} + +func TestGatewayConfigFromProto_Nil(t *testing.T) { + gc := GatewayConfigFromProto(nil) + assert.Nil(t, gc) +} + +func TestGatewayConfigFromProto_SettingsDeepCopy(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + Settings: map[string]*sbv1.SettingValue{ + "key": { + Value: &sbv1.SettingValue_StringValue{StringValue: "original"}, + }, + }, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + // Mutate the proto map — SDK map must not be affected. + resp.Settings["key"].Value = &sbv1.SettingValue_StringValue{StringValue: "mutated"} + assert.Equal(t, "original", gc.Settings["key"].StringVal, + "deep copy must isolate SDK settings from proto") +} + +// --- ConfigUpdate (ConfigUpdate → UpdateConfigRequest) --- + +func TestConfigUpdateToProto(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "timeout", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueInt, + IntVal: 60, + }, + DeleteSetting: false, + Global: false, + ExpectedResourceVersion: 7, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.Name) + assert.Equal(t, "timeout", req.SettingKey) + require.NotNil(t, req.SettingValue) + assert.Equal(t, int64(60), req.SettingValue.GetIntValue()) + assert.False(t, req.DeleteSetting) + assert.False(t, req.Global) + assert.Equal(t, uint64(7), req.ExpectedResourceVersion) + assert.Nil(t, req.Policy) + assert.Empty(t, req.MergeOperations) +} + +func TestConfigUpdateToProto_WithPolicy(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-policy", + Policy: &v1.SandboxPolicy{ + Version: 3, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + require.NotNil(t, req.Policy, "typed SandboxPolicy must be converted to proto") + assert.Equal(t, uint32(3), req.Policy.GetVersion()) + require.NotNil(t, req.Policy.GetFilesystem()) + assert.Equal(t, []string{"/etc"}, req.Policy.GetFilesystem().GetReadOnly()) +} + +func TestConfigUpdateToProto_WithDeleteSetting(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-del", + SettingKey: "obsolete-key", + DeleteSetting: true, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "obsolete-key", req.SettingKey) + assert.True(t, req.DeleteSetting) +} + +func TestConfigUpdateToProto_GlobalScope(t *testing.T) { + cu := &v1.ConfigUpdate{ + SettingKey: "global-setting", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "global-val", + }, + Global: true, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.True(t, req.Global) + assert.Empty(t, req.Name) +} + +func TestConfigUpdateToProto_NilSettingValue(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-nil", + SettingKey: "key", + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Nil(t, req.SettingValue) +} + +func TestConfigUpdateToProto_Nil(t *testing.T) { + req, err := ConfigUpdateToProto(nil) + require.NoError(t, err) + assert.Nil(t, req) +} + +func TestConfigUpdateToProto_NilPolicy(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-nil-policy", + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Nil(t, req.Policy, "nil SDK policy must produce nil proto policy") +} + +// --- ConfigUpdateResult (UpdateConfigResponse → ConfigUpdateResult) --- + +func TestConfigUpdateResultFromProto(t *testing.T) { + resp := &pb.UpdateConfigResponse{ + Version: 10, + PolicyHash: "sha256:updated", + SettingsRevision: 55, + Deleted: true, + } + + result := ConfigUpdateResultFromProto(resp) + + require.NotNil(t, result) + assert.Equal(t, uint32(10), result.Version) + assert.Equal(t, "sha256:updated", result.PolicyHash) + assert.Equal(t, uint64(55), result.SettingsRevision) + assert.True(t, result.Deleted) +} + +func TestConfigUpdateResultFromProto_DefaultValues(t *testing.T) { + resp := &pb.UpdateConfigResponse{} + + result := ConfigUpdateResultFromProto(resp) + + require.NotNil(t, result) + assert.Zero(t, result.Version) + assert.Empty(t, result.PolicyHash) + assert.Zero(t, result.SettingsRevision) + assert.False(t, result.Deleted) +} + +func TestConfigUpdateResultFromProto_Nil(t *testing.T) { + result := ConfigUpdateResultFromProto(nil) + assert.Nil(t, result) +} + +// --- PolicyMergeOperationToProto --- + +func TestPolicyMergeOperationToProto_Nil(t *testing.T) { + pmo, err := PolicyMergeOperationToProto(nil) + assert.NoError(t, err) + assert.Nil(t, pmo) +} + +func TestPolicyMergeOperationToProto_Empty(t *testing.T) { + op := &v1.PolicyMergeOperation{} + _, err := PolicyMergeOperationToProto(op) + + require.Error(t, err) + assert.Contains(t, err.Error(), "got 0") +} + +func TestPolicyMergeOperationToProto_MultipleSet(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddRule: &v1.AddNetworkRule{RuleName: "r1"}, + RemoveRule: &v1.RemoveNetworkRule{RuleName: "r2"}, + } + _, err := PolicyMergeOperationToProto(op) + + require.Error(t, err) + assert.Contains(t, err.Error(), "got 2") +} + +func TestPolicyMergeOperationToProto_AddRule(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddRule: &v1.AddNetworkRule{ + RuleName: "allow-api", + Rule: v1.NetworkPolicyRule{ + Name: "allow-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "tcp"}, + }, + Binaries: []v1.PolicyNetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + ar := pmo.GetAddRule() + require.NotNil(t, ar, "expected AddRule variant") + assert.Equal(t, "allow-api", ar.GetRuleName()) + require.NotNil(t, ar.GetRule()) + assert.Equal(t, "allow-api", ar.GetRule().GetName()) + require.Len(t, ar.GetRule().GetEndpoints(), 1) + assert.Equal(t, "api.example.com", ar.GetRule().GetEndpoints()[0].GetHost()) + assert.Equal(t, uint32(443), ar.GetRule().GetEndpoints()[0].GetPort()) + require.Len(t, ar.GetRule().GetBinaries(), 1) + assert.Equal(t, "/usr/bin/curl", ar.GetRule().GetBinaries()[0].GetPath()) +} + +func TestPolicyMergeOperationToProto_RemoveEndpoint(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveEndpoint: &v1.RemoveNetworkEndpoint{ + RuleName: "allow-api", + Host: "old.example.com", + Port: 8080, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + re := pmo.GetRemoveEndpoint() + require.NotNil(t, re, "expected RemoveEndpoint variant") + assert.Equal(t, "allow-api", re.GetRuleName()) + assert.Equal(t, "old.example.com", re.GetHost()) + assert.Equal(t, uint32(8080), re.GetPort()) +} + +func TestPolicyMergeOperationToProto_RemoveRule(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveRule: &v1.RemoveNetworkRule{ + RuleName: "obsolete-rule", + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + rr := pmo.GetRemoveRule() + require.NotNil(t, rr, "expected RemoveRule variant") + assert.Equal(t, "obsolete-rule", rr.GetRuleName()) +} + +func TestPolicyMergeOperationToProto_AddDenyRules(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddDenyRules: &v1.AddDenyRules{ + Host: "blocked.example.com", + Port: 443, + DenyRules: []v1.L7DenyRule{ + { + Method: "POST", + Path: "/admin", + }, + { + Method: "GET", + OperationType: "query", + OperationName: "InternalData", + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + adr := pmo.GetAddDenyRules() + require.NotNil(t, adr, "expected AddDenyRules variant") + assert.Equal(t, "blocked.example.com", adr.GetHost()) + assert.Equal(t, uint32(443), adr.GetPort()) + require.Len(t, adr.GetDenyRules(), 2) + assert.Equal(t, "POST", adr.GetDenyRules()[0].GetMethod()) + assert.Equal(t, "/admin", adr.GetDenyRules()[0].GetPath()) + assert.Equal(t, "InternalData", adr.GetDenyRules()[1].GetOperationName()) +} + +func TestPolicyMergeOperationToProto_AddAllowRules(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddAllowRules: &v1.AddAllowRules{ + Host: "api.example.com", + Port: 443, + Rules: []v1.L7Rule{ + { + Allow: &v1.L7Allow{ + Method: "GET", + Path: "/health", + }, + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + aar := pmo.GetAddAllowRules() + require.NotNil(t, aar, "expected AddAllowRules variant") + assert.Equal(t, "api.example.com", aar.GetHost()) + assert.Equal(t, uint32(443), aar.GetPort()) + require.Len(t, aar.GetRules(), 1) + require.NotNil(t, aar.GetRules()[0].GetAllow()) + assert.Equal(t, "GET", aar.GetRules()[0].GetAllow().GetMethod()) + assert.Equal(t, "/health", aar.GetRules()[0].GetAllow().GetPath()) +} + +func TestPolicyMergeOperationToProto_RemoveBinary(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveBinary: &v1.RemoveNetworkBinary{ + RuleName: "allow-api", + BinaryPath: "/usr/bin/wget", + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + rb := pmo.GetRemoveBinary() + require.NotNil(t, rb, "expected RemoveBinary variant") + assert.Equal(t, "allow-api", rb.GetRuleName()) + assert.Equal(t, "/usr/bin/wget", rb.GetBinaryPath()) +} + +// --- ConfigUpdateToProto with MergeOperations --- + +func TestConfigUpdateToProto_WithMergeOperations(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "my-sandbox", + MergeOperations: []v1.PolicyMergeOperation{ + { + RemoveRule: &v1.RemoveNetworkRule{RuleName: "old-rule"}, + }, + { + AddRule: &v1.AddNetworkRule{ + RuleName: "new-rule", + Rule: v1.NetworkPolicyRule{ + Name: "new-rule", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "svc.local", Port: 8080}, + }, + }, + }, + }, + { + RemoveBinary: &v1.RemoveNetworkBinary{ + RuleName: "new-rule", + BinaryPath: "/tmp/bad", + }, + }, + }, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.GetName()) + require.Len(t, req.GetMergeOperations(), 3) + + // First: RemoveRule + rr := req.GetMergeOperations()[0].GetRemoveRule() + require.NotNil(t, rr) + assert.Equal(t, "old-rule", rr.GetRuleName()) + + // Second: AddRule + ar := req.GetMergeOperations()[1].GetAddRule() + require.NotNil(t, ar) + assert.Equal(t, "new-rule", ar.GetRuleName()) + require.NotNil(t, ar.GetRule()) + require.Len(t, ar.GetRule().GetEndpoints(), 1) + assert.Equal(t, "svc.local", ar.GetRule().GetEndpoints()[0].GetHost()) + + // Third: RemoveBinary + rb := req.GetMergeOperations()[2].GetRemoveBinary() + require.NotNil(t, rb) + assert.Equal(t, "/tmp/bad", rb.GetBinaryPath()) +} + +func TestConfigUpdateToProto_EmptyMergeOperations(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb", + MergeOperations: []v1.PolicyMergeOperation{}, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Empty(t, req.GetMergeOperations()) +} + +// --- CopyByteSlice helper --- + +func TestCopyByteSlice(t *testing.T) { + original := []byte{0x01, 0x02, 0x03} + copied := CopyByteSlice(original) + + assert.Equal(t, original, copied) + + // Mutate original — copy must not be affected. + original[0] = 0xFF + assert.Equal(t, byte(0x01), copied[0], "copy must be independent of original") +} + +func TestCopyByteSlice_Nil(t *testing.T) { + copied := CopyByteSlice(nil) + assert.Nil(t, copied) +} + +func TestCopyByteSlice_Empty(t *testing.T) { + original := []byte{} + copied := CopyByteSlice(original) + assert.NotNil(t, copied) + assert.Empty(t, copied) +} diff --git a/sdk/go/openshell/v1/internal/converter/ssh.go b/sdk/go/openshell/v1/internal/converter/ssh.go new file mode 100644 index 0000000000..538c1d17f5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/ssh.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// SSHSessionFromProto converts a CreateSshSessionResponse to an SSHSession. +func SSHSessionFromProto(resp *pb.CreateSshSessionResponse) *v1.SSHSession { + if resp == nil { + return nil + } + return &v1.SSHSession{ + SandboxID: resp.GetSandboxId(), + Token: resp.GetToken(), + GatewayHost: resp.GetGatewayHost(), + GatewayPort: resp.GetGatewayPort(), + GatewayScheme: resp.GetGatewayScheme(), + HostKeyFingerprint: resp.GetHostKeyFingerprint(), + ExpiresAtMs: resp.GetExpiresAtMs(), + } +} + +// SSHSessionToProto converts an SSHSession to a CreateSshSessionResponse. +// This is primarily used for round-trip testing and fake implementations. +func SSHSessionToProto(session *v1.SSHSession) *pb.CreateSshSessionResponse { + if session == nil { + return nil + } + return &pb.CreateSshSessionResponse{ + SandboxId: session.SandboxID, + Token: session.Token, + GatewayHost: session.GatewayHost, + GatewayPort: session.GatewayPort, + GatewayScheme: session.GatewayScheme, + HostKeyFingerprint: session.HostKeyFingerprint, + ExpiresAtMs: session.ExpiresAtMs, + } +} diff --git a/sdk/go/openshell/v1/internal/converter/ssh_test.go b/sdk/go/openshell/v1/internal/converter/ssh_test.go new file mode 100644 index 0000000000..11ce395d75 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/ssh_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSHSessionFromProto(t *testing.T) { + resp := &pb.CreateSshSessionResponse{ + SandboxId: "sb-123", + Token: "tok-secret", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + + session := SSHSessionFromProto(resp) + + require.NotNil(t, session) + assert.Equal(t, "sb-123", session.SandboxID) + assert.Equal(t, "tok-secret", session.Token) + assert.Equal(t, "gw.example.com", session.GatewayHost) + assert.Equal(t, uint32(2222), session.GatewayPort) + assert.Equal(t, "https", session.GatewayScheme) + assert.Equal(t, "SHA256:abc123", session.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), session.ExpiresAtMs) +} + +func TestSSHSessionFromProto_MinimalFields(t *testing.T) { + resp := &pb.CreateSshSessionResponse{ + SandboxId: "sb-min", + Token: "tok-min", + GatewayHost: "localhost", + GatewayPort: 22, + } + + session := SSHSessionFromProto(resp) + + require.NotNil(t, session) + assert.Equal(t, "sb-min", session.SandboxID) + assert.Equal(t, "tok-min", session.Token) + assert.Equal(t, "localhost", session.GatewayHost) + assert.Equal(t, uint32(22), session.GatewayPort) + assert.Empty(t, session.GatewayScheme) + assert.Empty(t, session.HostKeyFingerprint) + assert.Zero(t, session.ExpiresAtMs) +} + +func TestSSHSessionFromProto_Nil(t *testing.T) { + session := SSHSessionFromProto(nil) + assert.Nil(t, session) +} + +func TestSSHSessionToProto(t *testing.T) { + session := &v1.SSHSession{ + SandboxID: "sb-123", + Token: "tok-secret", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + + resp := SSHSessionToProto(session) + + require.NotNil(t, resp) + assert.Equal(t, "sb-123", resp.SandboxId) + assert.Equal(t, "tok-secret", resp.Token) + assert.Equal(t, "gw.example.com", resp.GatewayHost) + assert.Equal(t, uint32(2222), resp.GatewayPort) + assert.Equal(t, "https", resp.GatewayScheme) + assert.Equal(t, "SHA256:abc123", resp.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), resp.ExpiresAtMs) +} + +func TestSSHSessionToProto_Nil(t *testing.T) { + resp := SSHSessionToProto(nil) + assert.Nil(t, resp) +} + +func TestSSHSessionRoundTrip(t *testing.T) { + original := &v1.SSHSession{ + SandboxID: "sb-rt", + Token: "tok-rt", + GatewayHost: "rt.example.com", + GatewayPort: 443, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:roundtrip", + ExpiresAtMs: 1800000000000, + } + + proto := SSHSessionToProto(original) + back := SSHSessionFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.SandboxID, back.SandboxID) + assert.Equal(t, original.Token, back.Token) + assert.Equal(t, original.GatewayHost, back.GatewayHost) + assert.Equal(t, original.GatewayPort, back.GatewayPort) + assert.Equal(t, original.GatewayScheme, back.GatewayScheme) + assert.Equal(t, original.HostKeyFingerprint, back.HostKeyFingerprint) + assert.Equal(t, original.ExpiresAtMs, back.ExpiresAtMs) +} diff --git a/sdk/go/openshell/v1/internal/converter/time.go b/sdk/go/openshell/v1/internal/converter/time.go new file mode 100644 index 0000000000..c1bba576aa --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/time.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import "time" + +// TimeFromMillis converts a millisecond epoch timestamp to time.Time. +// A zero value returns the zero time. +func TimeFromMillis(ms int64) time.Time { + if ms == 0 { + return time.Time{} + } + return time.UnixMilli(ms).UTC() +} + +// MillisFromTime converts a time.Time to a millisecond epoch timestamp. +// A zero time returns 0. +func MillisFromTime(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} diff --git a/sdk/go/openshell/v1/internal/converter/time_test.go b/sdk/go/openshell/v1/internal/converter/time_test.go new file mode 100644 index 0000000000..26ecdc83c8 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/time_test.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTimeFromMillis(t *testing.T) { + ms := int64(1719475200000) // 2024-06-27T12:00:00Z + tm := TimeFromMillis(ms) + assert.Equal(t, 2024, tm.Year()) + assert.Equal(t, time.June, tm.Month()) + assert.Equal(t, 27, tm.Day()) +} + +func TestTimeFromMillis_Zero(t *testing.T) { + tm := TimeFromMillis(0) + assert.True(t, tm.IsZero()) +} + +func TestMillisFromTime(t *testing.T) { + tm := time.Date(2024, time.June, 27, 12, 0, 0, 0, time.UTC) + ms := MillisFromTime(tm) + assert.Equal(t, int64(1719489600000), ms) +} + +func TestMillisFromTime_Zero(t *testing.T) { + ms := MillisFromTime(time.Time{}) + assert.Equal(t, int64(0), ms) +} + +func TestRoundTrip(t *testing.T) { + original := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC) + ms := MillisFromTime(original) + restored := TimeFromMillis(ms) + assert.Equal(t, original.Unix(), restored.Unix()) +} diff --git a/sdk/go/openshell/v1/internal/grpc/conn.go b/sdk/go/openshell/v1/internal/grpc/conn.go new file mode 100644 index 0000000000..4e2a1e6c63 --- /dev/null +++ b/sdk/go/openshell/v1/internal/grpc/conn.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package grpc provides gRPC connection setup utilities. +package grpc + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +// TLSParams holds TLS settings without importing the v1 package. +type TLSParams struct { + CertFile string + KeyFile string + CAFile string + Insecure bool +} + +// NewConnection creates a gRPC client connection. +// The address may include an http:// or https:// scheme (as written by the +// upstream gateway), which is stripped since gRPC expects host:port. +func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCredentials) (*grpc.ClientConn, error) { + address = strings.TrimPrefix(address, "https://") + address = strings.TrimPrefix(address, "http://") + opts := []grpc.DialOption{} + + if tlsCfg != nil && tlsCfg.Insecure { + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else if tlsCfg != nil { + creds, err := buildTLSCredentials(tlsCfg) + if err != nil { + return nil, fmt.Errorf("tls config: %w", err) + } + opts = append(opts, grpc.WithTransportCredentials(creds)) + } else { + opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}))) + } + + if auth != nil { + opts = append(opts, grpc.WithPerRPCCredentials(auth)) + } + + conn, err := grpc.NewClient(address, opts...) + if err != nil { + return nil, fmt.Errorf("grpc connect: %w", err) + } + return conn, nil +} + +func buildTLSCredentials(cfg *TLSParams) (credentials.TransportCredentials, error) { + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} + + if cfg.CAFile != "" { + caCert, err := os.ReadFile(cfg.CAFile) + if err != nil { + return nil, fmt.Errorf("read CA file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("invalid CA certificate") + } + tlsConfig.RootCAs = pool + } + + if cfg.CertFile != "" && cfg.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("load client cert: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + return credentials.NewTLS(tlsConfig), nil +} diff --git a/sdk/go/openshell/v1/logger.go b/sdk/go/openshell/v1/logger.go new file mode 100644 index 0000000000..e8274012ae --- /dev/null +++ b/sdk/go/openshell/v1/logger.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Logger defines structured logging for the SDK. Compatible with logr.Logger +// and slog.Logger adapters. +type Logger = types.Logger diff --git a/sdk/go/openshell/v1/oidc/authcode.go b/sdk/go/openshell/v1/oidc/authcode.go new file mode 100644 index 0000000000..d47f57b8a7 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// generateCodeVerifier creates a PKCE code verifier per RFC 7636. +// It generates 32 random bytes and encodes them as base64url without +// padding, producing a 43-character string. +func generateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate code verifier: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// codeChallengeS256 computes the S256 PKCE code challenge for the +// given verifier. It returns BASE64URL(SHA256(verifier)) without +// padding, as specified in RFC 7636 Section 4.2. +func codeChallengeS256(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// generateState creates a cryptographic state parameter for the +// authorization request. It generates 16 random bytes encoded as +// base64url without padding. +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// buildAuthURL constructs the authorization endpoint URL with query +// parameters for the authorization code flow. If challenge is empty, +// PKCE parameters are omitted (for providers that do not support it). +func buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge string, scopes []string) string { + u, err := url.Parse(authEndpoint) + if err != nil || u.Scheme == "" { + u = &url.URL{Path: authEndpoint} + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("state", state) + q.Set("scope", strings.Join(scopes, " ")) + + if challenge != "" { + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String() +} + +// callbackResult carries the authorization code (or error) from the +// callback server to the auth code flow orchestrator. +type callbackResult struct { + code string + err error +} + +// startCallbackServer starts a localhost HTTP server to receive the +// OIDC provider's authorization callback. It listens on the specified +// port (use 0 for OS-assigned port). The server handles a single +// callback request and sends the result on the returned channel. +// +// The caller is responsible for calling srv.Close() when done. +func startCallbackServer(ctx context.Context, port int, expectedState string) (*http.Server, <-chan callbackResult, error) { + resultCh := make(chan callbackResult, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + // Check for provider error response. + if errCode := q.Get("error"); errCode != "" { + desc := q.Get("error_description") + msg := fmt.Sprintf("provider error: %s", errCode) + if desc != "" { + msg += ": " + desc + } + http.Error(w, msg, http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: %s", ErrAuthCode, msg)} + return + } + + // Validate state parameter. + state := q.Get("state") + if state != expectedState { + http.Error(w, "state mismatch", http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: state mismatch", ErrAuthCode)} + return + } + + // Extract authorization code. + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: missing authorization code in callback", ErrAuthCode)} + return + } + + w.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(w, "

Login successful

You can close this window.

") + resultCh <- callbackResult{code: code} + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, nil, fmt.Errorf("%w: failed to start callback server on port %d: %v", ErrCallbackServer, port, err) + } + + srv := &http.Server{ + Addr: listener.Addr().String(), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + go func() { + _ = srv.Serve(listener) + }() + + // Shut down the server when the context is cancelled. + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + return srv, resultCh, nil +} + +// tokenResponse is the JSON structure returned by the token endpoint. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// exchangeCode exchanges an authorization code for tokens at the +// token endpoint. If codeVerifier is empty, the PKCE code_verifier +// parameter is omitted from the request. +// +// Secrets (code, verifier) are never included in error messages. +func exchangeCode(ctx context.Context, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + } + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request: %v", ErrAuthCode, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed: %v", ErrAuthCode, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response: %v", ErrAuthCode, err) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON: %v", ErrAuthCode, err) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + msg := "token exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("token exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrAuthCode, msg) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/authcode_test.go b/sdk/go/openshell/v1/oidc/authcode_test.go new file mode 100644 index 0000000000..dc241a3249 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode_test.go @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T012: PKCE verifier/challenge generation tests --- + +func TestGenerateCodeVerifier_Length(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // RFC 7636 requires 43-128 characters. Our implementation uses 32 + // random bytes -> 43 base64url characters (no padding). + assert.GreaterOrEqual(t, len(verifier), 43) + assert.LessOrEqual(t, len(verifier), 128) +} + +func TestGenerateCodeVerifier_Base64URLSafe(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // Must contain only base64url characters (A-Z, a-z, 0-9, -, _). + // No padding (=) allowed per RFC 7636. + for _, c := range verifier { + valid := (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' + assert.True(t, valid, "invalid character in verifier: %c", c) + } +} + +func TestGenerateCodeVerifier_Unique(t *testing.T) { + v1, err := generateCodeVerifier() + require.NoError(t, err) + + v2, err := generateCodeVerifier() + require.NoError(t, err) + + assert.NotEqual(t, v1, v2, "two verifiers should differ (random)") +} + +func TestCodeChallengeS256(t *testing.T) { + // RFC 7636 Appendix B test vector: + // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge) +} + +func TestCodeChallengeS256_NoPadding(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + challenge := codeChallengeS256(verifier) + + // S256 challenge must be base64url without padding. + assert.NotContains(t, challenge, "=") + assert.NotContains(t, challenge, "+") + assert.NotContains(t, challenge, "/") +} + +// --- T013: Auth code flow tests --- + +func TestGenerateState_Length(t *testing.T) { + state, err := generateState() + require.NoError(t, err) + + // 16 random bytes -> 22 base64url chars (no padding). + assert.GreaterOrEqual(t, len(state), 16) +} + +func TestGenerateState_Unique(t *testing.T) { + s1, err := generateState() + require.NoError(t, err) + + s2, err := generateState() + require.NoError(t, err) + + assert.NotEqual(t, s1, s2) +} + +func TestBuildAuthURL(t *testing.T) { + authEndpoint := "https://auth.example.com/authorize" + clientID := "test-client" + redirectURI := "http://localhost:8000/callback" + state := "random-state" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + scopes := []string{"openid", "profile"} + + authURL := buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge, scopes) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Equal(t, clientID, q.Get("client_id")) + assert.Equal(t, redirectURI, q.Get("redirect_uri")) + assert.Equal(t, state, q.Get("state")) + assert.Equal(t, challenge, q.Get("code_challenge")) + assert.Equal(t, "S256", q.Get("code_challenge_method")) + assert.Equal(t, "openid profile", q.Get("scope")) +} + +func TestBuildAuthURL_NoPKCE(t *testing.T) { + authURL := buildAuthURL( + "https://auth.example.com/authorize", + "test-client", + "http://localhost:8000/callback", + "state", + "", // empty challenge = no PKCE + []string{"openid"}, + ) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Empty(t, q.Get("code_challenge"), "no PKCE when challenge is empty") + assert.Empty(t, q.Get("code_challenge_method")) +} + +func TestStartCallbackServer_ReceivesCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state-123" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + // Extract the port from the server's listener address. + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=auth-code-xyz&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + result := <-resultCh + require.NoError(t, result.err) + assert.Equal(t, "auth-code-xyz", result.code) +} + +func TestStartCallbackServer_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "expected-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=some-code&state=wrong-state", addr, ) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "state") +} + +func TestStartCallbackServer_MissingCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) +} + +func TestStartCallbackServer_ProviderError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?error=access_denied&error_description=user+denied&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "access_denied") +} + +func TestStartCallbackServer_SpecificPort(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Port 0 tells OS to pick a free port. We just verify it works. + srv, _, err := startCallbackServer(ctx, 0, "state") + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + assert.NotEmpty(t, srv.Addr) +} + +func TestExchangeCode_Success(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, "test-code", r.Form.Get("code")) + assert.Equal(t, "test-client", r.Form.Get("client_id")) + assert.Equal(t, "http://localhost:8000/callback", r.Form.Get("redirect_uri")) + assert.NotEmpty(t, r.Form.Get("code_verifier"), "PKCE verifier should be sent") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-123", + "refresh_token": "rt-456", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "pkce-verifier", + ) + require.NoError(t, err) + assert.Equal(t, "at-123", tok.AccessToken) + assert.Equal(t, "rt-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestExchangeCode_NoPKCE(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Empty(t, r.Form.Get("code_verifier"), "no PKCE verifier when empty") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-no-pkce", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "", // empty verifier = no PKCE + ) + require.NoError(t, err) + assert.Equal(t, "at-no-pkce", tok.AccessToken) +} + +func TestExchangeCode_ErrorResponse(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant", "error_description": "code expired"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "bad-code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestExchangeCode_SecretsNotInError(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "secret-code-value", + "http://localhost/callback", + "secret-verifier-value", + ) + require.Error(t, err) + // The error message must not contain the auth code or verifier. + assert.NotContains(t, err.Error(), "secret-code-value") + assert.NotContains(t, err.Error(), "secret-verifier-value") +} + +func TestExchangeCode_InvalidJSON(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not json`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +// tokenResponseJSON is a helper for creating token endpoint responses. +func tokenResponseJSON(accessToken, refreshToken string, expiresIn int) string { + return fmt.Sprintf(`{ + "access_token": %q, + "refresh_token": %q, + "token_type": "Bearer", + "expires_in": %d + }`, accessToken, refreshToken, expiresIn) +} + diff --git a/sdk/go/openshell/v1/oidc/browser.go b/sdk/go/openshell/v1/oidc/browser.go new file mode 100644 index 0000000000..fd55c2ef36 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "fmt" + "os/exec" + "runtime" +) + +// browserCommand returns the platform-specific command name and +// arguments for opening a URL in the user's default browser. +func browserCommand(url string) (string, []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{url} + case "linux": + return "xdg-open", []string{url} + case "windows": + return "cmd", []string{"/c", "start", "", url} + default: + // Fallback: try xdg-open (common on Unix-like systems). + return "xdg-open", []string{url} + } +} + +// openBrowser attempts to open the given URL in the user's default +// browser using the platform-appropriate command. Returns an error if +// the browser could not be launched. +func openBrowser(url string) error { + name, args := browserCommand(url) + return openBrowserWith(name, args...) +} + +// openBrowserWith runs the given command with the provided arguments. +// This is separated from openBrowser to allow testing with arbitrary +// command names. +func openBrowserWith(name string, args ...string) error { + cmd := exec.Command(name, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open browser with %s: %w", name, err) + } + // We don't wait for the browser process to exit. It runs + // independently, and we only care that it launched. + return nil +} diff --git a/sdk/go/openshell/v1/oidc/browser_test.go b/sdk/go/openshell/v1/oidc/browser_test.go new file mode 100644 index 0000000000..49035238d9 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser_test.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "runtime" + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +// T015: Browser opener tests + +func TestBrowserCommand_Platform(t *testing.T) { + name, args := browserCommand("https://example.com/auth") + + switch runtime.GOOS { + case "darwin": + assert.Equal(t, "open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "linux": + assert.Equal(t, "xdg-open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "windows": + assert.Equal(t, "cmd", name) + assert.Contains(t, args, "/c") + assert.Contains(t, args, "start") + default: + // Unknown platform should still return something (even if it fails). + assert.NotEmpty(t, name) + } +} + +func TestBrowserCommand_URLPassedAsArg(t *testing.T) { + testURL := "https://auth.example.com/authorize?client_id=test&state=abc" + _, args := browserCommand(testURL) + + assert.True(t, slices.Contains(args, testURL), "URL should be passed as an argument to the browser command") +} + +func TestOpenBrowser_InvalidCommand(t *testing.T) { + // Attempting to open a browser with a non-existent command should + // return an error rather than panic. + err := openBrowserWith("nonexistent-browser-cmd-that-does-not-exist", "https://example.com") + assert.Error(t, err, "should fail when the browser command does not exist") +} diff --git a/sdk/go/openshell/v1/oidc/credentials.go b/sdk/go/openshell/v1/oidc/credentials.go new file mode 100644 index 0000000000..d49f19cbeb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// ClientCredentials performs a non-interactive OAuth2 client credentials +// grant (RFC 6749 Section 4.4). It requires [WithIssuer], [WithClientID], +// and [WithClientSecret] (or [WithGateway] combined with [WithClientSecret]). +// +// This flow is intended for service accounts and machine-to-machine +// authentication. No user interaction occurs. The returned token +// typically contains only an access token (no refresh token). +// +// The client secret is never included in error messages (FR-014). +func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Client credentials should not send interactive scopes by default. + // Only send scopes if the caller explicitly set them via WithScopes. + if !cfg.scopesSet { + cfg.scopes = nil + } + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + 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, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + if cfg.clientSecret == "" { + return nil, fmt.Errorf( + "%w: client secret is required (use WithClientSecret)", + ErrClientCredentials, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Build the token request with client credentials grant type. + data := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {cfg.clientID}, + "client_secret": {cfg.clientSecret}, + } + if len(cfg.scopes) > 0 { + data.Set("scope", strings.Join(cfg.scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request", ErrClientCredentials) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed", ErrClientCredentials) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response", ErrClientCredentials) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); 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) + } + + if tokResp.AccessToken == "" { + return nil, fmt.Errorf("%w: token response missing access_token", ErrClientCredentials) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/credentials_test.go b/sdk/go/openshell/v1/oidc/credentials_test.go new file mode 100644 index 0000000000..f87c6b31fb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials_test.go @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T023: Client credentials tests --- + +// setupCredentialsMockProvider creates a mock OIDC provider for client +// credentials testing. The token endpoint validates Basic Auth and +// returns a token response. Returns the server and the expected +// client ID / client secret pair. +func setupCredentialsMockProvider(t *testing.T, expectedClientID, expectedSecret string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Validate grant type. + if r.Form.Get("grant_type") != "client_credentials" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type","error_description":"expected client_credentials"}`)) + return + } + + // Check credentials from form body (client_id + client_secret) + // or Basic Auth header. + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + if clientID == "" || clientSecret == "" { + // Try Basic Auth. + var ok bool + clientID, clientSecret, ok = r.BasicAuth() + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"missing credentials"}`)) + return + } + } + + if clientID != expectedClientID || clientSecret != expectedSecret { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"invalid credentials"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("cc-access-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestClientCredentials_Success verifies the happy path: valid client +// ID, secret, and issuer produce a valid access token. +func TestClientCredentials_Success(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) + assert.Empty(t, tok.RefreshToken, "client credentials should not return a refresh token") +} + +// TestClientCredentials_MissingIssuer verifies that ClientCredentials +// returns ErrOIDCConfig when the issuer is not set. +func TestClientCredentials_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientID verifies that ClientCredentials +// returns ErrOIDCConfig when the client ID is not set. +func TestClientCredentials_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientSecret verifies that +// ClientCredentials returns ErrClientCredentials when the secret is +// missing. +func TestClientCredentials_MissingClientSecret(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientID("my-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) +} + +// TestClientCredentials_InvalidCredentials verifies that +// ClientCredentials returns ErrClientCredentials when the provider +// rejects the credentials, and that the secret is not leaked in the +// error message. +func TestClientCredentials_InvalidCredentials(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "good-client", "good-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("good-client"), + WithClientSecret("wrong-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) + + // FR-014: The secret must NEVER appear in error messages. + assert.NotContains(t, err.Error(), "wrong-secret", "secret must not leak in error message") + assert.NotContains(t, err.Error(), "good-secret", "secret must not leak in error message") +} + +// TestClientCredentials_DiscoveryFailure verifies that +// ClientCredentials returns ErrDiscovery when the provider is +// unreachable. +func TestClientCredentials_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestClientCredentials_WithGateway verifies that ClientCredentials +// resolves OIDC config from gateway metadata when WithGateway is set. +func TestClientCredentials_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "gw-client", "gw-secret") + + fakeConfig := &gateway.Config{ + Name: "cc-gateway", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithGateway("cc-gateway"), + WithClientSecret("gw-secret"), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "cc-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) +} + +// TestClientCredentials_CustomScopes verifies that WithScopes overrides +// default scopes in the client credentials request. +func TestClientCredentials_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + var receivedScope string + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedScope = r.Form.Get("scope") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-cc-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(srv.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + WithScopes("api:read", "api:write"), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-cc-token", tok.AccessToken) + assert.Equal(t, "api:read api:write", receivedScope) +} + +// TestClientCredentials_ContextCancellation verifies that +// ClientCredentials respects context cancellation. +func TestClientCredentials_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/device.go b/sdk/go/openshell/v1/oidc/device.go new file mode 100644 index 0000000000..c6701ff80e --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device.go @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// deviceAuthResponse holds the parsed response from the device +// authorization endpoint (RFC 8628 Section 3.2). +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// DeviceLogin performs an OAuth2 device authorization grant (RFC 8628). +// +// The flow requests a device code and user code from the provider's +// device authorization endpoint, displays them to the user (via +// [WithDisplayFunc] or stdout), and polls the token endpoint until the +// user completes authorization. +// +// Required options: [WithIssuer] and [WithClientID], or [WithGateway]. +// +// The polling loop respects the provider's interval and handles the +// following token endpoint error codes: +// - "authorization_pending": continue polling at the current interval +// - "slow_down": increase the polling interval by 5 seconds (RFC 8628 Section 3.5) +// - "expired_token": the device code has expired, return [ErrDeviceCode] +// - any other error: return [ErrDeviceCode] +func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + 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, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Verify the provider supports device authorization. + if provider.DeviceAuthorizationEndpoint == "" { + return nil, fmt.Errorf( + "%w: provider does not support device authorization (no device_authorization_endpoint in discovery)", + ErrDeviceCode, + ) + } + + // Request a device code from the provider. + deviceResp, err := requestDeviceCode(ctx, provider.DeviceAuthorizationEndpoint, cfg.clientID, cfg.scopes) + if err != nil { + return nil, err + } + + // Display the verification URL and user code to the user. + if cfg.displayFunc != nil { + cfg.displayFunc(deviceResp.VerificationURI, deviceResp.UserCode) + } else { + fmt.Printf("To sign in, visit: %s\n", deviceResp.VerificationURI) + fmt.Printf("Enter code: %s\n", deviceResp.UserCode) + } + + // Enforce device code lifetime from the provider's expires_in field. + // If the caller's context already has a shorter deadline, that takes + // precedence. This prevents indefinite polling against non-compliant + // providers that never return expired_token. + if deviceResp.ExpiresIn > 0 { + expiry := time.Duration(deviceResp.ExpiresIn) * time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, expiry) + defer cancel() + } + + // Poll the token endpoint until authorization completes, expires, + // or the context is cancelled. + interval := deviceResp.Interval + if interval < 1 { + interval = 5 // default polling interval per RFC 8628 + } + + return pollDeviceToken(ctx, provider.TokenEndpoint, cfg.clientID, deviceResp.DeviceCode, interval) +} + +// requestDeviceCode sends a POST to the device authorization endpoint +// and returns the parsed response containing the device code, user +// code, and verification URI. +func requestDeviceCode(ctx context.Context, endpoint, clientID string, scopes []string) (*deviceAuthResponse, error) { + data := url.Values{ + "client_id": {clientID}, + } + if len(scopes) > 0 { + data.Set("scope", strings.Join(scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create device authorization request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: device authorization request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read device authorization response", ErrDeviceCode) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: device authorization endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + var deviceResp deviceAuthResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("%w: invalid device authorization response JSON", ErrDeviceCode) + } + + if deviceResp.DeviceCode == "" || deviceResp.UserCode == "" { + return nil, fmt.Errorf("%w: device authorization response missing device_code or user_code", ErrDeviceCode) + } + + return &deviceResp, nil +} + +// pollDeviceToken polls the token endpoint at the given interval until +// the user completes authorization. It handles RFC 8628 error codes: +// - "authorization_pending": keep polling +// - "slow_down": increase interval by 5 seconds +// - "expired_token": return ErrDeviceCode +func pollDeviceToken(ctx context.Context, tokenEndpoint, clientID, deviceCode string, interval int64) (*oauth2.Token, error) { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case <-ticker.C: + tok, done, slowDown, err := tryDeviceTokenExchange(ctx, tokenEndpoint, clientID, deviceCode) + if done { + if err != nil { + return nil, err + } + return tok, nil + } + // Adjust interval if the provider requested slow_down + // (+5 seconds per RFC 8628 Section 3.5). + if slowDown < 0 { + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + } + } + } +} + +// tryDeviceTokenExchange makes a single token request for the device +// code grant. Returns: +// - (token, true, 0, nil): success +// - (nil, true, 0, err): terminal error (expired, access_denied, etc.) +// - (nil, false, interval, nil): continue polling (authorization_pending or slow_down) +func tryDeviceTokenExchange(ctx context.Context, tokenEndpoint, clientID, deviceCode string) (*oauth2.Token, bool, int64, error) { + data := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {clientID}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to create token request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + // If the context was cancelled or timed out, surface that as + // ErrTimeout so callers can distinguish "user/caller cancelled" + // from a genuine device-code error. + if ctx.Err() != nil { + return nil, true, 0, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + } + // Other network errors during polling are terminal. + return nil, true, 0, fmt.Errorf("%w: token request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to read token response", ErrDeviceCode) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, true, 0, fmt.Errorf("%w: invalid token response JSON", ErrDeviceCode) + } + + // Handle error responses per RFC 8628 Section 3.5. + if tokResp.Error != "" { + switch tokResp.Error { + case "authorization_pending": + // User has not yet completed authorization. Keep polling. + return nil, false, 0, nil + case "slow_down": + // Provider requests increased interval (+5 seconds per RFC 8628). + // Return a sentinel value; the caller adds 5 to current interval. + return nil, false, -1, nil + case "expired_token": + return nil, true, 0, fmt.Errorf("%w: device code expired", ErrDeviceCode) + case "access_denied": + return nil, true, 0, fmt.Errorf("%w: access denied by user", ErrDeviceCode) + default: + msg := fmt.Sprintf("device code exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + return nil, true, 0, fmt.Errorf("%w: %s", ErrDeviceCode, msg) + } + } + + // Success: parse the token. + if resp.StatusCode != http.StatusOK { + return nil, true, 0, fmt.Errorf("%w: token endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, true, 0, nil +} diff --git a/sdk/go/openshell/v1/oidc/device_test.go b/sdk/go/openshell/v1/oidc/device_test.go new file mode 100644 index 0000000000..43b2bae82b --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device_test.go @@ -0,0 +1,682 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T025: Device code flow tests --- + +// setupDeviceMockProvider creates a mock OIDC provider for device code +// flow testing. The device authorization endpoint returns a device code +// and verification URL. The token endpoint simulates polling behavior: +// it returns "authorization_pending" for the first N polls, then returns +// a valid token response. +func setupDeviceMockProvider(t *testing.T, pendingPolls int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=ABCD-1234", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Only handle device code grants here. + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type"}`)) + return + } + + count := pollCount.Add(1) + if int(count) <= pendingPolls { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("device-access-token", "device-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestDeviceLogin_Success verifies the happy path: the device code flow +// requests a device code, displays it, polls until authorized, and +// returns a valid token. +func TestDeviceLogin_Success(t *testing.T) { + resetDiscoveryCache() + + // Provider returns "authorization_pending" for the first 2 polls, + // then returns a token on the 3rd poll. + provider := setupDeviceMockProvider(t, 2) + + var displayedURL, displayedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + displayedURL = verificationURL + displayedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.Equal(t, "device-refresh-token", tok.RefreshToken) + + // Verify the display callback was invoked with correct values. + assert.Equal(t, "https://example.com/activate", displayedURL) + assert.Equal(t, "ABCD-1234", displayedCode) +} + +// TestDeviceLogin_MissingIssuer verifies that DeviceLogin returns +// ErrOIDCConfig when the issuer is not provided. +func TestDeviceLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_MissingClientID verifies that DeviceLogin returns +// ErrOIDCConfig when the client ID is not provided. +func TestDeviceLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithIssuer("https://example.com"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_DiscoveryFailure verifies that DeviceLogin returns +// ErrDiscovery when the OIDC provider is unreachable. +func TestDeviceLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestDeviceLogin_SlowDown verifies that the polling loop respects the +// "slow_down" response by increasing the polling interval. +func TestDeviceLogin_SlowDown(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "slow-device-code", + "user_code": "SLOW-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + count := pollCount.Add(1) + w.Header().Set("Content-Type", "application/json") + + switch count { + case 1: + // First poll: slow_down + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"slow_down"}`)) + case 2: + // Second poll: still pending + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + default: + // Third poll: success + _, _ = w.Write([]byte(tokenResponseJSON("slow-token", "", 3600))) + } + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.NoError(t, err) + assert.Equal(t, "slow-token", tok.AccessToken) +} + +// TestDeviceLogin_ExpiredDeviceCode verifies that DeviceLogin returns +// ErrDeviceCode when the device code expires before authorization. +func TestDeviceLogin_ExpiredDeviceCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "expiring-device-code", + "user_code": "EXPR-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"expired_token","error_description":"device code expired"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_CustomDisplayFunc verifies that WithDisplayFunc is +// invoked with the verification URL and user code. +func TestDeviceLogin_CustomDisplayFunc(t *testing.T) { + resetDiscoveryCache() + + // Provider that immediately returns a token (0 pending polls). + provider := setupDeviceMockProvider(t, 0) + + var called bool + var capturedURL, capturedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + called = true + capturedURL = verificationURL + capturedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.True(t, called, "display function should have been called") + assert.Equal(t, "https://example.com/activate", capturedURL) + assert.Equal(t, "ABCD-1234", capturedCode) +} + +// TestDeviceLogin_WithGateway verifies that DeviceLogin resolves OIDC +// config from gateway metadata when WithGateway is set. +func TestDeviceLogin_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupDeviceMockProvider(t, 0) + + fakeConfig := &gateway.Config{ + Name: "device-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-device-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithGateway("device-gw"), + WithDisplayFunc(func(_, _ string) {}), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "device-gw", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) +} + +// TestDeviceLogin_ContextCancellation verifies that DeviceLogin +// respects context cancellation during polling. +func TestDeviceLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + // Provider that always returns "authorization_pending" so + // the polling loop never succeeds on its own. + provider := setupDeviceMockProvider(t, 1000) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + // Should be ErrTimeout or context.DeadlineExceeded wrapped. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, context.DeadlineExceeded), + "expected timeout error, got: %v", err, + ) +} + +// TestDeviceLogin_AccessDenied verifies that DeviceLogin returns +// ErrDeviceCode when the user denies authorization. +func TestDeviceLogin_AccessDenied(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "denied-device-code", + "user_code": "DENY-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"user denied"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "access denied") +} + +// TestDeviceLogin_UnknownError verifies that DeviceLogin returns +// ErrDeviceCode with the error description for unknown error codes. +func TestDeviceLogin_UnknownError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "unknown-err-code", + "user_code": "UNKN-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"server_error","error_description":"internal failure"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "server_error") + assert.Contains(t, err.Error(), "internal failure") +} + +// TestDeviceLogin_DeviceEndpointHTTPError verifies that DeviceLogin +// returns ErrDeviceCode when the device authorization endpoint returns +// a non-200 HTTP status. +func TestDeviceLogin_DeviceEndpointHTTPError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_DeviceEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the device endpoint returns invalid JSON. +func TestDeviceLogin_DeviceEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_MissingUserCode verifies that DeviceLogin returns +// ErrDeviceCode when the device endpoint returns an empty user_code. +func TestDeviceLogin_MissingUserCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "code-but-no-user", + "user_code": "", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_TokenEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the token endpoint returns invalid JSON +// during polling. +func TestDeviceLogin_TokenEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "json-err-code", + "user_code": "JSON-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{invalid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_NoDeviceEndpoint verifies that DeviceLogin returns +// ErrDeviceCode when the provider does not advertise a device +// authorization endpoint. +func TestDeviceLogin_NoDeviceEndpoint(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + // No device_authorization_endpoint. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} diff --git a/sdk/go/openshell/v1/oidc/discovery.go b/sdk/go/openshell/v1/oidc/discovery.go new file mode 100644 index 0000000000..14ad5f47e4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +// providerConfig holds parsed fields from an OIDC discovery document +// (.well-known/openid-configuration). +type providerConfig struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// discoveryCache stores successfully fetched provider configurations +// keyed by normalized issuer URL. Only successful results are cached; +// errors are not cached so that transient failures (network issues, +// context cancellation) do not permanently poison the cache. +var ( + discoveryCacheMu sync.Mutex + discoveryCache = make(map[string]*providerConfig) +) + +// resetDiscoveryCache clears the in-memory discovery cache. This is +// only used by tests to avoid interference between test cases. +func resetDiscoveryCache() { + discoveryCacheMu.Lock() + defer discoveryCacheMu.Unlock() + discoveryCache = make(map[string]*providerConfig) +} + +// normalizeIssuer strips a trailing slash from the issuer URL so that +// "https://auth.example.com" and "https://auth.example.com/" resolve +// to the same cache key. +func normalizeIssuer(issuer string) string { + return strings.TrimRight(issuer, "/") +} + +// discover fetches and caches the OIDC discovery document for the +// given issuer URL. Only successful results are cached; failed +// fetches are retried on the next call. +func discover(ctx context.Context, issuer string) (*providerConfig, error) { + key := normalizeIssuer(issuer) + + discoveryCacheMu.Lock() + if cached, ok := discoveryCache[key]; ok { + discoveryCacheMu.Unlock() + return cached, nil + } + discoveryCacheMu.Unlock() + + cfg, err := fetchDiscovery(ctx, key) + if err != nil { + return nil, err + } + + discoveryCacheMu.Lock() + // Check again in case another goroutine cached it while we fetched. + if existing, ok := discoveryCache[key]; ok { + discoveryCacheMu.Unlock() + return existing, nil + } + discoveryCache[key] = cfg + discoveryCacheMu.Unlock() + + return cfg, nil +} + +// fetchDiscovery performs the actual HTTP GET to the OIDC discovery +// endpoint and parses the response. +func fetchDiscovery(ctx context.Context, issuer string) (*providerConfig, error) { + url := issuer + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: discovery endpoint returned HTTP %d", ErrDiscovery, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read discovery response: %v", ErrDiscovery, err) + } + + var cfg providerConfig + if err := json.Unmarshal(body, &cfg); err != nil { + return nil, fmt.Errorf("%w: invalid discovery JSON: %v", ErrDiscovery, err) + } + + if cfg.TokenEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing token_endpoint", ErrDiscovery) + } + if cfg.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing authorization_endpoint", ErrDiscovery) + } + + return &cfg, nil +} diff --git a/sdk/go/openshell/v1/oidc/discovery_test.go b/sdk/go/openshell/v1/oidc/discovery_test.go new file mode 100644 index 0000000000..14af9ed6aa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery_test.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wellKnownJSON returns a valid OIDC discovery document JSON string +// with the given issuer URL as the base. +func wellKnownJSON(issuer string) string { + return `{ + "issuer": "` + issuer + `", + "authorization_endpoint": "` + issuer + `/authorize", + "token_endpoint": "` + issuer + `/token", + "device_authorization_endpoint": "` + issuer + `/device", + "scopes_supported": ["openid", "profile", "email"], + "code_challenge_methods_supported": ["S256"] + }` +} + +func TestDiscover_ValidDocument(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + // Clear cache to avoid interference from other tests. + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + assert.Equal(t, srv.URL, cfg.Issuer) + assert.Equal(t, srv.URL+"/authorize", cfg.AuthorizationEndpoint) + assert.Equal(t, srv.URL+"/token", cfg.TokenEndpoint) + assert.Equal(t, srv.URL+"/device", cfg.DeviceAuthorizationEndpoint) + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.ScopesSupported) + assert.Equal(t, []string{"S256"}, cfg.CodeChallengeMethodsSupported) +} + +func TestDiscover_CachesResult(t *testing.T) { + callCount := 0 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + // Same pointer should be returned from cache. + assert.Same(t, cfg1, cfg2) + assert.Equal(t, 1, callCount, "discovery should be fetched only once") +} + +func TestDiscover_DifferentIssuersNotCached(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + }) + + srv1 := httptest.NewServer(handler) + defer srv1.Close() + srv2 := httptest.NewServer(handler) + defer srv2.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv1.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv2.URL) + require.NoError(t, err) + + // Different issuers should yield different cached entries. + assert.NotSame(t, cfg1, cfg2) +} + +func TestDiscover_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json}`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_MissingTokenEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) + assert.Contains(t, err.Error(), "token_endpoint") +} + +func TestDiscover_ContextCancelled(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + _, err := discover(ctx, srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_ConcurrentAccess(t *testing.T) { + callCount := 0 + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + callCount++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON("http://example.com"))) + })) + defer srv.Close() + + resetDiscoveryCache() + + var wg sync.WaitGroup + results := make([]*providerConfig, 10) + errs := make([]error, 10) + + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = discover(context.Background(), srv.URL) + }(i) + } + wg.Wait() + + for i := range 10 { + require.NoError(t, errs[i]) + assert.NotNil(t, results[i]) + } + + // Without sync.Once, concurrent goroutines may each fetch before + // the first result is cached. All calls should succeed, and the + // server should be called at most once per concurrent racer (not + // 10 times if caching works at all). In practice, most calls + // should hit the cache after the first fetch completes. + mu.Lock() + defer mu.Unlock() + assert.LessOrEqual(t, callCount, 10, "caching should reduce total fetches") +} + +func TestDiscover_NoDeviceEndpointIsOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + assert.Empty(t, cfg.DeviceAuthorizationEndpoint) +} + +func TestDiscover_TrailingSlashNormalized(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + // Call with trailing slash and without; should be same cache entry. + _, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + _, err = discover(context.Background(), srv.URL+"/") + require.NoError(t, err) + + assert.Equal(t, 1, callCount, "trailing slash should be normalized for caching") +} diff --git a/sdk/go/openshell/v1/oidc/doc.go b/sdk/go/openshell/v1/oidc/doc.go new file mode 100644 index 0000000000..2575a09127 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/doc.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package oidc provides OIDC authentication flows for the OpenShell SDK. +// +// The package supports four authentication flows: +// +// - Authorization Code with PKCE (interactive browser-based login) +// - Keyboard flow (manual URL copy and code paste for headless environments) +// - Device Code flow (RFC 8628, for input-constrained devices) +// - Client Credentials grant (non-interactive service account authentication) +// +// # Gateway-Aware Login +// +// The primary use case is gateway-aware login, where OIDC provider +// configuration is read from a gateway's metadata.json file: +// +// token, err := oidc.Login(ctx, "my-gateway") +// if err != nil { +// log.Fatal(err) +// } +// +// After successful authentication, tokens are persisted to disk in the +// gateway directory as oidc_token.json, compatible with +// [gateway.NewClient] and the existing [gateway.diskTokenSource]. +// +// # Standalone Login +// +// For OIDC providers not tied to an OpenShell gateway, use explicit +// configuration: +// +// token, err := oidc.Login(ctx, "", +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// oidc.WithInMemory(), +// ) +// +// # Device Code Flow +// +// For environments without a browser: +// +// token, err := oidc.DeviceLogin(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// ) +// +// # Client Credentials +// +// For non-interactive service accounts: +// +// token, err := oidc.ClientCredentials(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-service"), +// oidc.WithClientSecret("secret"), +// ) +// +// # Error Handling +// +// The package provides typed sentinel errors for precise failure +// classification: +// +// - [ErrDiscovery]: OIDC discovery fetch or parse failed +// - [ErrAuthCode]: Authorization code exchange failed +// - [ErrDeviceCode]: Device code flow failed +// - [ErrClientCredentials]: Client credentials exchange failed +// - [ErrTimeout]: Interactive flow timed out +// - [ErrCallbackServer]: Localhost callback server failed to start +// - [ErrTokenPersist]: Token disk write failed +// - [ErrOIDCConfig]: Gateway metadata missing OIDC fields +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. OIDC discovery documents are cached in memory per issuer +// URL for the lifetime of the process. +package oidc diff --git a/sdk/go/openshell/v1/oidc/errors.go b/sdk/go/openshell/v1/oidc/errors.go new file mode 100644 index 0000000000..20c9105c03 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import "errors" + +// Sentinel errors for OIDC authentication failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrDiscovery is returned when the OIDC discovery document + // (.well-known/openid-configuration) cannot be fetched or parsed. + ErrDiscovery = errors.New("oidc: discovery failed") + + // ErrAuthCode is returned when the authorization code exchange + // fails (invalid code, expired code, provider error). + ErrAuthCode = errors.New("oidc: auth code exchange failed") + + // ErrDeviceCode is returned when the device code flow fails + // (request error, expired device code, provider error). + ErrDeviceCode = errors.New("oidc: device code flow failed") + + // ErrClientCredentials is returned when the client credentials + // grant fails (invalid credentials, provider error). The error + // message never contains the client secret. + ErrClientCredentials = errors.New("oidc: client credentials exchange failed") + + // ErrTimeout is returned when an interactive login flow + // (browser, keyboard, or device code) exceeds its deadline. + ErrTimeout = errors.New("oidc: login timed out") + + // ErrCallbackServer is returned when the localhost HTTP server + // for the authorization code redirect cannot bind to any port. + ErrCallbackServer = errors.New("oidc: callback server failed") + + // ErrTokenPersist is returned when the token cannot be written + // to disk (permission error, invalid path). + ErrTokenPersist = errors.New("oidc: token persistence failed") + + // ErrOIDCConfig is returned when gateway metadata is missing + // the required oidc_issuer or oidc_client_id fields. + ErrOIDCConfig = errors.New("oidc: gateway OIDC config missing") +) diff --git a/sdk/go/openshell/v1/oidc/errors_test.go b/sdk/go/openshell/v1/oidc/errors_test.go new file mode 100644 index 0000000000..ea9dfb5a10 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors_test.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSentinelErrors_AreDistinct(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for i, a := range sentinels { + for j, b := range sentinels { + if i == j { + continue + } + assert.False(t, errors.Is(a, b), + "expected %v and %v to be distinct", a, b) + } + } +} + +func TestSentinelErrors_MatchSelf(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for _, sentinel := range sentinels { + assert.True(t, errors.Is(sentinel, sentinel), + "expected %v to match itself", sentinel) + } +} + +func TestSentinelErrors_WrappedMatchViIs(t *testing.T) { + cases := []struct { + name string + sentinel error + }{ + {"ErrDiscovery", ErrDiscovery}, + {"ErrAuthCode", ErrAuthCode}, + {"ErrDeviceCode", ErrDeviceCode}, + {"ErrClientCredentials", ErrClientCredentials}, + {"ErrTimeout", ErrTimeout}, + {"ErrCallbackServer", ErrCallbackServer}, + {"ErrTokenPersist", ErrTokenPersist}, + {"ErrOIDCConfig", ErrOIDCConfig}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := fmt.Errorf("operation failed: %w", tc.sentinel) + assert.True(t, errors.Is(wrapped, tc.sentinel), + "wrapped error should match sentinel via errors.Is") + }) + } +} + +func TestSentinelErrors_HaveDescriptiveMessages(t *testing.T) { + cases := []struct { + sentinel error + contains string + }{ + {ErrDiscovery, "discovery"}, + {ErrAuthCode, "auth code"}, + {ErrDeviceCode, "device code"}, + {ErrClientCredentials, "client credentials"}, + {ErrTimeout, "timed out"}, + {ErrCallbackServer, "callback server"}, + {ErrTokenPersist, "token persistence"}, + {ErrOIDCConfig, "OIDC config"}, + } + + for _, tc := range cases { + t.Run(tc.sentinel.Error(), func(t *testing.T) { + assert.Contains(t, tc.sentinel.Error(), tc.contains) + }) + } +} + +func TestSentinelErrors_DoubleWrapped(t *testing.T) { + inner := fmt.Errorf("http timeout: %w", ErrDiscovery) + outer := fmt.Errorf("login failed: %w", inner) + + assert.True(t, errors.Is(outer, ErrDiscovery), + "double-wrapped error should still match sentinel") +} diff --git a/sdk/go/openshell/v1/oidc/example_test.go b/sdk/go/openshell/v1/oidc/example_test.go new file mode 100644 index 0000000000..c304ef0bfa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/example_test.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// These examples demonstrate OIDC package usage but are guarded from +// execution during `go test` because they require real network access +// and user interaction. The guard `if false` keeps the code type-checked +// by the compiler without executing during tests. + +package oidc_test + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" +) + +func ExampleLogin_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_standalone() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "", + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-app"), + oidc.WithInMemory(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Access token: %s...\n", token.AccessToken[:10]) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_keyboard() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway", + oidc.WithKeyboardFlow(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated via keyboard flow. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-device-app"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Device authorized. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin_customDisplay() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-tui-app"), + oidc.WithDisplayFunc(func(verificationURL, userCode string) { + fmt.Printf("Please visit: %s\n", verificationURL) + fmt.Printf("Enter code: %s\n", userCode) + }), + ) + if err != nil { + log.Fatal(err) + } + + _ = token + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-service"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithGateway("my-gateway"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated via gateway. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} diff --git a/sdk/go/openshell/v1/oidc/keyboard.go b/sdk/go/openshell/v1/oidc/keyboard.go new file mode 100644 index 0000000000..d7367b8186 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +// keyboardFlow implements the keyboard fallback for the authorization +// code flow. It displays the authorization URL to the user and reads +// the pasted authorization code from the provided reader. +// +// Parameters: +// - ctx: context for cancellation/timeout +// - authURL: the full authorization URL to display +// - input: reader for user input (typically os.Stdin) +// - output: writer for prompts/instructions (typically os.Stderr) +// +// Returns the authorization code or an error. +func keyboardFlow(ctx context.Context, authURL string, input io.Reader, output io.Writer) (string, error) { + // Display instructions and URL. + _, _ = fmt.Fprintf(output, "\nOpen the following URL in your browser to authenticate:\n\n %s\n\n", authURL) + _, _ = fmt.Fprint(output, "Paste the authorization code here and press Enter: ") + + // Read code with context cancellation support. + type readResult struct { + code string + err error + } + ch := make(chan readResult, 1) + + go func() { + scanner := bufio.NewScanner(input) + if scanner.Scan() { + ch <- readResult{code: strings.TrimSpace(scanner.Text())} + } else { + err := scanner.Err() + if err == nil { + // EOF without reading a line. + ch <- readResult{err: fmt.Errorf("%w: no authorization code received (EOF)", ErrAuthCode)} + } else { + ch <- readResult{err: fmt.Errorf("%w: failed to read authorization code: %v", ErrAuthCode, err)} + } + } + }() + + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-ch: + if result.err != nil { + return "", result.err + } + if result.code == "" { + return "", fmt.Errorf("%w: empty authorization code", ErrAuthCode) + } + return result.code, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/keyboard_test.go b/sdk/go/openshell/v1/oidc/keyboard_test.go new file mode 100644 index 0000000000..fa0c4ab17f --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard_test.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// T014: Keyboard fallback flow tests + +func TestKeyboardFlow_ReadsCode(t *testing.T) { + // Simulate user pasting a code via stdin. + input := strings.NewReader("my-auth-code\n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?client_id=test", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "my-auth-code", code) + + // Verify that the URL was displayed to the user. + assert.Contains(t, output.String(), "https://auth.example.com/authorize?client_id=test") +} + +func TestKeyboardFlow_TrimsWhitespace(t *testing.T) { + input := strings.NewReader(" some-code-with-spaces \n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "some-code-with-spaces", code) +} + +func TestKeyboardFlow_EmptyInput(t *testing.T) { + input := strings.NewReader("\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_EOFBeforeInput(t *testing.T) { + // Reader that returns EOF immediately (e.g., piped /dev/null). + input := strings.NewReader("") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + // Should be ErrAuthCode since no code was received. + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + // Use a reader that blocks forever (until context cancel). + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://auth.example.com/authorize", input, output) + require.Error(t, err) +} + +func TestKeyboardFlow_DisplaysInstructions(t *testing.T) { + input := strings.NewReader("test-code\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?response_type=code", + input, + output, + ) + require.NoError(t, err) + + displayed := output.String() + // Must show the URL and some instruction text. + assert.Contains(t, displayed, "https://auth.example.com/authorize?response_type=code") + // Should prompt user to paste the code. + lower := strings.ToLower(displayed) + assert.True(t, + strings.Contains(lower, "paste") || strings.Contains(lower, "code") || strings.Contains(lower, "enter"), + "output should instruct the user to paste or enter the code", + ) +} + +func TestKeyboardFlow_Timeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // Reader that never returns data. + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://example.com/auth", input, output) + require.Error(t, err) +} + +// blockingReader is an io.Reader that blocks until the context is cancelled. +// It is used to simulate a user who never types anything. +type blockingReader struct{} + +func (r *blockingReader) Read(_ []byte) (int, error) { + // Block for a long time to simulate waiting for input. + time.Sleep(10 * time.Second) + return 0, io.EOF +} diff --git a/sdk/go/openshell/v1/oidc/oidc.go b/sdk/go/openshell/v1/oidc/oidc.go new file mode 100644 index 0000000000..916e31b78c --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "fmt" + "io" + "os" + "slices" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// Login performs an interactive OIDC authorization code login. +// +// When gatewayName is non-empty, Login resolves OIDC configuration +// (issuer URL and client ID) from the gateway's metadata.json file and +// persists tokens to the gateway directory. +// +// When gatewayName is empty, the caller must provide [WithIssuer] and +// [WithClientID] options explicitly. +// +// Before starting an interactive flow, Login checks for an existing +// valid token on disk (FR-019). If a valid, non-expired token is found, +// it is returned immediately without user interaction. +// +// The flow attempts to open a browser for authorization. If the browser +// cannot be opened, or if [WithKeyboardFlow] is set, the keyboard +// fallback flow is used instead. +func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Apply configured timeout if the caller's context has no deadline. + if _, hasDeadline := ctx.Deadline(); !hasDeadline && cfg.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.timeout) + defer cancel() + } + + // Resolve OIDC configuration from gateway or explicit options. + tokenDir, err := resolveOIDCConfig(cfg, gatewayName) + if err != nil { + return nil, err + } + + // FR-019: Check for existing valid token on disk before starting + // an interactive flow. + if tokenDir != "" { + tok, readErr := readToken(tokenDir) + if readErr == nil && tok != nil && tok.Valid() { + return tok, nil + } + // If readErr is a non-NotExist error, we log and proceed. + // Stale/expired tokens or missing files are not errors; we + // simply proceed to the interactive flow. + } + + // Run OIDC discovery to get provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Generate PKCE verifier and challenge if the provider supports S256. + var codeVerifier, codeChallenge string + if supportsS256(provider) { + codeVerifier, err = generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + codeChallenge = codeChallengeS256(codeVerifier) + } + + // Generate cryptographic state for CSRF protection. + state, err := generateState() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + + // Determine the authorization code acquisition method. + // The redirectURI must match exactly between the auth request and + // the token exchange (OIDC/OAuth2 requirement). + var code, redirectURI string + if cfg.keyboardFlow { + redirectURI = "urn:ietf:wg:oauth:2.0:oob" + code, err = loginKeyboard(ctx, cfg, provider, state, codeChallenge) + } else { + code, redirectURI, err = loginBrowser(ctx, cfg, provider, state, codeChallenge) + } + if err != nil { + return nil, err + } + + // Exchange the authorization code for tokens. + tok, err := exchangeCode(ctx, provider.TokenEndpoint, cfg.clientID, code, redirectURI, codeVerifier) + if err != nil { + return nil, err + } + + // Persist token to disk unless in-memory mode is requested. + if !cfg.inMemory && tokenDir != "" { + if writeErr := writeToken(tokenDir, tok); writeErr != nil { + return nil, writeErr + } + } + + return tok, nil +} + +// resolveOIDCConfig resolves the OIDC issuer and client ID either from +// the gateway metadata or from explicit options. Returns the token +// directory path (empty if in-memory or no directory available). +func resolveOIDCConfig(cfg *loginConfig, gatewayName string) (string, error) { + tokenDir := cfg.tokenDir + + if gatewayName != "" { + // Resolve from gateway. + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(gatewayName) + if err != nil { + return "", fmt.Errorf("failed to load gateway %q: %w", gatewayName, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return "", fmt.Errorf("%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", ErrOIDCConfig, gatewayName) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + if tokenDir == "" { + tokenDir = gwCfg.Dir + } + } + + // Validate that we have the minimum required config. + if cfg.issuer == "" || cfg.clientID == "" { + return "", fmt.Errorf("%w: issuer and client ID are required (provide a gateway name or use WithIssuer and WithClientID)", ErrOIDCConfig) + } + + return tokenDir, nil +} + +// supportsS256 checks if the OIDC provider advertises S256 PKCE support. +func supportsS256(provider *providerConfig) bool { + return slices.Contains(provider.CodeChallengeMethodsSupported, "S256") +} + +// loginKeyboard performs the keyboard flow: builds the auth URL, shows +// it to the user, and reads the pasted authorization code. +func loginKeyboard(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, error) { + redirectURI := "urn:ietf:wg:oauth:2.0:oob" + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + input := cfg.input + if input == nil { + input = os.Stdin + } + var output io.Writer = os.Stderr + if cfg.output != nil { + output = cfg.output + } + + return keyboardFlow(ctx, authURL, input, output) +} + +// loginBrowser performs the browser-based flow: starts a callback server, +// opens the browser, and waits for the callback. Falls back to keyboard +// if the browser cannot be opened. +// +// Returns (code, redirectURI, error). The redirectURI must be passed to +// exchangeCode so that it exactly matches the URI used in the auth +// request. When the function falls back to keyboard flow, the returned +// redirectURI is the keyboard placeholder ("urn:ietf:wg:oauth:2.0:oob"). +func loginBrowser(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, string, error) { + port := cfg.callbackPort + if port == 0 { + port = 8000 + } + + srv, resultCh, err := startCallbackServer(ctx, port, state) + if err != nil { + // Try fallback port if the primary port failed and no custom + // port was specified. + if cfg.callbackPort == 0 { + port = 18000 + srv, resultCh, err = startCallbackServer(ctx, port, state) + } + if err != nil { + // Cannot start callback server, fall back to keyboard. + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + } + defer func() { + _ = srv.Close() + }() + + redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + // Try to open the browser. + if browserErr := openBrowser(authURL); browserErr != nil { + // Browser failed, fall back to keyboard flow. + _ = srv.Close() + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + + // Wait for the callback result or context cancellation. + select { + case <-ctx.Done(): + return "", "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-resultCh: + if result.err != nil { + return "", "", result.err + } + return result.code, redirectURI, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/oidc_test.go b/sdk/go/openshell/v1/oidc/oidc_test.go new file mode 100644 index 0000000000..691ff67c68 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc_test.go @@ -0,0 +1,496 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T021: Login entry point tests --- + +// setupMockProvider creates a mock OIDC provider that serves discovery, +// authorize, and token endpoints. The token endpoint returns a valid +// token response. Returns the server (auto-cleaned up) and its URL. +func setupMockProvider(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "scopes_supported": []string{"openid", "profile", "email"}, + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("login-access-token", "login-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestLogin_MissingOIDCConfig verifies that Login returns ErrOIDCConfig +// when called without a gateway name and without WithIssuer/WithClientID. +func TestLogin_MissingOIDCConfig(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingIssuer verifies that Login returns ErrOIDCConfig +// when only WithClientID is provided (missing issuer). +func TestLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithClientID("test-client")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingClientID verifies that Login returns ErrOIDCConfig +// when only WithIssuer is provided (missing client ID). +func TestLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithIssuer("https://example.com")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_ReusesValidToken verifies FR-019: when a valid token exists +// on disk in the token directory, Login returns it without starting an +// interactive flow. +func TestLogin_ReusesValidToken(t *testing.T) { + resetDiscoveryCache() + + // Create a temp dir with a valid, non-expired token file. + tokenDir := t.TempDir() + existingToken := &oauth2.Token{ + AccessToken: "existing-access-token", + RefreshToken: "existing-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(1 * time.Hour), + } + err := writeToken(tokenDir, existingToken) + require.NoError(t, err) + + // Login with explicit issuer/clientID and WithTokenDir (internal) + // pointing to the directory with the existing token. No OIDC + // provider is needed because the existing token is returned. + tok, err := Login(context.Background(), "", + WithIssuer("https://issuer-should-not-be-called.example.com"), + WithClientID("test-client"), + withTokenDir(tokenDir), + ) + require.NoError(t, err) + assert.Equal(t, "existing-access-token", tok.AccessToken) + assert.Equal(t, "existing-refresh-token", tok.RefreshToken) +} + +// TestLogin_ExpiredTokenTriggersFlow verifies that an expired token on +// disk does not short-circuit: Login proceeds to the interactive flow. +// Since we use keyboard flow (no browser), we feed it a code and verify +// a new token is returned. +func TestLogin_ExpiredTokenTriggersFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + // Write an expired token. + expiredToken := &oauth2.Token{ + AccessToken: "expired-token", + RefreshToken: "old-refresh", + TokenType: "Bearer", + Expiry: time.Now().Add(-1 * time.Hour), // expired + } + err := writeToken(tokenDir, expiredToken) + require.NoError(t, err) + + // Start a callback server ourselves to simulate the auth code callback. + // We'll use keyboard flow to avoid browser dependency. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Use keyboard flow with a reader that provides a fake auth code. + // The mock provider's /token endpoint accepts any code. + codeReader := strings.NewReader("fake-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + assert.Equal(t, "login-refresh-token", tok.RefreshToken) +} + +// TestLogin_KeyboardFlow verifies that Login completes using the +// keyboard flow when WithKeyboardFlow() is set. The test provides a +// mock OIDC provider and feeds an auth code through a reader. +func TestLogin_KeyboardFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted to disk. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_InMemorySkipsPersistence verifies that WithInMemory() +// returns a token without writing to disk. +func TestLogin_InMemorySkipsPersistence(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + WithInMemory(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify NO token file on disk. + _, err = os.Stat(filepath.Join(tokenDir, oidcTokenFile)) + assert.True(t, os.IsNotExist(err), "token file should not exist in in-memory mode") +} + +// TestLogin_DiscoveryFailure verifies that Login returns ErrDiscovery +// when the OIDC provider is unreachable. +func TestLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + // Point to a server that doesn't exist. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := Login(ctx, "", + WithIssuer("http://127.0.0.1:1"), // port 1 should refuse connections + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestLogin_GatewayResolution verifies that Login resolves OIDC config +// from gateway metadata when a gateway name is provided. We use the +// withGatewayResolver option to inject a fake gateway loader. +func TestLogin_GatewayResolution(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("gw-auth-code\n") + + fakeConfig := &gateway.Config{ + Name: "test-gateway", + Endpoint: "gateway.example.com:443", + Dir: tokenDir, + OIDCIssuer: provider.URL, + OIDCClientID: "gateway-client-id", + } + + tok, err := Login(ctx, "test-gateway", + WithKeyboardFlow(), + withInput(codeReader), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "test-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted in the gateway dir. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_GatewayMissingOIDCFields verifies that Login returns +// ErrOIDCConfig when the gateway config has empty OIDC fields. +func TestLogin_GatewayMissingOIDCFields(t *testing.T) { + resetDiscoveryCache() + + fakeConfig := &gateway.Config{ + Name: "no-oidc-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + // OIDCIssuer and OIDCClientID are empty. + } + + _, err := Login(context.Background(), "no-oidc-gw", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return fakeConfig, nil + }), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_GatewayResolutionError verifies that Login propagates +// errors from gateway resolution. +func TestLogin_GatewayResolutionError(t *testing.T) { + resetDiscoveryCache() + + gwErr := fmt.Errorf("gateway not found: no-such-gateway") + + _, err := Login(context.Background(), "no-such-gateway", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return nil, gwErr + }), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway not found") +} + +// TestLogin_NoPKCESupport verifies that Login proceeds without PKCE +// when the OIDC provider does not advertise S256 support. +func TestLogin_NoPKCESupport(t *testing.T) { + resetDiscoveryCache() + + // Create a provider that does NOT list S256 in supported methods. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "scopes_supported": []string{"openid"}, + // No code_challenge_methods_supported field. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + var receivedVerifier string + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedVerifier = r.Form.Get("code_verifier") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("no-pkce-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "no-pkce-token", tok.AccessToken) + + // Verify no PKCE verifier was sent to the token endpoint. + assert.Empty(t, receivedVerifier, "code_verifier should not be sent when PKCE is not supported") +} + +// TestLogin_ContextCancellation verifies that Login respects context +// cancellation during the interactive flow. +func TestLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that is already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + // Should get a context error or timeout error. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, ErrDiscovery) || errors.Is(err, context.Canceled), + "expected timeout/discovery/cancelled error, got: %v", err, + ) +} + +// TestLogin_CustomScopes verifies that WithScopes overrides the +// default scopes sent in the authorization request. +func TestLogin_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + // Provider that captures the auth URL scope parameter. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + WithScopes("openid", "custom-scope"), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-token", tok.AccessToken) +} + +// TestLoginBrowser_PortBusy_FallbackToKeyboard verifies that +// loginBrowser falls back to keyboard flow when the callback server +// port is already occupied and no custom port is set. +func TestLoginBrowser_PortBusy_FallbackToKeyboard(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Occupy port 8000 so startCallbackServer fails on the primary port. + // Then occupy port 18000 so the fallback port also fails. + // This forces loginBrowser into the keyboard fallback path. + ln1, err1 := net.Listen("tcp", "127.0.0.1:8000") + ln2, err2 := net.Listen("tcp", "127.0.0.1:18000") + if err1 != nil || err2 != nil { + if ln1 != nil { + _ = ln1.Close() + } + if ln2 != nil { + _ = ln2.Close() + } + t.Skip("Cannot bind test ports 8000 and 18000") + } + defer func() { _ = ln1.Close() }() + defer func() { _ = ln2.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-fallback-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) +} + +// TestLogin_ContextTimeout verifies that a Login with a very short +// timeout returns a timeout-related error. +func TestLogin_ContextTimeout(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that times out immediately. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + time.Sleep(1 * time.Millisecond) // ensure timeout fires + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/options.go b/sdk/go/openshell/v1/oidc/options.go new file mode 100644 index 0000000000..0c83be1c4a --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "io" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// defaultScopes are the OIDC scopes requested when no custom scopes +// are specified via [WithScopes]. +var defaultScopes = []string{"openid", "profile", "email"} + +// defaultTimeout is the maximum duration for interactive login flows +// (browser, keyboard, device code) when no custom timeout is set. +const defaultTimeout = 2 * time.Minute + +// loginConfig holds the resolved configuration for a single login +// 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 + + // Internal fields for testing. Not exposed via public API. + tokenDir string // override token directory + input io.Reader // override stdin for keyboard flow + output io.Writer // override stderr for keyboard flow + gatewayResolver func(name string) (*gateway.Config, error) // override gateway.LoadConfig +} + +// applyDefaults fills in default values for fields that were not set +// by any option function. +func (c *loginConfig) applyDefaults() { + if len(c.scopes) == 0 { + // Deep copy to avoid callers mutating the package-level slice. + c.scopes = make([]string, len(defaultScopes)) + copy(c.scopes, defaultScopes) + } + if c.timeout == 0 { + c.timeout = defaultTimeout + } +} + +// LoginOption configures a login attempt. Use the With* functions to +// create option values. +type LoginOption func(*loginConfig) + +// WithIssuer sets the OIDC issuer URL. Required for standalone flows +// (when no gateway name is provided to [Login]). +func WithIssuer(url string) LoginOption { + return func(c *loginConfig) { + c.issuer = url + } +} + +// WithClientID sets the OAuth2 client ID. Required for standalone +// flows (when no gateway name is provided to [Login]). +func WithClientID(id string) LoginOption { + return func(c *loginConfig) { + c.clientID = id + } +} + +// WithClientSecret sets the client secret for the client credentials +// grant. Required for [ClientCredentials]. +func WithClientSecret(secret string) LoginOption { + return func(c *loginConfig) { + c.clientSecret = secret + } +} + +// WithScopes overrides the default scopes (openid, profile, email). +// The provided scopes replace the defaults entirely. +func WithScopes(scopes ...string) LoginOption { + return func(c *loginConfig) { + c.scopes = make([]string, len(scopes)) + copy(c.scopes, scopes) + c.scopesSet = true + } +} + +// WithCallbackPort sets a fixed port for the localhost callback server. +// By default the server tries port 8000, then 18000. +func WithCallbackPort(port int) LoginOption { + return func(c *loginConfig) { + c.callbackPort = port + } +} + +// WithTimeout sets the maximum duration for interactive login flows. +// The default is 2 minutes. +func WithTimeout(d time.Duration) LoginOption { + return func(c *loginConfig) { + c.timeout = d + } +} + +// WithKeyboardFlow forces the keyboard flow (manual URL copy and code +// paste) instead of attempting to open a browser. +func WithKeyboardFlow() LoginOption { + return func(c *loginConfig) { + c.keyboardFlow = true + } +} + +// WithInMemory skips persisting the token to disk. The returned token +// is only available in memory for the lifetime of the process. +func WithInMemory() LoginOption { + return func(c *loginConfig) { + c.inMemory = true + } +} + +// WithDisplayFunc sets a custom display function for the device code +// flow. The function receives the verification URL and user code that +// the user must enter to authorize the device. If not set, the default +// behavior prints to stdout. +func WithDisplayFunc(fn func(verificationURL, userCode string)) LoginOption { + return func(c *loginConfig) { + c.displayFunc = fn + } +} + +// 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. +func WithGateway(name string) LoginOption { + return func(c *loginConfig) { + c.gateway = name + } +} + +// --- Internal options for testing (unexported) --- + +// withTokenDir overrides the token directory for testing. +func withTokenDir(dir string) LoginOption { + return func(c *loginConfig) { + c.tokenDir = dir + } +} + +// withInput overrides the input reader for keyboard flow testing. +func withInput(r io.Reader) LoginOption { + return func(c *loginConfig) { + c.input = r + } +} + +// withGatewayResolver overrides the gateway.LoadConfig function for +// testing. This allows tests to inject a fake gateway resolver +// without filesystem setup. +func withGatewayResolver(fn func(name string) (*gateway.Config, error)) LoginOption { + return func(c *loginConfig) { + c.gatewayResolver = fn + } +} diff --git a/sdk/go/openshell/v1/oidc/options_test.go b/sdk/go/openshell/v1/oidc/options_test.go new file mode 100644 index 0000000000..28d17c06b4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLoginConfig_Defaults(t *testing.T) { + var cfg loginConfig + cfg.applyDefaults() + + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.scopes) + assert.Equal(t, 2*time.Minute, cfg.timeout) + assert.Empty(t, cfg.issuer) + assert.Empty(t, cfg.clientID) + assert.Empty(t, cfg.clientSecret) + assert.Zero(t, cfg.callbackPort) + assert.False(t, cfg.keyboardFlow) + assert.False(t, cfg.inMemory) + assert.Nil(t, cfg.displayFunc) + assert.Empty(t, cfg.gateway) +} + +func TestWithIssuer(t *testing.T) { + var cfg loginConfig + WithIssuer("https://auth.example.com")(&cfg) + + assert.Equal(t, "https://auth.example.com", cfg.issuer) +} + +func TestWithClientID(t *testing.T) { + var cfg loginConfig + WithClientID("my-app")(&cfg) + + assert.Equal(t, "my-app", cfg.clientID) +} + +func TestWithClientSecret(t *testing.T) { + var cfg loginConfig + WithClientSecret("s3cret")(&cfg) + + assert.Equal(t, "s3cret", cfg.clientSecret) +} + +func TestWithScopes(t *testing.T) { + var cfg loginConfig + WithScopes("openid", "custom")(&cfg) + cfg.applyDefaults() + + // Custom scopes should not be overwritten by defaults. + assert.Equal(t, []string{"openid", "custom"}, cfg.scopes) +} + +func TestWithScopes_DeepCopy(t *testing.T) { + original := []string{"openid", "custom"} + var cfg loginConfig + WithScopes(original...)(&cfg) + + // Mutating the original slice should not affect the config. + original[0] = "mutated" + assert.Equal(t, "openid", cfg.scopes[0]) +} + +func TestWithCallbackPort(t *testing.T) { + var cfg loginConfig + WithCallbackPort(9090)(&cfg) + + assert.Equal(t, 9090, cfg.callbackPort) +} + +func TestWithTimeout(t *testing.T) { + var cfg loginConfig + WithTimeout(5 * time.Minute)(&cfg) + cfg.applyDefaults() + + // Custom timeout should not be overwritten by defaults. + assert.Equal(t, 5*time.Minute, cfg.timeout) +} + +func TestWithKeyboardFlow(t *testing.T) { + var cfg loginConfig + WithKeyboardFlow()(&cfg) + + assert.True(t, cfg.keyboardFlow) +} + +func TestWithInMemory(t *testing.T) { + var cfg loginConfig + WithInMemory()(&cfg) + + assert.True(t, cfg.inMemory) +} + +func TestWithDisplayFunc(t *testing.T) { + called := false + fn := func(_, _ string) { called = true } + + var cfg loginConfig + WithDisplayFunc(fn)(&cfg) + + assert.NotNil(t, cfg.displayFunc) + cfg.displayFunc("http://example.com", "ABCD-1234") + assert.True(t, called) +} + +func TestWithGateway(t *testing.T) { + var cfg loginConfig + WithGateway("prod-gw")(&cfg) + + assert.Equal(t, "prod-gw", cfg.gateway) +} + +func TestMultipleOptions(t *testing.T) { + opts := []LoginOption{ + WithIssuer("https://auth.example.com"), + WithClientID("app-id"), + WithScopes("openid"), + WithTimeout(30 * time.Second), + WithKeyboardFlow(), + } + + var cfg loginConfig + for _, opt := range opts { + opt(&cfg) + } + cfg.applyDefaults() + + assert.Equal(t, "https://auth.example.com", cfg.issuer) + assert.Equal(t, "app-id", cfg.clientID) + assert.Equal(t, []string{"openid"}, cfg.scopes) + assert.Equal(t, 30*time.Second, cfg.timeout) + assert.True(t, cfg.keyboardFlow) +} + +func TestDefaultScopes_NotMutatedByConfig(t *testing.T) { + // Verify the package-level defaultScopes slice is not shared. + var cfg loginConfig + cfg.applyDefaults() + cfg.scopes[0] = "mutated" + + assert.Equal(t, "openid", defaultScopes[0]) +} + +func TestLastOptionWins(t *testing.T) { + var cfg loginConfig + WithIssuer("first")(&cfg) + WithIssuer("second")(&cfg) + + assert.Equal(t, "second", cfg.issuer) +} diff --git a/sdk/go/openshell/v1/oidc/token.go b/sdk/go/openshell/v1/oidc/token.go new file mode 100644 index 0000000000..e551dafc39 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/oauth2" +) + +// oidcTokenFile is the filename for persisted OIDC tokens. This must +// match the constant in gateway/token.go for interop. +const oidcTokenFile = "oidc_token.json" + +// tokenExpiryLeeway is the grace period subtracted from the token +// expiry when checking validity. Tokens expiring within this window +// are treated as expired to avoid using a token that expires during +// an in-flight request. +const tokenExpiryLeeway = 10 * time.Second + +// oidcBundle is the on-disk JSON representation of an OIDC token. +// The format is shared with the Rust CLI and the gateway package's +// diskTokenSource for interop. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// writeToken persists an oauth2.Token to disk as oidc_token.json in +// the given directory. The file is written with 0600 permissions +// (owner-only) to protect credentials. +func writeToken(dir string, tok *oauth2.Token) error { + bundle := oidcBundle{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + } + + if !tok.Expiry.IsZero() { + bundle.Expiry = tok.Expiry.UTC().Format(time.RFC3339) + remaining := time.Until(tok.Expiry) + if remaining > 0 { + bundle.ExpiresIn = int64(remaining.Seconds()) + } + } + + data, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("%w: failed to marshal token: %v", ErrTokenPersist, err) + } + + path := filepath.Join(dir, oidcTokenFile) + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("%w: failed to write %s: %v", ErrTokenPersist, path, err) + } + + return nil +} + +// readToken reads an existing oidc_token.json from the given +// directory. It returns: +// - (token, nil) if the file exists, is valid, and the token has not +// expired (with leeway) +// - (nil, nil) if the file does not exist, the token is expired, or +// the access token is empty (not an error, just no reusable token) +// - (nil, error) if the file exists but cannot be parsed +func readToken(dir string) (*oauth2.Token, error) { + path := filepath.Join(dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenPersist, path, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrTokenPersist, oidcTokenFile, err) + } + + if bundle.AccessToken == "" { + return nil, nil + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry from the "expiry" field (RFC 3339). Without an + // explicit expiry, the token is treated as non-expiring (always + // valid); "expires_in" alone cannot reconstruct an absolute time + // without a write timestamp. + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr != nil { + return nil, fmt.Errorf("%w: invalid expiry format in %s: %v", ErrTokenPersist, oidcTokenFile, parseErr) + } + tok.Expiry = expiry + } + + // Check if the token has expired (with leeway). + if !tok.Expiry.IsZero() && time.Now().After(tok.Expiry.Add(-tokenExpiryLeeway)) { + return nil, nil // Expired; caller should re-authenticate. + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/token_test.go b/sdk/go/openshell/v1/oidc/token_test.go new file mode 100644 index 0000000000..3590916b2f --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token_test.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestWriteToken_Success(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "access-123", + RefreshToken: "refresh-456", + Expiry: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + // Verify the file was written. + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + assert.Contains(t, string(data), `"access_token":"access-123"`) + assert.Contains(t, string(data), `"refresh_token":"refresh-456"`) + assert.Contains(t, string(data), `"expiry":"2026-07-03T12:00:00Z"`) +} + +func TestWriteToken_ExpiresInCalculated(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(3600 * time.Second) + tok := &oauth2.Token{ + AccessToken: "access-123", + Expiry: expiry, + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // expires_in should be roughly 3600 (within a few seconds). + assert.Contains(t, string(data), `"expires_in":`) +} + +func TestWriteToken_InvalidDirectory(t *testing.T) { + err := writeToken("/nonexistent/path/that/does/not/exist", &oauth2.Token{ + AccessToken: "test", + Expiry: time.Now().Add(time.Hour), + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_Success(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "2099-07-03T12:00:00Z", + "expires_in": 3600 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestReadToken_MissingFile(t *testing.T) { + dir := t.TempDir() + + tok, err := readToken(dir) + assert.Nil(t, tok) + assert.NoError(t, err, "missing file should return nil token, no error") +} + +func TestReadToken_InvalidJSON(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(`{invalid`), 0o600) + require.NoError(t, err) + + _, err = readToken(dir) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_ExpiredToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "expired-access", + "expiry": "2020-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "expired token should return nil") + assert.NoError(t, err, "expired token is not an error, just nil") +} + +func TestReadToken_ValidWithExpiresInFallback(t *testing.T) { + dir := t.TempDir() + // No expiry field, only expires_in. Since we wrote it "now", + // a large expires_in should make the token valid. + content := `{ + "access_token": "access-via-expires-in", + "expires_in": 99999 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + // Token with only expires_in cannot reconstruct a valid Expiry + // without knowing when the file was written. readToken should + // treat it as potentially valid and return it. + assert.NotNil(t, tok) + assert.Equal(t, "access-via-expires-in", tok.AccessToken) +} + +func TestReadToken_EmptyAccessToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "", + "expiry": "2099-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "empty access token should return nil") + assert.NoError(t, err) +} + +func TestWriteAndReadToken_Roundtrip(t *testing.T) { + dir := t.TempDir() + original := &oauth2.Token{ + AccessToken: "roundtrip-access", + RefreshToken: "roundtrip-refresh", + Expiry: time.Now().Add(time.Hour).Truncate(time.Second), + } + + err := writeToken(dir, original) + require.NoError(t, err) + + loaded, err := readToken(dir) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, original.AccessToken, loaded.AccessToken) + assert.Equal(t, original.RefreshToken, loaded.RefreshToken) + // Expiry should be close (within a second due to serialization). + assert.WithinDuration(t, original.Expiry, loaded.Expiry, time.Second) +} + +func TestWriteToken_FilePermissions(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "perm-check", + Expiry: time.Now().Add(time.Hour), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // File should be owner-only readable (0600). + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/sdk/go/openshell/v1/options.go b/sdk/go/openshell/v1/options.go new file mode 100644 index 0000000000..cb165b23a4 --- /dev/null +++ b/sdk/go/openshell/v1/options.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// CreateOptions configures resource creation. +type CreateOptions = types.CreateOptions + +// GetOptions configures resource retrieval. +type GetOptions = types.GetOptions + +// ListOptions configures resource listing with pagination and filtering. +type ListOptions = types.ListOptions + +// DeleteOptions configures resource deletion. +type DeleteOptions = types.DeleteOptions + +// UpdateOptions configures resource updates. +type UpdateOptions = types.UpdateOptions + +// WatchOptions configures watch behavior. +type WatchOptions = types.WatchOptions + +// WaitOptions configures wait behavior. Use context for timeout control. +type WaitOptions = types.WaitOptions + +// ExecOptions configures command execution. +type ExecOptions = types.ExecOptions diff --git a/sdk/go/openshell/v1/policy.go b/sdk/go/openshell/v1/policy.go new file mode 100644 index 0000000000..5678157da1 --- /dev/null +++ b/sdk/go/openshell/v1/policy.go @@ -0,0 +1,173 @@ +// 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" +) + +// SandboxPolicy is the top-level security policy configuration for a sandbox. +type SandboxPolicy = types.SandboxPolicy + +// FilesystemPolicy controls which directories the sandbox can access. +type FilesystemPolicy = types.FilesystemPolicy + +// LandlockPolicy configures the Linux Landlock LSM. +type LandlockPolicy = types.LandlockPolicy + +// ProcessPolicy controls the user and group identity for sandboxed processes. +type ProcessPolicy = types.ProcessPolicy + +// PolicyChunk represents a single proposed policy change in the draft inbox. +type PolicyChunk = types.PolicyChunk + +// DraftPolicy contains the full draft policy state returned by GetDraft. +type DraftPolicy = types.DraftPolicy + +// PolicyStatusResult contains the status of a sandbox's policy. +type PolicyStatusResult = types.PolicyStatusResult + +// SandboxPolicyRevision represents a versioned policy revision for a sandbox. +type SandboxPolicyRevision = types.SandboxPolicyRevision + +// PolicyLoadStatus represents the load state of a policy revision. +type PolicyLoadStatus = types.PolicyLoadStatus + +// PolicyLoadStatus constants re-exported from types package. +const ( + PolicyLoadStatusUnspecified = types.PolicyLoadStatusUnspecified + PolicyLoadStatusPending = types.PolicyLoadStatusPending + PolicyLoadStatusLoaded = types.PolicyLoadStatusLoaded + PolicyLoadStatusFailed = types.PolicyLoadStatusFailed + PolicyLoadStatusSuperseded = types.PolicyLoadStatusSuperseded +) + +// ApproveResult contains the result of approving a single draft chunk. +type ApproveResult = types.ApproveResult + +// ApproveAllResult contains the result of approving all draft chunks. +type ApproveAllResult = types.ApproveAllResult + +// UndoResult contains the result of undoing a draft chunk approval. +type UndoResult = types.UndoResult + +// ClearResult contains the result of clearing all draft chunks. +type ClearResult = types.ClearResult + +// DraftHistoryEntry represents a single event in the draft policy history. +type DraftHistoryEntry = types.DraftHistoryEntry + +// GetDraftOption configures a GetDraft call. +type GetDraftOption = types.GetDraftOption + +// WithStatusFilter filters draft chunks by approval status. +var WithStatusFilter = types.WithStatusFilter + +// ApproveAllOption configures an ApproveAllDraftChunks call. +type ApproveAllOption = types.ApproveAllOption + +// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval. +var WithIncludeSecurityFlagged = types.WithIncludeSecurityFlagged + +// GetStatusOption configures a GetStatus call. +type GetStatusOption = types.GetStatusOption + +// WithVersion queries a specific policy version instead of the latest. +var WithVersion = types.WithVersion + +// ListPolicyOption configures a List call. +type ListPolicyOption = types.ListPolicyOption + +// WithLimit sets the maximum number of revisions to return. +var WithLimit = types.WithLimit + +// WithOffset sets the pagination offset. +var WithOffset = types.WithOffset + +// PolicyInterface defines operations for managing sandbox policy drafts, +// approvals, and revision history. +type PolicyInterface interface { + // GetDraft retrieves the current draft policy for a sandbox, including + // all pending, approved, and rejected chunks. Use WithStatusFilter to + // return only chunks matching a specific status. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if the + // sandbox name is empty; Unimplemented by the fake client. + GetDraft(ctx context.Context, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) + + // ApproveDraftChunk approves a single pending draft chunk, merging + // its proposed rule into the active policy. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name or chunk ID is empty; + // Conflict if the chunk has already been approved or rejected; + // Unimplemented by the fake client. + ApproveDraftChunk(ctx context.Context, sandboxName, chunkID string) (*ApproveResult, error) + + // RejectDraftChunk rejects a single pending draft chunk with an + // optional reason that is fed to future LLM analysis context. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name or chunk ID is empty; + // Conflict if the chunk has already been approved or rejected; + // Unimplemented by the fake client. + RejectDraftChunk(ctx context.Context, sandboxName, chunkID, reason string) error + + // ApproveAllDraftChunks approves all pending draft chunks at once. + // By default, security-flagged chunks are skipped. Use + // WithIncludeSecurityFlagged to include them. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + ApproveAllDraftChunks(ctx context.Context, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error) + + // ClearDraftChunks removes all pending draft chunks for a sandbox. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + ClearDraftChunks(ctx context.Context, sandboxName string) (*ClearResult, error) + + // GetDraftHistory returns the chronological decision history for a + // sandbox's draft policy (approvals, rejections, edits, undos, clears). + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + GetDraftHistory(ctx context.Context, sandboxName string) ([]DraftHistoryEntry, error) + + // GetStatus retrieves the policy status for a sandbox, including the + // queried revision and the active version. Use WithVersion to query a + // specific version instead of the latest. + // + // Errors: NotFound if the sandbox or requested version does not exist; + // InvalidArgument if the sandbox name is empty; + // Unimplemented by the fake client. + GetStatus(ctx context.Context, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) + + // List returns policy revisions for a sandbox, ordered by version. + // Use WithLimit and WithOffset for pagination. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + List(ctx context.Context, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) + + // EditDraftChunk replaces the proposed rule of a pending draft chunk + // with the given network policy rule. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name, chunk ID, or proposed rule is + // empty/nil; Conflict if the chunk is not in a pending state; + // Unimplemented by the fake client. + EditDraftChunk(ctx context.Context, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error + + // UndoDraftChunk reverses a previously approved chunk, removing its + // merged rule from the active policy. + // + // Errors: NotFound if the sandbox or chunk does not exist; + // InvalidArgument if the sandbox name or chunk ID is empty; + // Conflict if the chunk has not been approved; + // Unimplemented by the fake client. + UndoDraftChunk(ctx context.Context, sandboxName, chunkID string) (*UndoResult, error) +} diff --git a/sdk/go/openshell/v1/policy_client.go b/sdk/go/openshell/v1/policy_client.go new file mode 100644 index 0000000000..0844242fc1 --- /dev/null +++ b/sdk/go/openshell/v1/policy_client.go @@ -0,0 +1,156 @@ +// 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" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type policyClient struct { + client pb.OpenShellClient +} + +func newPolicyClient(conn grpc.ClientConnInterface) *policyClient { + return &policyClient{client: pb.NewOpenShellClient(conn)} +} + +func (p *policyClient) GetDraft(ctx context.Context, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) { + cfg := types.ApplyGetDraftOptions(opts) + resp, err := p.client.GetDraftPolicy(ctx, &pb.GetDraftPolicyRequest{ + Name: sandboxName, + StatusFilter: cfg.StatusFilter(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.DraftPolicyFromProto(resp), nil +} + +func (p *policyClient) ApproveDraftChunk(ctx context.Context, sandboxName, chunkID string) (*ApproveResult, error) { + resp, err := p.client.ApproveDraftChunk(ctx, &pb.ApproveDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ApproveResultFromProto(resp), nil +} + +func (p *policyClient) RejectDraftChunk(ctx context.Context, sandboxName, chunkID, reason string) error { + _, err := p.client.RejectDraftChunk(ctx, &pb.RejectDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + Reason: reason, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *policyClient) ApproveAllDraftChunks(ctx context.Context, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error) { + cfg := types.ApplyApproveAllOptions(opts) + resp, err := p.client.ApproveAllDraftChunks(ctx, &pb.ApproveAllDraftChunksRequest{ + Name: sandboxName, + IncludeSecurityFlagged: cfg.IncludeSecurityFlagged(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ApproveAllResultFromProto(resp), nil +} + +func (p *policyClient) ClearDraftChunks(ctx context.Context, sandboxName string) (*ClearResult, error) { + resp, err := p.client.ClearDraftChunks(ctx, &pb.ClearDraftChunksRequest{ + Name: sandboxName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ClearResultFromProto(resp), nil +} + +func (p *policyClient) GetDraftHistory(ctx context.Context, sandboxName string) ([]DraftHistoryEntry, error) { + resp, err := p.client.GetDraftHistory(ctx, &pb.GetDraftHistoryRequest{ + Name: sandboxName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + entries := resp.GetEntries() + if len(entries) == 0 { + return nil, nil + } + result := make([]DraftHistoryEntry, 0, len(entries)) + for _, e := range entries { + if converted := converter.DraftHistoryEntryFromProto(e); converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *policyClient) GetStatus(ctx context.Context, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) { + cfg := types.ApplyGetStatusOptions(opts) + resp, err := p.client.GetSandboxPolicyStatus(ctx, &pb.GetSandboxPolicyStatusRequest{ + Name: sandboxName, + Version: cfg.Version(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.PolicyStatusResultFromProto(resp), nil +} + +func (p *policyClient) List(ctx context.Context, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { + cfg := types.ApplyListPolicyOptions(opts) + resp, err := p.client.ListSandboxPolicies(ctx, &pb.ListSandboxPoliciesRequest{ + Name: sandboxName, + Limit: cfg.Limit(), + Offset: cfg.Offset(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + revisions := resp.GetRevisions() + if len(revisions) == 0 { + return nil, nil + } + result := make([]SandboxPolicyRevision, 0, len(revisions)) + for _, r := range revisions { + if converted := converter.SandboxPolicyRevisionFromProto(r); converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *policyClient) EditDraftChunk(ctx context.Context, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error { + _, err := p.client.EditDraftChunk(ctx, &pb.EditDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + ProposedRule: converter.NetworkPolicyRuleToProto(proposedRule), + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *policyClient) UndoDraftChunk(ctx context.Context, sandboxName, chunkID string) (*UndoResult, error) { + resp, err := p.client.UndoDraftChunk(ctx, &pb.UndoDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.UndoResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go new file mode 100644 index 0000000000..1383af6251 --- /dev/null +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -0,0 +1,816 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "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" +) + +// --- Mock server for Policy RPCs --- + +type mockPolicyServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + // Canned responses. + getDraftResp *pb.GetDraftPolicyResponse + approveResp *pb.ApproveDraftChunkResponse + rejectResp *pb.RejectDraftChunkResponse + approveAllResp *pb.ApproveAllDraftChunksResponse + clearResp *pb.ClearDraftChunksResponse + historyResp *pb.GetDraftHistoryResponse + statusResp *pb.GetSandboxPolicyStatusResponse + listResp *pb.ListSandboxPoliciesResponse + editResp *pb.EditDraftChunkResponse + undoResp *pb.UndoDraftChunkResponse + + // Recorded requests. + lastGetDraftReq *pb.GetDraftPolicyRequest + lastApproveReq *pb.ApproveDraftChunkRequest + lastRejectReq *pb.RejectDraftChunkRequest + lastApproveAllReq *pb.ApproveAllDraftChunksRequest + lastClearReq *pb.ClearDraftChunksRequest + lastHistoryReq *pb.GetDraftHistoryRequest + lastStatusReq *pb.GetSandboxPolicyStatusRequest + lastListReq *pb.ListSandboxPoliciesRequest + lastEditReq *pb.EditDraftChunkRequest + lastUndoReq *pb.UndoDraftChunkRequest + + // Inject errors. + getDraftErr error + approveErr error + rejectErr error + approveAllErr error + clearErr error + historyErr error + statusErr error + listErr error + editErr error + undoErr error +} + +func newMockPolicyServer() *mockPolicyServer { + return &mockPolicyServer{} +} + +func (s *mockPolicyServer) GetDraftPolicy(_ context.Context, req *pb.GetDraftPolicyRequest) (*pb.GetDraftPolicyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGetDraftReq = req + if s.getDraftErr != nil { + return nil, s.getDraftErr + } + return s.getDraftResp, nil +} + +func (s *mockPolicyServer) ApproveDraftChunk(_ context.Context, req *pb.ApproveDraftChunkRequest) (*pb.ApproveDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastApproveReq = req + if s.approveErr != nil { + return nil, s.approveErr + } + return s.approveResp, nil +} + +func (s *mockPolicyServer) RejectDraftChunk(_ context.Context, req *pb.RejectDraftChunkRequest) (*pb.RejectDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastRejectReq = req + if s.rejectErr != nil { + return nil, s.rejectErr + } + return s.rejectResp, nil +} + +func (s *mockPolicyServer) ApproveAllDraftChunks(_ context.Context, req *pb.ApproveAllDraftChunksRequest) (*pb.ApproveAllDraftChunksResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastApproveAllReq = req + if s.approveAllErr != nil { + return nil, s.approveAllErr + } + return s.approveAllResp, nil +} + +func (s *mockPolicyServer) ClearDraftChunks(_ context.Context, req *pb.ClearDraftChunksRequest) (*pb.ClearDraftChunksResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastClearReq = req + if s.clearErr != nil { + return nil, s.clearErr + } + return s.clearResp, nil +} + +func (s *mockPolicyServer) GetDraftHistory(_ context.Context, req *pb.GetDraftHistoryRequest) (*pb.GetDraftHistoryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastHistoryReq = req + if s.historyErr != nil { + return nil, s.historyErr + } + return s.historyResp, nil +} + +func (s *mockPolicyServer) GetSandboxPolicyStatus(_ context.Context, req *pb.GetSandboxPolicyStatusRequest) (*pb.GetSandboxPolicyStatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastStatusReq = req + if s.statusErr != nil { + return nil, s.statusErr + } + return s.statusResp, nil +} + +func (s *mockPolicyServer) ListSandboxPolicies(_ context.Context, req *pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastListReq = req + if s.listErr != nil { + return nil, s.listErr + } + return s.listResp, nil +} + +func (s *mockPolicyServer) EditDraftChunk(_ context.Context, req *pb.EditDraftChunkRequest) (*pb.EditDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastEditReq = req + if s.editErr != nil { + return nil, s.editErr + } + return s.editResp, nil +} + +func (s *mockPolicyServer) UndoDraftChunk(_ context.Context, req *pb.UndoDraftChunkRequest) (*pb.UndoDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastUndoReq = req + if s.undoErr != nil { + return nil, s.undoErr + } + return s.undoResp, nil +} + +// --- Test setup --- + +func setupPolicyTest(t *testing.T, mock *mockPolicyServer) (*policyClient, 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 newPolicyClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// =========================================================================== +// Phase 2 (T020): GetDraft, ApproveDraftChunk, RejectDraftChunk +// =========================================================================== + +func TestPolicyGetDraft(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftResp = &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + { + Id: "chunk-1", + Status: "pending", + RuleName: "allow-dns", + Rationale: "DNS access needed", + Confidence: 0.95, + DenialSummaryIds: []string{"ds-1", "ds-2"}, + CreatedAtMs: 1700000000000, + Stage: "initial", + HitCount: 3, + Binary: "/usr/bin/curl", + ProposedRule: &sbv1.NetworkPolicyRule{ + Name: "allow-dns-rule", + }, + }, + { + Id: "chunk-2", + Status: "approved", + RuleName: "allow-https", + }, + }, + RollingSummary: "Two rules proposed", + DraftVersion: 5, + LastAnalyzedAtMs: 1700000001000, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, draft) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastGetDraftReq.GetName()) + assert.Empty(t, mock.lastGetDraftReq.GetStatusFilter()) + mock.mu.Unlock() + + // Verify response mapping. + assert.Equal(t, "Two rules proposed", draft.RollingSummary) + assert.Equal(t, uint64(5), draft.DraftVersion) + assert.False(t, draft.LastAnalyzedAt.IsZero()) + + require.Len(t, draft.Chunks, 2) + + c1 := draft.Chunks[0] + assert.Equal(t, "chunk-1", c1.ID) + assert.Equal(t, "pending", c1.Status) + assert.Equal(t, "allow-dns", c1.RuleName) + assert.Equal(t, "DNS access needed", c1.Rationale) + assert.InDelta(t, float32(0.95), c1.Confidence, 0.001) + assert.Equal(t, []string{"ds-1", "ds-2"}, c1.DenialSummaryIDs) + assert.Equal(t, "initial", c1.Stage) + assert.Equal(t, int32(3), c1.HitCount) + assert.Equal(t, "/usr/bin/curl", c1.Binary) + require.NotNil(t, c1.ProposedRule) + assert.Equal(t, "allow-dns-rule", c1.ProposedRule.Name) + + c2 := draft.Chunks[1] + assert.Equal(t, "chunk-2", c2.ID) + assert.Equal(t, "approved", c2.Status) +} + +func TestPolicyGetDraft_WithStatusFilter(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftResp = &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + {Id: "chunk-1", Status: "pending"}, + }, + DraftVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "sb1", types.WithStatusFilter("pending")) + + require.NoError(t, err) + require.NotNil(t, draft) + + // Verify status filter was forwarded. + mock.mu.Lock() + assert.Equal(t, "pending", mock.lastGetDraftReq.GetStatusFilter()) + mock.mu.Unlock() + + require.Len(t, draft.Chunks, 1) + assert.Equal(t, "pending", draft.Chunks[0].Status) +} + +func TestPolicyGetDraft_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "missing") + + assert.Nil(t, draft) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyApproveDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.approveResp = &pb.ApproveDraftChunkResponse{ + PolicyVersion: 7, + PolicyHash: "sha256:abc123", + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveDraftChunk(context.Background(), "my-sandbox", "chunk-1") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastApproveReq.GetName()) + assert.Equal(t, "chunk-1", mock.lastApproveReq.GetChunkId()) + mock.mu.Unlock() + + // Verify response mapping. + assert.Equal(t, uint32(7), result.PolicyVersion) + assert.Equal(t, "sha256:abc123", result.PolicyHash) +} + +func TestPolicyApproveDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.approveErr = status.Errorf(codes.NotFound, "chunk not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveDraftChunk(context.Background(), "sb1", "bad-chunk") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyRejectDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.rejectResp = &pb.RejectDraftChunkResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.RejectDraftChunk(context.Background(), "my-sandbox", "chunk-2", "too broad") + + require.NoError(t, err) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastRejectReq.GetName()) + assert.Equal(t, "chunk-2", mock.lastRejectReq.GetChunkId()) + assert.Equal(t, "too broad", mock.lastRejectReq.GetReason()) + mock.mu.Unlock() +} + +func TestPolicyRejectDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.rejectErr = status.Errorf(codes.InvalidArgument, "invalid chunk") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.RejectDraftChunk(context.Background(), "sb1", "bad", "reason") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// =========================================================================== +// Phase 3 (T022): ApproveAllDraftChunks, ClearDraftChunks, GetDraftHistory +// =========================================================================== + +func TestPolicyApproveAllDraftChunks(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllResp = &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 8, + PolicyHash: "sha256:bulk", + ChunksApproved: 5, + ChunksSkipped: 2, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify default: security-flagged NOT included. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastApproveAllReq.GetName()) + assert.False(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) + mock.mu.Unlock() + + assert.Equal(t, uint32(8), result.PolicyVersion) + assert.Equal(t, "sha256:bulk", result.PolicyHash) + assert.Equal(t, uint32(5), result.ChunksApproved) + assert.Equal(t, uint32(2), result.ChunksSkipped) +} + +func TestPolicyApproveAllDraftChunks_WithSecurityFlagged(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllResp = &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 9, + PolicyHash: "sha256:all", + ChunksApproved: 7, + ChunksSkipped: 0, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "sb1", types.WithIncludeSecurityFlagged()) + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify security-flagged flag was sent. + mock.mu.Lock() + assert.True(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) + mock.mu.Unlock() + + assert.Equal(t, uint32(7), result.ChunksApproved) + assert.Equal(t, uint32(0), result.ChunksSkipped) +} + +func TestPolicyApproveAllDraftChunks_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "missing") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyClearDraftChunks(t *testing.T) { + mock := newMockPolicyServer() + mock.clearResp = &pb.ClearDraftChunksResponse{ + ChunksCleared: 4, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ClearDraftChunks(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastClearReq.GetName()) + mock.mu.Unlock() + + assert.Equal(t, uint32(4), result.ChunksCleared) +} + +func TestPolicyClearDraftChunks_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.clearErr = status.Errorf(codes.Internal, "internal error") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ClearDraftChunks(context.Background(), "sb1") + + assert.Nil(t, result) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +func TestPolicyGetDraftHistory(t *testing.T) { + mock := newMockPolicyServer() + mock.historyResp = &pb.GetDraftHistoryResponse{ + Entries: []*pb.DraftHistoryEntry{ + { + TimestampMs: 1700000000000, + EventType: "approved", + Description: "Chunk chunk-1 approved", + ChunkId: "chunk-1", + }, + { + TimestampMs: 1700000001000, + EventType: "rejected", + Description: "Chunk chunk-2 rejected: too broad", + ChunkId: "chunk-2", + }, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.Len(t, entries, 2) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastHistoryReq.GetName()) + mock.mu.Unlock() + + assert.Equal(t, "approved", entries[0].EventType) + assert.Equal(t, "Chunk chunk-1 approved", entries[0].Description) + assert.Equal(t, "chunk-1", entries[0].ChunkID) + assert.False(t, entries[0].Timestamp.IsZero()) + + assert.Equal(t, "rejected", entries[1].EventType) + assert.Equal(t, "chunk-2", entries[1].ChunkID) +} + +func TestPolicyGetDraftHistory_Empty(t *testing.T) { + mock := newMockPolicyServer() + mock.historyResp = &pb.GetDraftHistoryResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "sb1") + + require.NoError(t, err) + assert.Nil(t, entries) +} + +func TestPolicyGetDraftHistory_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.historyErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "missing") + + assert.Nil(t, entries) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// =========================================================================== +// Phase 4 (T024): GetStatus, List, EditDraftChunk, UndoDraftChunk +// =========================================================================== + +func TestPolicyGetStatus(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:rev3", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + CreatedAtMs: 1700000000000, + LoadedAtMs: 1700000001000, + }, + ActiveVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded (no version = latest). + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetName()) + assert.Equal(t, uint32(0), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() + + assert.Equal(t, uint32(3), result.ActiveVersion) + assert.Equal(t, uint32(3), result.Revision.Version) + assert.Equal(t, "sha256:rev3", result.Revision.PolicyHash) + assert.Equal(t, PolicyLoadStatusLoaded, result.Revision.Status) + assert.False(t, result.Revision.CreatedAt.IsZero()) + assert.False(t, result.Revision.LoadedAt.IsZero()) +} + +func TestPolicyGetStatus_WithVersion(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 2, + PolicyHash: "sha256:rev2", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + }, + ActiveVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "sb1", types.WithVersion(2)) + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify version was forwarded. + mock.mu.Lock() + assert.Equal(t, uint32(2), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() + + assert.Equal(t, uint32(2), result.Revision.Version) + assert.Equal(t, PolicyLoadStatusSuperseded, result.Revision.Status) + assert.Equal(t, uint32(3), result.ActiveVersion) +} + +func TestPolicyGetStatus_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.statusErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "missing") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyList(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + { + Version: 1, + PolicyHash: "sha256:v1", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + CreatedAtMs: 1700000000000, + }, + { + Version: 2, + PolicyHash: "sha256:v2", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + CreatedAtMs: 1700000001000, + LoadedAtMs: 1700000002000, + }, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.Len(t, revisions, 2) + + // Verify request was forwarded (no pagination options). + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastListReq.GetName()) + assert.Equal(t, uint32(0), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(0), mock.lastListReq.GetOffset()) + mock.mu.Unlock() + + assert.Equal(t, uint32(1), revisions[0].Version) + assert.Equal(t, "sha256:v1", revisions[0].PolicyHash) + assert.Equal(t, PolicyLoadStatusSuperseded, revisions[0].Status) + + assert.Equal(t, uint32(2), revisions[1].Version) + assert.Equal(t, "sha256:v2", revisions[1].PolicyHash) + assert.Equal(t, PolicyLoadStatusLoaded, revisions[1].Status) +} + +func TestPolicyList_WithPagination(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 3, PolicyHash: "sha256:v3"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "sb1", + types.WithLimit(10), + types.WithOffset(20), + ) + + require.NoError(t, err) + require.Len(t, revisions, 1) + + // Verify pagination options were forwarded. + mock.mu.Lock() + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(20), mock.lastListReq.GetOffset()) + mock.mu.Unlock() +} + +func TestPolicyList_Empty(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "sb1") + + require.NoError(t, err) + assert.Nil(t, revisions) +} + +func TestPolicyList_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.listErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "missing") + + assert.Nil(t, revisions) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyEditDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.editResp = &pb.EditDraftChunkResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + rule := &NetworkPolicyRule{ + Name: "allow-https", + Endpoints: []PolicyNetworkEndpoint{ + {Host: "example.com", Port: 443, Protocol: "tcp"}, + }, + } + + err := client.EditDraftChunk(context.Background(), "my-sandbox", "chunk-1", rule) + + require.NoError(t, err) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastEditReq.GetName()) + assert.Equal(t, "chunk-1", mock.lastEditReq.GetChunkId()) + require.NotNil(t, mock.lastEditReq.GetProposedRule()) + assert.Equal(t, "allow-https", mock.lastEditReq.GetProposedRule().GetName()) + require.Len(t, mock.lastEditReq.GetProposedRule().GetEndpoints(), 1) + assert.Equal(t, "example.com", mock.lastEditReq.GetProposedRule().GetEndpoints()[0].GetHost()) + mock.mu.Unlock() +} + +func TestPolicyEditDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.editErr = status.Errorf(codes.InvalidArgument, "invalid rule") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.EditDraftChunk(context.Background(), "sb1", "chunk-1", &NetworkPolicyRule{}) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestPolicyUndoDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.undoResp = &pb.UndoDraftChunkResponse{ + PolicyVersion: 10, + PolicyHash: "sha256:undo", + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.UndoDraftChunk(context.Background(), "my-sandbox", "chunk-3") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastUndoReq.GetName()) + assert.Equal(t, "chunk-3", mock.lastUndoReq.GetChunkId()) + mock.mu.Unlock() + + assert.Equal(t, uint32(10), result.PolicyVersion) + assert.Equal(t, "sha256:undo", result.PolicyHash) +} + +func TestPolicyUndoDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.undoErr = status.Errorf(codes.NotFound, "chunk not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.UndoDraftChunk(context.Background(), "sb1", "bad-chunk") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} diff --git a/sdk/go/openshell/v1/profile.go b/sdk/go/openshell/v1/profile.go new file mode 100644 index 0000000000..8f289be53e --- /dev/null +++ b/sdk/go/openshell/v1/profile.go @@ -0,0 +1,70 @@ +// 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" +) + +// ProviderProfile represents a provider type template. +type ProviderProfile = types.ProviderProfile + +// ProfileCredential defines a single credential required by a provider profile. +type ProfileCredential = types.ProfileCredential + +// ProfileCategory classifies a provider profile. +type ProfileCategory = types.ProfileCategory + +// NetworkEndpoint describes a network endpoint provided by a profile. +type NetworkEndpoint = types.NetworkEndpoint + +// NetworkBinary describes a binary artifact provided by a profile. +type NetworkBinary = types.NetworkBinary + +// ProfileDiscovery holds local discovery configuration for a profile. +type ProfileDiscovery = types.ProfileDiscovery + +// ProfileImportItem is an item submitted for profile import or lint validation. +type ProfileImportItem = types.ProfileImportItem + +// ProfileDiagnostic is a validation finding from Import, Update, or Lint. +type ProfileDiagnostic = types.ProfileDiagnostic + +// ImportResult holds the result of a profile import operation. +type ImportResult = types.ImportResult + +// UpdateResult holds the result of a profile update operation. +type UpdateResult = types.UpdateResult + +// LintResult holds the result of a profile lint operation. +type LintResult = types.LintResult + +// ProfileCategory values. +const ( + ProfileCategoryOther = types.ProfileCategoryOther + ProfileCategoryInference = types.ProfileCategoryInference + ProfileCategoryAgent = types.ProfileCategoryAgent + ProfileCategorySourceControl = types.ProfileCategorySourceControl + ProfileCategoryMessaging = types.ProfileCategoryMessaging + ProfileCategoryData = types.ProfileCategoryData + ProfileCategoryKnowledge = types.ProfileCategoryKnowledge +) + +// ProfileInterface defines operations for managing provider profiles. +type ProfileInterface interface { + // List returns all provider profiles. + List(ctx context.Context, opts ...ListOptions) ([]*ProviderProfile, error) + // Get retrieves a provider profile by ID. + Get(ctx context.Context, id string) (*ProviderProfile, error) + // Import submits profiles for import and returns the result with diagnostics. + Import(ctx context.Context, items []ProfileImportItem) (*ImportResult, error) + // Update replaces an existing profile identified by ID and expected resource version. + Update(ctx context.Context, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) + // Lint validates profiles without persisting them and returns diagnostics. + Lint(ctx context.Context, items []ProfileImportItem) (*LintResult, error) + // Delete removes a provider profile by ID. Returns true if deleted. + Delete(ctx context.Context, id string) (bool, error) +} diff --git a/sdk/go/openshell/v1/profile_client.go b/sdk/go/openshell/v1/profile_client.go new file mode 100644 index 0000000000..f53a50f841 --- /dev/null +++ b/sdk/go/openshell/v1/profile_client.go @@ -0,0 +1,147 @@ +// 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 profileClient struct { + client pb.OpenShellClient +} + +func newProfileClient(conn grpc.ClientConnInterface) *profileClient { + return &profileClient{client: pb.NewOpenShellClient(conn)} +} + +func (p *profileClient) List(ctx context.Context, opts ...ListOptions) ([]*ProviderProfile, error) { + req := &pb.ListProviderProfilesRequest{} + 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) + } + + resp, err := p.client.ListProviderProfiles(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + profiles := make([]*ProviderProfile, 0, len(resp.GetProfiles())) + for _, pp := range resp.GetProfiles() { + profiles = append(profiles, converter.ProviderProfileFromProto(pp)) + } + return profiles, nil +} + +func (p *profileClient) Get(ctx context.Context, id string) (*ProviderProfile, error) { + resp, err := p.client.GetProviderProfile(ctx, &pb.GetProviderProfileRequest{ + Id: id, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderProfileFromProto(resp.GetProfile()), nil +} + +func (p *profileClient) Import(ctx context.Context, items []ProfileImportItem) (*ImportResult, error) { + pbItems := make([]*pb.ProviderProfileImportItem, len(items)) + for i := range items { + pbItems[i] = converter.ProfileImportItemToProto(&items[i]) + } + + resp, err := p.client.ImportProviderProfiles(ctx, &pb.ImportProviderProfilesRequest{ + Profiles: pbItems, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &ImportResult{ + Imported: resp.GetImported(), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + for _, pp := range resp.GetProfiles() { + if profile := converter.ProviderProfileFromProto(pp); profile != nil { + result.Profiles = append(result.Profiles, *profile) + } + } + + return result, nil +} + +func (p *profileClient) Update(ctx context.Context, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) { + resp, err := p.client.UpdateProviderProfiles(ctx, &pb.UpdateProviderProfilesRequest{ + Id: id, + Profile: converter.ProfileImportItemToProto(&item), + ExpectedResourceVersion: expectedResourceVersion, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &UpdateResult{ + Updated: resp.GetUpdated(), + Profile: converter.ProviderProfileFromProto(resp.GetProfile()), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + return result, nil +} + +func (p *profileClient) Lint(ctx context.Context, items []ProfileImportItem) (*LintResult, error) { + pbItems := make([]*pb.ProviderProfileImportItem, len(items)) + for i := range items { + pbItems[i] = converter.ProfileImportItemToProto(&items[i]) + } + + resp, err := p.client.LintProviderProfiles(ctx, &pb.LintProviderProfilesRequest{ + Profiles: pbItems, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &LintResult{ + Valid: resp.GetValid(), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + return result, nil +} + +func (p *profileClient) Delete(ctx context.Context, id string) (bool, error) { + resp, err := p.client.DeleteProviderProfile(ctx, &pb.DeleteProviderProfileRequest{ + Id: id, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/profile_client_test.go b/sdk/go/openshell/v1/profile_client_test.go new file mode 100644 index 0000000000..efb5109b9b --- /dev/null +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -0,0 +1,570 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "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" +) + +// --- Mock server for provider profiles --- + +type mockProfileServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + profiles map[string]*pb.ProviderProfile // key: profile ID + + listErr error + getErr error + importErr error + updateErr error + lintErr error + deleteErr error + + lastListReq *pb.ListProviderProfilesRequest +} + +func newMockProfileServer() *mockProfileServer { + return &mockProfileServer{ + profiles: make(map[string]*pb.ProviderProfile), + } +} + +func (s *mockProfileServer) ListProviderProfiles(_ context.Context, req *pb.ListProviderProfilesRequest) (*pb.ListProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastListReq = req + if s.listErr != nil { + return nil, s.listErr + } + + var profiles []*pb.ProviderProfile + for _, p := range s.profiles { + profiles = append(profiles, p) + } + return &pb.ListProviderProfilesResponse{Profiles: profiles}, nil +} + +func (s *mockProfileServer) GetProviderProfile(_ context.Context, req *pb.GetProviderProfileRequest) (*pb.ProviderProfileResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + + p, ok := s.profiles[req.GetId()] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", req.GetId()) + } + return &pb.ProviderProfileResponse{Profile: p}, nil +} + +func (s *mockProfileServer) ImportProviderProfiles(_ context.Context, req *pb.ImportProviderProfilesRequest) (*pb.ImportProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.importErr != nil { + return nil, s.importErr + } + + var imported []*pb.ProviderProfile + for _, item := range req.GetProfiles() { + p := item.GetProfile() + if p != nil { + s.profiles[p.GetId()] = p + imported = append(imported, p) + } + } + return &pb.ImportProviderProfilesResponse{ + Profiles: imported, + Imported: len(imported) > 0, + }, nil +} + +func (s *mockProfileServer) UpdateProviderProfiles(_ context.Context, req *pb.UpdateProviderProfilesRequest) (*pb.UpdateProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.updateErr != nil { + return nil, s.updateErr + } + + id := req.GetId() + existing, ok := s.profiles[id] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", id) + } + + if req.GetExpectedResourceVersion() != existing.GetResourceVersion() { + return nil, status.Errorf(codes.FailedPrecondition, "resource version mismatch") + } + + p := req.GetProfile().GetProfile() + if p != nil { + p.ResourceVersion = existing.GetResourceVersion() + 1 + s.profiles[id] = p + } + + return &pb.UpdateProviderProfilesResponse{ + Profile: p, + Updated: true, + }, nil +} + +func (s *mockProfileServer) LintProviderProfiles(_ context.Context, req *pb.LintProviderProfilesRequest) (*pb.LintProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.lintErr != nil { + return nil, s.lintErr + } + + // Simple lint: valid if all profiles have an ID + var diagnostics []*pb.ProviderProfileDiagnostic + valid := true + for _, item := range req.GetProfiles() { + p := item.GetProfile() + if p != nil && p.GetId() == "" { + valid = false + diagnostics = append(diagnostics, &pb.ProviderProfileDiagnostic{ + Source: item.GetSource(), + Field: "id", + Message: "profile ID is required", + Severity: "error", + }) + } + } + return &pb.LintProviderProfilesResponse{ + Diagnostics: diagnostics, + Valid: valid, + }, nil +} + +func (s *mockProfileServer) DeleteProviderProfile(_ context.Context, req *pb.DeleteProviderProfileRequest) (*pb.DeleteProviderProfileResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + _, ok := s.profiles[req.GetId()] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", req.GetId()) + } + delete(s.profiles, req.GetId()) + return &pb.DeleteProviderProfileResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupProfileTest(t *testing.T, mock *mockProfileServer) (*profileClient, 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 newProfileClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// seedProfile adds a profile to the mock server store for testing. +func seedProfile(mock *mockProfileServer, id, displayName string, category pb.ProviderProfileCategory) { + mock.mu.Lock() + defer mock.mu.Unlock() + mock.profiles[id] = &pb.ProviderProfile{ + Id: id, + DisplayName: displayName, + Description: "Test profile " + id, + Category: category, + ResourceVersion: 1, + Credentials: []*pb.ProviderProfileCredential{ + {Name: "api-key", Description: "API Key", Required: true, Refresh: &pb.ProviderCredentialRefresh{}}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "localhost", Port: 8080, Protocol: "http"}, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/provider"}, + }, + } +} + +// --- List tests --- + +func TestProfileList(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + seedProfile(mock, "p2", "Profile Two", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Len(t, profiles, 2) +} + +func TestProfileList_Empty(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Empty(t, profiles) +} + +func TestProfileList_WithOptions(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background(), ListOptions{Limit: 10, Offset: 5}) + + require.NoError(t, err) + assert.Len(t, profiles, 1) + require.NotNil(t, mock.lastListReq) + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(5), mock.lastListReq.GetOffset()) +} + +func TestProfileList_Error(t *testing.T) { + mock := newMockProfileServer() + mock.listErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background()) + + assert.Nil(t, profiles) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Get tests --- + +func TestProfileGet(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "p1") + + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, "p1", profile.ID) + assert.Equal(t, "Profile One", profile.DisplayName) + assert.Equal(t, ProfileCategoryInference, profile.Category) + assert.Equal(t, uint64(1), profile.ResourceVersion) + // Verify credential deep copy + require.Len(t, profile.Credentials, 1) + assert.Equal(t, "api-key", profile.Credentials[0].Name) + assert.True(t, profile.Credentials[0].Required) + assert.True(t, profile.Credentials[0].Secret) // derived from Refresh != nil + // Verify endpoint deep copy + require.Len(t, profile.Endpoints, 1) + assert.Equal(t, "localhost", profile.Endpoints[0].Host) + assert.Equal(t, uint32(8080), profile.Endpoints[0].Port) + // Verify binary deep copy + require.Len(t, profile.Binaries, 1) + assert.Equal(t, "/usr/bin/provider", profile.Binaries[0].Path) +} + +func TestProfileGet_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "nonexistent") + + assert.Nil(t, profile) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileGet_Error(t *testing.T) { + mock := newMockProfileServer() + mock.getErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "p1") + + assert.Nil(t, profile) + require.Error(t, err) +} + +// --- Import tests --- + +func TestProfileImport(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ + ID: "p1", + DisplayName: "New Profile", + Category: ProfileCategoryInference, + }, + Source: "test.yaml", + }, + } + + result, err := client.Import(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Imported) + assert.Len(t, result.Profiles, 1) + assert.Equal(t, "p1", result.Profiles[0].ID) +} + +func TestProfileImport_MultipleItems(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "p1", DisplayName: "Profile 1"}, + Source: "a.yaml", + }, + { + Profile: ProviderProfile{ID: "p2", DisplayName: "Profile 2"}, + Source: "b.yaml", + }, + } + + result, err := client.Import(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Imported) + assert.Len(t, result.Profiles, 2) +} + +func TestProfileImport_Error(t *testing.T) { + mock := newMockProfileServer() + mock.importErr = status.Errorf(codes.InvalidArgument, "bad request") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + {Profile: ProviderProfile{ID: "p1"}, Source: "test.yaml"}, + } + + result, err := client.Import(context.Background(), items) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Update tests --- + +func TestProfileUpdate(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Original", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ + ID: "p1", + DisplayName: "Updated", + Category: ProfileCategoryAgent, + }, + Source: "update.yaml", + } + + result, err := client.Update(context.Background(), "p1", 1, item) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Updated) + require.NotNil(t, result.Profile) + assert.Equal(t, uint64(2), result.Profile.ResourceVersion) // bumped by mock +} + +func TestProfileUpdate_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "missing"}, + Source: "test.yaml", + } + + result, err := client.Update(context.Background(), "missing", 1, item) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileUpdate_VersionMismatch(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Original", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "p1"}, + Source: "test.yaml", + } + + // Use wrong version (99 instead of 1) + result, err := client.Update(context.Background(), "p1", 99, item) + + assert.Nil(t, result) + require.Error(t, err) +} + +func TestProfileUpdate_Error(t *testing.T) { + mock := newMockProfileServer() + mock.updateErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "p1"}, + Source: "test.yaml", + } + + result, err := client.Update(context.Background(), "p1", 1, item) + + assert.Nil(t, result) + require.Error(t, err) +} + +// --- Lint tests --- + +func TestProfileLint_Valid(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "p1", DisplayName: "Good Profile"}, + Source: "test.yaml", + }, + } + + result, err := client.Lint(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Valid) + assert.Empty(t, result.Diagnostics) +} + +func TestProfileLint_Invalid(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "", DisplayName: "Bad Profile"}, // empty ID triggers lint error + Source: "bad.yaml", + }, + } + + result, err := client.Lint(context.Background(), items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Valid) + require.Len(t, result.Diagnostics, 1) + assert.Equal(t, "id", result.Diagnostics[0].Field) + assert.Equal(t, "error", result.Diagnostics[0].Severity) +} + +func TestProfileLint_Error(t *testing.T) { + mock := newMockProfileServer() + mock.lintErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + {Profile: ProviderProfile{ID: "p1"}, Source: "test.yaml"}, + } + + result, err := client.Lint(context.Background(), items) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Delete tests --- + +func TestProfileDelete(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "p1") + + require.NoError(t, err) + assert.True(t, deleted) + + // Verify subsequent Get returns NotFound + profile, err := client.Get(context.Background(), "p1") + assert.Nil(t, profile) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileDelete_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "nonexistent") + + assert.False(t, deleted) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileDelete_Error(t *testing.T) { + mock := newMockProfileServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "p1") + + assert.False(t, deleted) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/provider.go b/sdk/go/openshell/v1/provider.go new file mode 100644 index 0000000000..49fd6ea22f --- /dev/null +++ b/sdk/go/openshell/v1/provider.go @@ -0,0 +1,29 @@ +// 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" +) + +// Provider represents an AI provider registration. +type Provider = types.Provider + +// ProviderSpec holds provider-specific configuration and credentials. +type ProviderSpec = types.ProviderSpec + +// ProviderInterface defines CRUD and Ensure operations on providers, +// plus sub-client accessors for profiles and credential refresh. +type ProviderInterface interface { + Create(ctx context.Context, provider *Provider) (*Provider, error) + Get(ctx context.Context, name string) (*Provider, error) + List(ctx context.Context, opts ...ListOptions) ([]*Provider, error) + Update(ctx context.Context, provider *Provider) (*Provider, error) + Delete(ctx context.Context, name string) error + Ensure(ctx context.Context, provider *Provider) (*Provider, error) + Profiles() ProfileInterface + Refresh() RefreshInterface +} diff --git a/sdk/go/openshell/v1/provider_client.go b/sdk/go/openshell/v1/provider_client.go new file mode 100644 index 0000000000..57e33f7f48 --- /dev/null +++ b/sdk/go/openshell/v1/provider_client.go @@ -0,0 +1,119 @@ +// 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 providerClient struct { + client pb.OpenShellClient + profiles *profileClient + refresh *refreshClient +} + +func newProviderClient(conn grpc.ClientConnInterface) *providerClient { + return &providerClient{ + client: pb.NewOpenShellClient(conn), + profiles: newProfileClient(conn), + refresh: newRefreshClient(conn), + } +} + +// Profiles returns a sub-client for provider profile operations. +func (p *providerClient) Profiles() ProfileInterface { + return p.profiles +} + +// Refresh returns a sub-client for credential refresh operations. +func (p *providerClient) Refresh() RefreshInterface { + return p.refresh +} + +func (p *providerClient) Create(ctx context.Context, provider *Provider) (*Provider, error) { + resp, err := p.client.CreateProvider(ctx, &pb.CreateProviderRequest{ + Provider: converter.ProviderToProto(provider), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) Get(ctx context.Context, name string) (*Provider, error) { + resp, err := p.client.GetProvider(ctx, &pb.GetProviderRequest{ + Name: name, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) List(ctx context.Context, opts ...ListOptions) ([]*Provider, error) { + req := &pb.ListProvidersRequest{} + if len(opts) > 0 { + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + } + + resp, err := p.client.ListProviders(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + providers := make([]*Provider, 0, len(resp.GetProviders())) + for _, proto := range resp.GetProviders() { + providers = append(providers, converter.ProviderFromProto(proto)) + } + return providers, nil +} + +func (p *providerClient) Update(ctx context.Context, provider *Provider) (*Provider, error) { + proto := converter.ProviderToProto(provider) + req := &pb.UpdateProviderRequest{ + Provider: proto, + } + if proto != nil { + req.CredentialExpiresAtMs = proto.CredentialExpiresAtMs + } + + resp, err := p.client.UpdateProvider(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) Delete(ctx context.Context, name string) error { + _, err := p.client.DeleteProvider(ctx, &pb.DeleteProviderRequest{ + Name: name, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *providerClient) Ensure(ctx context.Context, provider *Provider) (*Provider, error) { + if provider == nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "provider must not be nil"} + } + existing, err := p.Get(ctx, provider.Name) + if err != nil { + if !IsNotFound(err) { + return nil, err + } + return p.Create(ctx, provider) + } + + updated := *provider + updated.ID = existing.ID + updated.ResourceVersion = existing.ResourceVersion + return p.Update(ctx, &updated) +} diff --git a/sdk/go/openshell/v1/provider_client_test.go b/sdk/go/openshell/v1/provider_client_test.go new file mode 100644 index 0000000000..ea793673ff --- /dev/null +++ b/sdk/go/openshell/v1/provider_client_test.go @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + 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" +) + +type mockProviderServer struct { + pb.UnimplementedOpenShellServer + providers map[string]*dm.Provider + createErr error + getErr error + listErr error + updateErr error + deleteErr error +} + +func newMockProviderServer() *mockProviderServer { + return &mockProviderServer{ + providers: make(map[string]*dm.Provider), + } +} + +func (s *mockProviderServer) CreateProvider(_ context.Context, req *pb.CreateProviderRequest) (*pb.ProviderResponse, error) { + if s.createErr != nil { + return nil, s.createErr + } + p := req.GetProvider() + if p.GetMetadata() != nil { + s.providers[p.GetMetadata().GetName()] = p + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) GetProvider(_ context.Context, req *pb.GetProviderRequest) (*pb.ProviderResponse, error) { + if s.getErr != nil { + return nil, s.getErr + } + p, ok := s.providers[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "provider %q not found", req.GetName()) + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) ListProviders(_ context.Context, _ *pb.ListProvidersRequest) (*pb.ListProvidersResponse, error) { + if s.listErr != nil { + return nil, s.listErr + } + var list []*dm.Provider + for _, p := range s.providers { + list = append(list, p) + } + return &pb.ListProvidersResponse{Providers: list}, nil +} + +func (s *mockProviderServer) UpdateProvider(_ context.Context, req *pb.UpdateProviderRequest) (*pb.ProviderResponse, error) { + if s.updateErr != nil { + return nil, s.updateErr + } + p := req.GetProvider() + if p.GetMetadata() != nil { + name := p.GetMetadata().GetName() + if _, ok := s.providers[name]; !ok { + return nil, status.Errorf(codes.NotFound, "provider %q not found", name) + } + s.providers[name] = p + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) DeleteProvider(_ context.Context, req *pb.DeleteProviderRequest) (*pb.DeleteProviderResponse, error) { + if s.deleteErr != nil { + return nil, s.deleteErr + } + delete(s.providers, req.GetName()) + return &pb.DeleteProviderResponse{}, nil +} + +func setupProviderTest(t *testing.T, mock *mockProviderServer) (*providerClient, 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 newProviderClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestProviderCreate(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "my-claude", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + }, + } + + result, err := client.Create(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "my-claude", result.Name) + assert.Equal(t, "claude", result.Type) + assert.Nil(t, result.Spec.Credentials, "credentials are write-only and should not be returned") +} + +func TestProviderCreate_AlreadyExists(t *testing.T) { + mock := newMockProviderServer() + mock.createErr = status.Error(codes.AlreadyExists, "provider already exists") + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), &Provider{Name: "dup"}) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestProviderGet(t *testing.T) { + mock := newMockProviderServer() + mock.providers["existing"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Id: "p1", Name: "existing"}, + Type: "gitlab", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.Get(context.Background(), "existing") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) + assert.Equal(t, "gitlab", result.Type) +} + +func TestProviderGet_NotFound(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Get(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderList(t *testing.T) { + mock := newMockProviderServer() + mock.providers["p1"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "p1"}, + Type: "claude", + } + mock.providers["p2"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "p2"}, + Type: "gitlab", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestProviderList_Empty(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestProviderUpdate(t *testing.T) { + mock := newMockProviderServer() + mock.providers["updatable"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "updatable"}, + Type: "claude", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "updatable", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"API_KEY": "new-secret"}, + }, + } + + result, err := client.Update(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "updatable", result.Name) +} + +func TestProviderUpdate_NotFound(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Update(context.Background(), &Provider{Name: "missing"}) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderDelete(t *testing.T) { + mock := newMockProviderServer() + mock.providers["deleteme"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "deleteme"}, + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "deleteme") + + require.NoError(t, err) + assert.Empty(t, mock.providers["deleteme"]) +} + +func TestProviderDelete_NotFound(t *testing.T) { + mock := newMockProviderServer() + mock.deleteErr = status.Error(codes.NotFound, "not found") + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderEnsure_Creates(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "new-provider", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"KEY": "val"}, + }, + } + + result, err := client.Ensure(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "new-provider", result.Name) +} + +func TestProviderEnsure_Updates(t *testing.T) { + mock := newMockProviderServer() + mock.providers["existing"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "existing"}, + Type: "claude", + Config: map[string]string{"old": "config"}, + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "existing", + Type: "claude", + Spec: ProviderSpec{ + Config: map[string]string{"new": "config"}, + }, + } + + result, err := client.Ensure(context.Background(), p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) +} diff --git a/sdk/go/openshell/v1/refresh.go b/sdk/go/openshell/v1/refresh.go new file mode 100644 index 0000000000..ff2da766c0 --- /dev/null +++ b/sdk/go/openshell/v1/refresh.go @@ -0,0 +1,41 @@ +// 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" +) + +// RefreshStrategy describes how credentials are refreshed. +type RefreshStrategy = types.RefreshStrategy + +// RefreshStatus reports the current state of credential refresh for a provider credential. +type RefreshStatus = types.RefreshStatus + +// RefreshConfig holds configuration parameters for credential refresh. +type RefreshConfig = types.RefreshConfig + +// RefreshStrategy values. +const ( + RefreshStrategyStatic = types.RefreshStrategyStatic + RefreshStrategyExternal = types.RefreshStrategyExternal + RefreshStrategyOAuth2RefreshToken = types.RefreshStrategyOAuth2RefreshToken + RefreshStrategyOAuth2ClientCredentials = types.RefreshStrategyOAuth2ClientCredentials + RefreshStrategyGoogleServiceAccountJWT = types.RefreshStrategyGoogleServiceAccountJWT +) + +// RefreshInterface defines operations for managing provider credential refresh. +type RefreshInterface interface { + // GetStatus returns the refresh status for a provider's credential. + // If credentialKey is empty, statuses for all credentials are returned. + GetStatus(ctx context.Context, provider, credentialKey string) ([]*RefreshStatus, error) + // Configure sets up credential refresh for a provider credential. + Configure(ctx context.Context, config *RefreshConfig) (*RefreshStatus, error) + // Rotate triggers an immediate credential rotation. + Rotate(ctx context.Context, provider, credentialKey string) (*RefreshStatus, error) + // Delete removes credential refresh configuration. Returns true if deleted. + Delete(ctx context.Context, provider, credentialKey string) (bool, error) +} diff --git a/sdk/go/openshell/v1/refresh_client.go b/sdk/go/openshell/v1/refresh_client.go new file mode 100644 index 0000000000..89a5ffdd46 --- /dev/null +++ b/sdk/go/openshell/v1/refresh_client.go @@ -0,0 +1,67 @@ +// 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 refreshClient struct { + client pb.OpenShellClient +} + +func newRefreshClient(conn grpc.ClientConnInterface) *refreshClient { + return &refreshClient{client: pb.NewOpenShellClient(conn)} +} + +func (r *refreshClient) GetStatus(ctx context.Context, provider, credentialKey string) ([]*RefreshStatus, error) { + resp, err := r.client.GetProviderRefreshStatus(ctx, &pb.GetProviderRefreshStatusRequest{ + Provider: provider, + CredentialKey: credentialKey, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + statuses := make([]*RefreshStatus, 0, len(resp.GetCredentials())) + for _, s := range resp.GetCredentials() { + statuses = append(statuses, converter.RefreshStatusFromProto(s)) + } + return statuses, nil +} + +func (r *refreshClient) Configure(ctx context.Context, config *RefreshConfig) (*RefreshStatus, error) { + req := converter.RefreshConfigToProto(config) + resp, err := r.client.ConfigureProviderRefresh(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.RefreshStatusFromProto(resp.GetStatus()), nil +} + +func (r *refreshClient) Rotate(ctx context.Context, provider, credentialKey string) (*RefreshStatus, error) { + resp, err := r.client.RotateProviderCredential(ctx, &pb.RotateProviderCredentialRequest{ + Provider: provider, + CredentialKey: credentialKey, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.RefreshStatusFromProto(resp.GetStatus()), nil +} + +func (r *refreshClient) Delete(ctx context.Context, provider, credentialKey string) (bool, error) { + resp, err := r.client.DeleteProviderRefresh(ctx, &pb.DeleteProviderRefreshRequest{ + Provider: provider, + CredentialKey: credentialKey, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/refresh_client_test.go b/sdk/go/openshell/v1/refresh_client_test.go new file mode 100644 index 0000000000..a08759bf65 --- /dev/null +++ b/sdk/go/openshell/v1/refresh_client_test.go @@ -0,0 +1,426 @@ +// 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" + + 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" +) + +// --- Mock server for credential refresh --- + +type mockRefreshServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + statuses map[string]*pb.ProviderCredentialRefreshStatus // key: "provider/credentialKey" + getStatusErr error + configureErr error + rotateErr error + deleteErr error +} + +func newMockRefreshServer() *mockRefreshServer { + return &mockRefreshServer{ + statuses: make(map[string]*pb.ProviderCredentialRefreshStatus), + } +} + +func refreshKey(provider, credentialKey string) string { + return provider + "/" + credentialKey +} + +func (s *mockRefreshServer) GetProviderRefreshStatus(_ context.Context, req *pb.GetProviderRefreshStatusRequest) (*pb.GetProviderRefreshStatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getStatusErr != nil { + return nil, s.getStatusErr + } + + var creds []*pb.ProviderCredentialRefreshStatus + if req.GetCredentialKey() != "" { + // Return specific credential + st, ok := s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] + if ok { + creds = append(creds, st) + } + } else { + // Return all credentials for provider + for key, st := range s.statuses { + if len(key) > len(req.GetProvider()) && key[:len(req.GetProvider())+1] == req.GetProvider()+"/" { + creds = append(creds, st) + } + } + } + return &pb.GetProviderRefreshStatusResponse{Credentials: creds}, nil +} + +func (s *mockRefreshServer) ConfigureProviderRefresh(_ context.Context, req *pb.ConfigureProviderRefreshRequest) (*pb.ConfigureProviderRefreshResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.configureErr != nil { + return nil, s.configureErr + } + + st := &pb.ProviderCredentialRefreshStatus{ + ProviderName: req.GetProvider(), + ProviderId: "prov-id-" + req.GetProvider(), + CredentialKey: req.GetCredentialKey(), + Strategy: req.GetStrategy(), + Status: "active", + ExpiresAtMs: req.GetExpiresAtMs(), + } + s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] = st + return &pb.ConfigureProviderRefreshResponse{Status: st}, nil +} + +func (s *mockRefreshServer) RotateProviderCredential(_ context.Context, req *pb.RotateProviderCredentialRequest) (*pb.RotateProviderCredentialResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.rotateErr != nil { + return nil, s.rotateErr + } + + key := refreshKey(req.GetProvider(), req.GetCredentialKey()) + st, ok := s.statuses[key] + if !ok { + return nil, status.Errorf(codes.NotFound, "refresh config %q not found", key) + } + st.Status = "rotated" + st.LastRefreshAtMs = time.Now().UnixMilli() + return &pb.RotateProviderCredentialResponse{Status: st}, nil +} + +func (s *mockRefreshServer) DeleteProviderRefresh(_ context.Context, req *pb.DeleteProviderRefreshRequest) (*pb.DeleteProviderRefreshResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + key := refreshKey(req.GetProvider(), req.GetCredentialKey()) + _, ok := s.statuses[key] + if !ok { + return &pb.DeleteProviderRefreshResponse{Deleted: false}, nil + } + delete(s.statuses, key) + return &pb.DeleteProviderRefreshResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupRefreshTest(t *testing.T, mock *mockRefreshServer) (*refreshClient, 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 newRefreshClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- GetStatus tests --- + +func TestRefreshGetStatus(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure a credential first + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + Material: map[string]string{"refresh_token": "tok-123"}, + } + _, err := client.Configure(context.Background(), cfg) + require.NoError(t, err) + + // Get status for specific credential + statuses, err := client.GetStatus(context.Background(), "openai", "api-key") + + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, "openai", statuses[0].ProviderName) + assert.Equal(t, "api-key", statuses[0].CredentialKey) + assert.Equal(t, RefreshStrategyOAuth2RefreshToken, statuses[0].Strategy) + assert.Equal(t, "active", statuses[0].Status) +} + +func TestRefreshGetStatus_AllCredentials(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure two credentials + _, err := client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "key-1", + Strategy: RefreshStrategyStatic, + }) + require.NoError(t, err) + _, err = client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "key-2", + Strategy: RefreshStrategyExternal, + }) + require.NoError(t, err) + + // Get all statuses (empty credentialKey) + statuses, err := client.GetStatus(context.Background(), "openai", "") + + require.NoError(t, err) + assert.Len(t, statuses, 2) +} + +func TestRefreshGetStatus_Empty(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + statuses, err := client.GetStatus(context.Background(), "openai", "nonexistent") + + require.NoError(t, err) + assert.Empty(t, statuses) +} + +func TestRefreshGetStatus_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.getStatusErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + statuses, err := client.GetStatus(context.Background(), "openai", "key") + + assert.Nil(t, statuses) + require.Error(t, err) +} + +// --- Configure tests --- + +func TestRefreshConfigure(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + expires := time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC) + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{"client_id": "id-1", "client_secret": "sec-1"}, + SecretMaterialKeys: []string{"client_secret"}, + ExpiresAt: &expires, + } + + result, err := client.Configure(context.Background(), cfg) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "openai", result.ProviderName) + assert.Equal(t, "prov-id-openai", result.ProviderID) + assert.Equal(t, "api-key", result.CredentialKey) + assert.Equal(t, RefreshStrategyOAuth2ClientCredentials, result.Strategy) + assert.Equal(t, "active", result.Status) +} + +func TestRefreshConfigure_MinimalConfig(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + cfg := &RefreshConfig{ + Provider: "anthropic", + CredentialKey: "key", + Strategy: RefreshStrategyStatic, + } + + result, err := client.Configure(context.Background(), cfg) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "anthropic", result.ProviderName) + assert.Equal(t, RefreshStrategyStatic, result.Strategy) +} + +func TestRefreshConfigure_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.configureErr = status.Errorf(codes.InvalidArgument, "invalid config") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "key", + Strategy: RefreshStrategyStatic, + } + + result, err := client.Configure(context.Background(), cfg) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Rotate tests --- + +func TestRefreshRotate(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure first + _, err := client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + }) + require.NoError(t, err) + + // Rotate + result, err := client.Rotate(context.Background(), "openai", "api-key") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "rotated", result.Status) + assert.False(t, result.LastRefreshAt.IsZero()) +} + +func TestRefreshRotate_NotFound(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + result, err := client.Rotate(context.Background(), "openai", "nonexistent") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestRefreshRotate_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.rotateErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + result, err := client.Rotate(context.Background(), "openai", "key") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Delete tests --- + +func TestRefreshDelete(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure first + _, err := client.Configure(context.Background(), &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyStatic, + }) + require.NoError(t, err) + + // Delete + deleted, err := client.Delete(context.Background(), "openai", "api-key") + + require.NoError(t, err) + assert.True(t, deleted) + + // Verify it's gone + statuses, err := client.GetStatus(context.Background(), "openai", "api-key") + require.NoError(t, err) + assert.Empty(t, statuses) +} + +func TestRefreshDelete_NotConfigured(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "openai", "nonexistent") + + require.NoError(t, err) + assert.False(t, deleted) +} + +func TestRefreshDelete_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "openai", "key") + + assert.False(t, deleted) + require.Error(t, err) +} + +// --- Integration test: full lifecycle --- + +func TestRefreshLifecycle(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + ctx := context.Background() + + // 1. Configure + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + Material: map[string]string{"refresh_token": "tok-123"}, + } + st, err := client.Configure(ctx, cfg) + require.NoError(t, err) + assert.Equal(t, "active", st.Status) + + // 2. GetStatus + statuses, err := client.GetStatus(ctx, "openai", "api-key") + require.NoError(t, err) + require.Len(t, statuses, 1) + + // 3. Rotate + rotated, err := client.Rotate(ctx, "openai", "api-key") + require.NoError(t, err) + assert.Equal(t, "rotated", rotated.Status) + + // 4. Delete + deleted, err := client.Delete(ctx, "openai", "api-key") + require.NoError(t, err) + assert.True(t, deleted) + + // 5. Verify removed + statuses, err = client.GetStatus(ctx, "openai", "api-key") + require.NoError(t, err) + assert.Empty(t, statuses) +} diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go new file mode 100644 index 0000000000..060b3c72c3 --- /dev/null +++ b/sdk/go/openshell/v1/sandbox.go @@ -0,0 +1,73 @@ +// 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" +) + +// Sandbox represents a sandbox instance. +type Sandbox = types.Sandbox + +// SandboxSpec holds the desired state of a sandbox. +type SandboxSpec = types.SandboxSpec + +// SandboxTemplate defines the container template for a sandbox. +type SandboxTemplate = types.SandboxTemplate + +// SandboxStatus holds the observed state of a sandbox. +type SandboxStatus = types.SandboxStatus + +// SandboxCondition describes an observed condition of a sandbox. +type SandboxCondition = types.SandboxCondition + +// AttachProviderResult holds the result of attaching a provider to a sandbox. +type AttachProviderResult = types.AttachProviderResult + +// DetachProviderResult holds the result of detaching a provider from a sandbox. +type DetachProviderResult = types.DetachProviderResult + +// LogLine represents a single log entry from a sandbox. +type LogLine = types.LogLine + +// LogResult contains the result of a GetLogs call. +type LogResult = types.LogResult + +// LogOption configures a GetLogs call. +type LogOption = types.LogOption + +// WithLogLines sets the maximum number of log lines to return. +var WithLogLines = types.WithLogLines + +// WithLogSince filters logs to entries at or after the given time. +var WithLogSince = types.WithLogSince + +// WithLogSources filters logs by source (e.g., "gateway", "sandbox"). +var WithLogSources = types.WithLogSources + +// WithLogMinLevel sets the minimum log level to include. +var WithLogMinLevel = types.WithLogMinLevel + +// SandboxInterface defines lifecycle operations on sandboxes. +type SandboxInterface interface { + Create(ctx context.Context, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) + Get(ctx context.Context, name string) (*Sandbox, error) + List(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) + Delete(ctx context.Context, name string) error + AttachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) + DetachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) + ListProviders(ctx context.Context, sandboxName string) ([]*Provider, error) + WaitReady(ctx context.Context, name string, opts ...WaitOptions) (*Sandbox, error) + Watch(ctx context.Context, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) + // GetLogs retrieves log entries for a sandbox. The sandbox is resolved + // by name (an internal Get call translates name to ID). Use + // WithLogLines, WithLogSince, WithLogSources, and WithLogMinLevel to + // filter the results. + // + // Errors: NotFound if the sandbox does not exist; InvalidArgument if + // the sandbox name is empty; Unimplemented by the fake client. + GetLogs(ctx context.Context, sandboxName string, opts ...LogOption) (*LogResult, error) +} diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go new file mode 100644 index 0000000000..66f02b27fa --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +const defaultPollInterval = 500 * time.Millisecond + +type sandboxClient struct { + client pb.OpenShellClient +} + +func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient { + return &sandboxClient{client: pb.NewOpenShellClient(conn)} +} + +func (s *sandboxClient) Create(ctx context.Context, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) { + resp, err := s.client.CreateSandbox(ctx, &pb.CreateSandboxRequest{ + Name: name, + Spec: converter.SandboxSpecToProto(spec), + Labels: labels, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxFromProto(resp.GetSandbox()), nil +} + +func (s *sandboxClient) Get(ctx context.Context, name string) (*Sandbox, error) { + resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{ + Name: name, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxFromProto(resp.GetSandbox()), nil +} + +func (s *sandboxClient) List(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) { + req := &pb.ListSandboxesRequest{} + if len(opts) > 0 { + if opts[0].Limit > 0 { + req.Limit = uint32(opts[0].Limit) + } + if opts[0].Offset > 0 { + req.Offset = uint32(opts[0].Offset) + } + req.LabelSelector = opts[0].LabelSelector + } + + resp, err := s.client.ListSandboxes(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + sandboxes := make([]*Sandbox, 0, len(resp.GetSandboxes())) + for _, proto := range resp.GetSandboxes() { + sandboxes = append(sandboxes, converter.SandboxFromProto(proto)) + } + return sandboxes, nil +} + +func (s *sandboxClient) Delete(ctx context.Context, name string) error { + _, err := s.client.DeleteSandbox(ctx, &pb.DeleteSandboxRequest{ + Name: name, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (s *sandboxClient) AttachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) { + resp, err := s.client.AttachSandboxProvider(ctx, &pb.AttachSandboxProviderRequest{ + SandboxName: sandboxName, + ProviderName: providerName, + ExpectedResourceVersion: expectedResourceVersion, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return &AttachProviderResult{ + Sandbox: converter.SandboxFromProto(resp.GetSandbox()), + Attached: resp.GetAttached(), + }, nil +} + +func (s *sandboxClient) DetachProvider(ctx context.Context, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) { + resp, err := s.client.DetachSandboxProvider(ctx, &pb.DetachSandboxProviderRequest{ + SandboxName: sandboxName, + ProviderName: providerName, + ExpectedResourceVersion: expectedResourceVersion, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return &DetachProviderResult{ + Sandbox: converter.SandboxFromProto(resp.GetSandbox()), + Detached: resp.GetDetached(), + }, nil +} + +func (s *sandboxClient) ListProviders(ctx context.Context, sandboxName string) ([]*Provider, error) { + resp, err := s.client.ListSandboxProviders(ctx, &pb.ListSandboxProvidersRequest{ + SandboxName: sandboxName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + providers := make([]*Provider, 0, len(resp.GetProviders())) + for _, proto := range resp.GetProviders() { + providers = append(providers, converter.ProviderFromProto(proto)) + } + return providers, nil +} + +func (s *sandboxClient) WaitReady(ctx context.Context, name string, opts ...WaitOptions) (*Sandbox, error) { + interval := defaultPollInterval + if len(opts) > 0 && opts[0].PollInterval > 0 { + interval = opts[0].PollInterval + } + + sb, err := s.Get(ctx, name) + if err != nil { + return nil, err + } + + if sb.Status.Phase == SandboxReady { + return sb, nil + } + if sb.Status.Phase == SandboxError { + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + sb, err = s.Get(ctx, name) + if err != nil { + return nil, err + } + if sb.Status.Phase == SandboxReady { + return sb, nil + } + if sb.Status.Phase == SandboxError { + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + } + } + } +} + +func (s *sandboxClient) Watch(ctx context.Context, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) { + if name == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + + var watchOpts WatchOptions + if len(opts) > 0 { + watchOpts = opts[0] + } + + // Resolve sandbox name to ID — the proto RPC takes Id, not name. + sb, err := s.Get(ctx, name) + if err != nil { + return nil, err + } + + streamCtx, streamCancel := context.WithCancel(ctx) + stream, err := s.client.WatchSandbox(streamCtx, &pb.WatchSandboxRequest{ + Id: sb.ID, + FollowStatus: true, + StopOnTerminal: watchOpts.StopOnTerminal, + }) + if err != nil { + streamCancel() + return nil, converter.FromGRPCError(err) + } + + first, err := stream.Recv() + if err != nil { + streamCancel() + return nil, converter.FromGRPCError(err) + } + + ch := make(chan Event[*Sandbox], 64) + w := newWatcher(ch, streamCancel) + + go func() { + defer close(ch) + ev := first + for { + if sbPayload, ok := ev.Payload.(*pb.SandboxStreamEvent_Sandbox); ok && sbPayload.Sandbox != nil { + sandbox := converter.SandboxFromProto(sbPayload.Sandbox) + eventType := EventModified + if sandbox.Status.Phase == SandboxDeleting { + eventType = EventDeleted + } + select { + case ch <- Event[*Sandbox]{Type: eventType, Object: sandbox}: + case <-w.done: + return + } + // StopOnTerminal: close watcher after delivering a terminal phase event + if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxError) { + w.Stop() + return + } + } + var recvErr error + ev, recvErr = stream.Recv() + if recvErr != nil { + select { + case <-w.done: + default: + if recvErr != io.EOF { + select { + case ch <- Event[*Sandbox]{Type: EventError, Object: nil}: + default: + } + } + } + return + } + } + }() + + return w, nil +} + +func (s *sandboxClient) GetLogs(ctx context.Context, sandboxName string, opts ...LogOption) (*LogResult, error) { + // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. + sb, err := s.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + cfg := types.ApplyLogOptions(opts) + req := &pb.GetSandboxLogsRequest{ + SandboxId: sb.ID, + Lines: cfg.Lines(), + Sources: cfg.Sources(), + MinLevel: cfg.MinLevel(), + } + if !cfg.Since().IsZero() { + req.SinceMs = converter.MillisFromTime(cfg.Since()) + } + + resp, err := s.client.GetSandboxLogs(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.LogResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go new file mode 100644 index 0000000000..130e28d86b --- /dev/null +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -0,0 +1,971 @@ +// 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" +) + +type mockSandboxServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + sandboxes map[string]*pb.Sandbox + providers map[string][]*dm.Provider + createErr error + getErr error + listErr error + deleteErr error + attachErr error + detachErr error + listProvErr error + watchEvents []*pb.SandboxStreamEvent + watchErr error + watchKeepOpen chan struct{} // if non-nil, WatchSandbox blocks after sending events until closed + watchRequest *pb.WatchSandboxRequest // recorded request + + // GetLogs fields + getLogsResp *pb.GetSandboxLogsResponse + getLogsErr error + getLogsRequest *pb.GetSandboxLogsRequest // recorded request +} + +func newMockSandboxServer() *mockSandboxServer { + return &mockSandboxServer{ + sandboxes: make(map[string]*pb.Sandbox), + providers: make(map[string][]*dm.Provider), + } +} + +func (s *mockSandboxServer) CreateSandbox(_ context.Context, req *pb.CreateSandboxRequest) (*pb.SandboxResponse, error) { + if s.createErr != nil { + return nil, s.createErr + } + sb := &pb.Sandbox{ + Metadata: &dm.ObjectMeta{ + Id: "sb-" + req.GetName(), + Name: req.GetName(), + CreatedAtMs: 1700000000000, + Labels: req.GetLabels(), + ResourceVersion: 1, + }, + Spec: req.GetSpec(), + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + s.sandboxes[req.GetName()] = sb + return &pb.SandboxResponse{Sandbox: sb}, nil +} + +func (s *mockSandboxServer) GetSandbox(_ context.Context, req *pb.GetSandboxRequest) (*pb.SandboxResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + sb, ok := s.sandboxes[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + } + cloned := proto.Clone(sb).(*pb.Sandbox) + return &pb.SandboxResponse{Sandbox: cloned}, nil +} + +func (s *mockSandboxServer) setPhase(name string, phase pb.SandboxPhase) { + s.mu.Lock() + defer s.mu.Unlock() + if sb, ok := s.sandboxes[name]; ok { + sb.Status.Phase = phase + } +} + +func (s *mockSandboxServer) ListSandboxes(_ context.Context, _ *pb.ListSandboxesRequest) (*pb.ListSandboxesResponse, error) { + if s.listErr != nil { + return nil, s.listErr + } + var list []*pb.Sandbox + for _, sb := range s.sandboxes { + list = append(list, sb) + } + return &pb.ListSandboxesResponse{Sandboxes: list}, nil +} + +func (s *mockSandboxServer) DeleteSandbox(_ context.Context, req *pb.DeleteSandboxRequest) (*pb.DeleteSandboxResponse, error) { + if s.deleteErr != nil { + return nil, s.deleteErr + } + _, ok := s.sandboxes[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetName()) + } + delete(s.sandboxes, req.GetName()) + return &pb.DeleteSandboxResponse{Deleted: true}, nil +} + +func (s *mockSandboxServer) AttachSandboxProvider(_ context.Context, req *pb.AttachSandboxProviderRequest) (*pb.AttachSandboxProviderResponse, error) { + if s.attachErr != nil { + return nil, s.attachErr + } + sb, ok := s.sandboxes[req.GetSandboxName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetSandboxName()) + } + sb.Spec.Providers = append(sb.Spec.Providers, req.GetProviderName()) + return &pb.AttachSandboxProviderResponse{Sandbox: sb, Attached: true}, nil +} + +func (s *mockSandboxServer) DetachSandboxProvider(_ context.Context, req *pb.DetachSandboxProviderRequest) (*pb.DetachSandboxProviderResponse, error) { + if s.detachErr != nil { + return nil, s.detachErr + } + sb, ok := s.sandboxes[req.GetSandboxName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "sandbox %q not found", req.GetSandboxName()) + } + return &pb.DetachSandboxProviderResponse{Sandbox: sb, Detached: true}, nil +} + +func (s *mockSandboxServer) ListSandboxProviders(_ context.Context, req *pb.ListSandboxProvidersRequest) (*pb.ListSandboxProvidersResponse, error) { + if s.listProvErr != nil { + return nil, s.listProvErr + } + provs := s.providers[req.GetSandboxName()] + return &pb.ListSandboxProvidersResponse{Providers: provs}, nil +} + +func (s *mockSandboxServer) WatchSandbox(req *pb.WatchSandboxRequest, stream grpc.ServerStreamingServer[pb.SandboxStreamEvent]) error { + s.mu.Lock() + s.watchRequest = req + s.mu.Unlock() + if s.watchErr != nil { + return s.watchErr + } + s.mu.Lock() + events := make([]*pb.SandboxStreamEvent, len(s.watchEvents)) + copy(events, s.watchEvents) + keepOpen := s.watchKeepOpen + s.mu.Unlock() + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + // If watchKeepOpen is set, block until it is closed (simulates long-running stream) + if keepOpen != nil { + <-keepOpen + } + return nil +} + +func (s *mockSandboxServer) GetSandboxLogs(_ context.Context, req *pb.GetSandboxLogsRequest) (*pb.GetSandboxLogsResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.getLogsRequest = req + if s.getLogsErr != nil { + return nil, s.getLogsErr + } + if s.getLogsResp != nil { + return s.getLogsResp, nil + } + return &pb.GetSandboxLogsResponse{}, nil +} + +func setupSandboxTest(t *testing.T, mock *mockSandboxServer) (*sandboxClient, 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 newSandboxClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T029: Sandbox CRUD tests --- + +func TestSandboxCreate(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + spec := &SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"FOO": "bar"}, + Providers: []string{"claude"}, + } + labels := map[string]string{"env": "dev"} + + result, err := client.Create(context.Background(), "my-sandbox", spec, labels) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "my-sandbox", result.Name) + assert.Equal(t, "sb-my-sandbox", result.ID) + assert.Equal(t, map[string]string{"env": "dev"}, result.Labels) + assert.Equal(t, SandboxProvisioning, result.Status.Phase) +} + +func TestSandboxCreate_AlreadyExists(t *testing.T) { + mock := newMockSandboxServer() + mock.createErr = status.Error(codes.AlreadyExists, "sandbox already exists") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), "dup", &SandboxSpec{}, nil) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestSandboxGet(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["existing"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-1", Name: "existing", ResourceVersion: 5}, + Spec: &pb.SandboxSpec{LogLevel: "info"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.Get(context.Background(), "existing") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) + assert.Equal(t, "sb-1", result.ID) + assert.Equal(t, uint64(5), result.ResourceVersion) + assert.Equal(t, SandboxReady, result.Status.Phase) +} + +func TestSandboxGet_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Get(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxList(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.sandboxes["sb2"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb2"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestSandboxList_Empty(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background()) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestSandboxList_WithOptions(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background(), ListOptions{Limit: 10, Offset: 0}) + + require.NoError(t, err) + assert.Len(t, result, 1) +} + +func TestSandboxDelete(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["deleteme"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "deleteme"}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "deleteme") + + require.NoError(t, err) + assert.Empty(t, mock.sandboxes["deleteme"]) +} + +func TestSandboxDelete_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- T030: AttachProvider, DetachProvider, ListProviders tests --- + +func TestSandboxAttachProvider(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["my-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "my-sb", ResourceVersion: 2}, + Spec: &pb.SandboxSpec{Providers: []string{"existing-prov"}}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.AttachProvider(context.Background(), "my-sb", "new-prov", 2) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Attached) + require.NotNil(t, result.Sandbox) + assert.Equal(t, "my-sb", result.Sandbox.Name) +} + +func TestSandboxAttachProvider_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.AttachProvider(context.Background(), "missing", "prov", 1) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxAttachProvider_Error(t *testing.T) { + mock := newMockSandboxServer() + mock.attachErr = status.Error(codes.InvalidArgument, "bad version") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.AttachProvider(context.Background(), "sb", "prov", 99) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSandboxDetachProvider(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["my-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "my-sb", ResourceVersion: 3}, + Spec: &pb.SandboxSpec{Providers: []string{"prov-a", "prov-b"}}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.DetachProvider(context.Background(), "my-sb", "prov-a", 3) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Detached) + require.NotNil(t, result.Sandbox) + assert.Equal(t, "my-sb", result.Sandbox.Name) +} + +func TestSandboxDetachProvider_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.DetachProvider(context.Background(), "missing", "prov", 1) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxListProviders(t *testing.T) { + mock := newMockSandboxServer() + mock.providers["my-sb"] = []*dm.Provider{ + {Metadata: &dm.ObjectMeta{Name: "claude-prov"}, Type: "claude"}, + {Metadata: &dm.ObjectMeta{Name: "github-prov"}, Type: "github"}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.ListProviders(context.Background(), "my-sb") + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestSandboxListProviders_Empty(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.ListProviders(context.Background(), "empty-sb") + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestSandboxListProviders_Error(t *testing.T) { + mock := newMockSandboxServer() + mock.listProvErr = status.Error(codes.Unavailable, "service down") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.ListProviders(context.Background(), "sb") + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- T031: WaitReady tests --- + +func TestSandboxWaitReady_AlreadyReady(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["ready-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "ready-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.WaitReady(context.Background(), "ready-sb") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "ready-sb", result.Name) + assert.Equal(t, SandboxReady, result.Status.Phase) +} + +func TestSandboxWaitReady_BecomesReady(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["pending-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "pending-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + go func() { + time.Sleep(50 * time.Millisecond) + mock.setPhase("pending-sb", pb.SandboxPhase_SANDBOX_PHASE_READY) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, err := client.WaitReady(ctx, "pending-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, SandboxReady, result.Status.Phase) +} + +func TestSandboxWaitReady_ContextTimeout(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["stuck-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "stuck-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := client.WaitReady(ctx, "stuck-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.Error(t, err) +} + +func TestSandboxWaitReady_SandboxFailed(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["fail-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "fail-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_ERROR}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "fail-sb") + + require.Error(t, err) +} + +func TestSandboxWaitReady_NotFound(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- T039: Watch integration tests --- + +func TestSandboxWatch_ReceivesEvents(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev1 := <-w.ResultChan() + assert.Equal(t, EventModified, ev1.Type) + require.NotNil(t, ev1.Object) + assert.Equal(t, "sb-1", ev1.Object.Name) + assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) + + ev2 := <-w.ResultChan() + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, SandboxReady, ev2.Object.Status.Phase) +} + +func TestSandboxWatch_FiltersSandboxEventsOnly(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Log{Log: &pb.SandboxLogLine{Message: "some log"}}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + {Payload: &pb.SandboxStreamEvent_Warning{Warning: &pb.SandboxStreamWarning{Message: "warn"}}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev := <-w.ResultChan() + assert.Equal(t, EventModified, ev.Type) + assert.Equal(t, "sb-1", ev.Object.Name) + + // Stream ends after server sends all events; channel should close + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after stream ends") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } +} + +func TestSandboxWatch_StopCancelsStream(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + + <-w.ResultChan() + w.Stop() + + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should be closed after Stop") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close after Stop") + } +} + +func TestSandboxWatch_RPCError(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["watch-err"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-watch-err", Name: "watch-err"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchErr = status.Error(codes.Unavailable, "stream unavailable") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Watch(context.Background(), "watch-err") + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- T016: Watch name-to-ID resolution verification tests --- + +func TestSandboxWatch_ResolvesNameToID(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["my-sandbox"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "resolved-id-123", Name: "my-sandbox"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "my-sandbox", Id: "resolved-id-123"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "my-sandbox") + require.NoError(t, err) + defer w.Stop() + + // Verify the WatchSandboxRequest.Id contains the resolved ID, not the name + mock.mu.Lock() + req := mock.watchRequest + mock.mu.Unlock() + require.NotNil(t, req) + assert.Equal(t, "resolved-id-123", req.GetId(), "Watch should send resolved sandbox ID, not the name") +} + +func TestSandboxWatch_ResolutionError(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Watch(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err), "Watch should return NotFound when sandbox name cannot be resolved") +} + +func TestSandboxWatch_EmptySandboxName(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Watch(context.Background(), "") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "Watch should reject empty sandbox name") +} + +// --- T023/T024: StopOnTerminal watch tests --- + +func TestSandboxWatch_StopOnTerminal_Ready(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1", WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + defer w.Stop() + + // Should receive the Provisioning event + ev1 := <-w.ResultChan() + assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) + + // Should receive the Ready event (terminal) + ev2 := <-w.ResultChan() + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, SandboxReady, ev2.Object.Status.Phase) + + // Channel should close automatically after terminal event (stream is still open) + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after terminal Ready event") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close after terminal Ready event") + } +} + +func TestSandboxWatch_StopOnTerminal_Error(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + mock.watchKeepOpen = make(chan struct{}) + defer close(mock.watchKeepOpen) + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + }}}, + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_ERROR}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + w, err := client.Watch(context.Background(), "sb-1", WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + defer w.Stop() + + // Should receive the Provisioning event + ev1 := <-w.ResultChan() + assert.Equal(t, EventModified, ev1.Type) + assert.Equal(t, SandboxProvisioning, ev1.Object.Status.Phase) + + // Should receive the Error event (terminal) + ev2 := <-w.ResultChan() + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, SandboxError, ev2.Object.Status.Phase) + + // Channel should close automatically after terminal event (stream is still open) + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after terminal Error event") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close after terminal Error event") + } +} + +func TestSandboxWatch_StopOnTerminal_False_DoesNotClose(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["sb-1"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "id-1", Name: "sb-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.watchEvents = []*pb.SandboxStreamEvent{ + {Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "sb-1", Id: "id-1"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + }}}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + // Default: StopOnTerminal=false — watcher should NOT auto-close on Ready + w, err := client.Watch(context.Background(), "sb-1") + require.NoError(t, err) + defer w.Stop() + + ev := <-w.ResultChan() + assert.Equal(t, EventModified, ev.Type) + assert.Equal(t, SandboxReady, ev.Object.Status.Phase) + + // Channel closes because mock stream ends (not because of StopOnTerminal) + // This test verifies the existing behavior is preserved + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close after stream ends") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } +} + +// --- T027: GetLogs tests --- + +func TestSandboxGetLogs(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["log-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-123", Name: "log-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsResp = &pb.GetSandboxLogsResponse{ + Logs: []*pb.SandboxLogLine{ + {TimestampMs: 1700000000000, Level: "INFO", Target: "gateway", Message: "connected", Source: "gateway"}, + {TimestampMs: 1700000001000, Level: "DEBUG", Target: "sandbox", Message: "init done", Source: "sandbox"}, + }, + BufferTotal: 42, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.GetLogs(context.Background(), "log-sb") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Lines, 2) + assert.Equal(t, uint32(42), result.BufferTotal) + assert.Equal(t, "INFO", result.Lines[0].Level) + assert.Equal(t, "connected", result.Lines[0].Message) + assert.Equal(t, "gateway", result.Lines[0].Source) + assert.Equal(t, "DEBUG", result.Lines[1].Level) + assert.Equal(t, "init done", result.Lines[1].Message) + + // Verify name→id resolution: the proto request should contain the sandbox ID + mock.mu.Lock() + assert.Equal(t, "sb-id-123", mock.getLogsRequest.GetSandboxId()) + mock.mu.Unlock() +} + +func TestSandboxGetLogs_WithOptions(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["opts-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-opts", Name: "opts-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsResp = &pb.GetSandboxLogsResponse{ + Logs: []*pb.SandboxLogLine{{TimestampMs: 1700000000000, Level: "WARN", Message: "high cpu"}}, + BufferTotal: 100, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + since := time.Date(2023, 11, 14, 0, 0, 0, 0, time.UTC) + result, err := client.GetLogs(context.Background(), "opts-sb", + WithLogLines(50), + WithLogSince(since), + WithLogSources("gateway", "sandbox"), + WithLogMinLevel("WARN"), + ) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Lines, 1) + assert.Equal(t, "WARN", result.Lines[0].Level) + + // Verify all options were passed to the proto request + mock.mu.Lock() + req := mock.getLogsRequest + mock.mu.Unlock() + assert.Equal(t, "sb-id-opts", req.GetSandboxId()) + assert.Equal(t, uint32(50), req.GetLines()) + assert.Equal(t, since.UnixMilli(), req.GetSinceMs()) + assert.Equal(t, []string{"gateway", "sandbox"}, req.GetSources()) + assert.Equal(t, "WARN", req.GetMinLevel()) +} + +func TestSandboxGetLogs_SandboxNotFound(t *testing.T) { + mock := newMockSandboxServer() + // No sandbox registered — Get will return NotFound + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.GetLogs(context.Background(), "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSandboxGetLogs_RPCError(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["rpc-err-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-rpc", Name: "rpc-err-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsErr = status.Error(codes.Unavailable, "log service down") + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.GetLogs(context.Background(), "rpc-err-sb") + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +func TestSandboxGetLogs_EmptyResult(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["empty-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-empty", Name: "empty-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + mock.getLogsResp = &pb.GetSandboxLogsResponse{ + BufferTotal: 0, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + result, err := client.GetLogs(context.Background(), "empty-sb") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Empty(t, result.Lines) + assert.Equal(t, uint32(0), result.BufferTotal) +} + +func TestSandboxGetLogs_SinceZeroNotSent(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["zero-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Id: "sb-id-zero", Name: "zero-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + // Call without WithLogSince — SinceMs should be 0 (not set) + _, err := client.GetLogs(context.Background(), "zero-sb") + + require.NoError(t, err) + mock.mu.Lock() + assert.Equal(t, int64(0), mock.getLogsRequest.GetSinceMs()) + mock.mu.Unlock() +} diff --git a/sdk/go/openshell/v1/service.go b/sdk/go/openshell/v1/service.go new file mode 100644 index 0000000000..e31ce8497a --- /dev/null +++ b/sdk/go/openshell/v1/service.go @@ -0,0 +1,25 @@ +// 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" +) + +// ServiceEndpoint represents an exposed HTTP service endpoint within a sandbox. +type ServiceEndpoint = types.ServiceEndpoint + +// ServiceInterface defines operations for managing sandbox service endpoints. +type ServiceInterface interface { + // Expose creates a new service endpoint in the given sandbox. + Expose(ctx context.Context, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) + // Get retrieves a service endpoint by sandbox and service name. + Get(ctx context.Context, sandboxName, serviceName string) (*ServiceEndpoint, error) + // List returns all service endpoints for a sandbox. An empty sandboxName returns endpoints across all sandboxes. + List(ctx context.Context, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) + // Delete removes a service endpoint by sandbox and service name. + Delete(ctx context.Context, sandboxName, serviceName string) error +} diff --git a/sdk/go/openshell/v1/service_client.go b/sdk/go/openshell/v1/service_client.go new file mode 100644 index 0000000000..ed203cd931 --- /dev/null +++ b/sdk/go/openshell/v1/service_client.go @@ -0,0 +1,82 @@ +// 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 serviceClient struct { + client pb.OpenShellClient +} + +func newServiceClient(conn grpc.ClientConnInterface) *serviceClient { + return &serviceClient{client: pb.NewOpenShellClient(conn)} +} + +func (s *serviceClient) Expose(ctx context.Context, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) { + resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + TargetPort: targetPort, + Domain: domain, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ServiceEndpointFromProto(resp), nil +} + +func (s *serviceClient) Get(ctx context.Context, sandboxName, serviceName string) (*ServiceEndpoint, error) { + resp, err := s.client.GetService(ctx, &pb.GetServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ServiceEndpointFromProto(resp), nil +} + +func (s *serviceClient) List(ctx context.Context, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) { + req := &pb.ListServicesRequest{ + Sandbox: sandboxName, + } + 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) + } + + resp, err := s.client.ListServices(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + endpoints := make([]*ServiceEndpoint, 0, len(resp.GetServices())) + for _, svc := range resp.GetServices() { + endpoints = append(endpoints, converter.ServiceEndpointFromProto(svc)) + } + return endpoints, nil +} + +func (s *serviceClient) Delete(ctx context.Context, sandboxName, serviceName string) error { + _, err := s.client.DeleteService(ctx, &pb.DeleteServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} diff --git a/sdk/go/openshell/v1/service_client_test.go b/sdk/go/openshell/v1/service_client_test.go new file mode 100644 index 0000000000..4732880649 --- /dev/null +++ b/sdk/go/openshell/v1/service_client_test.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + 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" +) + +// --- Mock server for service endpoints --- + +type mockServiceServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + endpoints map[string]*pb.ServiceEndpointResponse // key: "sandbox/service" + exposeErr error + getErr error + listErr error + deleteErr error +} + +func newMockServiceServer() *mockServiceServer { + return &mockServiceServer{ + endpoints: make(map[string]*pb.ServiceEndpointResponse), + } +} + +func serviceKey(sandbox, service string) string { + return sandbox + "/" + service +} + +func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServiceRequest) (*pb.ServiceEndpointResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.exposeErr != nil { + return nil, s.exposeErr + } + + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: "ep-" + req.GetService(), + }, + SandboxName: req.GetSandbox(), + ServiceName: req.GetService(), + TargetPort: req.GetTargetPort(), + Domain: req.GetDomain(), + }, + } + if req.GetDomain() { + resp.Url = "https://" + req.GetService() + ".example.com" + } + + s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] = resp + return resp, nil +} + +func (s *mockServiceServer) GetService(_ context.Context, req *pb.GetServiceRequest) (*pb.ServiceEndpointResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + + ep, ok := s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] + if !ok { + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + } + return ep, nil +} + +func (s *mockServiceServer) ListServices(_ context.Context, req *pb.ListServicesRequest) (*pb.ListServicesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.listErr != nil { + return nil, s.listErr + } + + var services []*pb.ServiceEndpointResponse + for key, ep := range s.endpoints { + prefix := req.GetSandbox() + "/" + if req.GetSandbox() == "" || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) { + services = append(services, ep) + } + } + return &pb.ListServicesResponse{Services: services}, nil +} + +func (s *mockServiceServer) DeleteService(_ context.Context, req *pb.DeleteServiceRequest) (*pb.DeleteServiceResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + key := serviceKey(req.GetSandbox(), req.GetService()) + _, ok := s.endpoints[key] + if !ok { + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + } + delete(s.endpoints, key) + return &pb.DeleteServiceResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupServiceTest(t *testing.T, mock *mockServiceServer) (*serviceClient, 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 newServiceClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestServiceExpose(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + + require.NoError(t, err) + require.NotNil(t, ep) + assert.Equal(t, "ep-api", ep.ID) + assert.Equal(t, "web-app", ep.SandboxName) + assert.Equal(t, "api", ep.ServiceName) + assert.Equal(t, uint32(8080), ep.TargetPort) + assert.True(t, ep.Domain) + assert.Equal(t, "https://api.example.com", ep.URL) +} + +func TestServiceExpose_NoDomain(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "web-app", "api", 8080, false) + + require.NoError(t, err) + require.NotNil(t, ep) + assert.False(t, ep.Domain) + assert.Empty(t, ep.URL) +} + +func TestServiceExpose_Error(t *testing.T) { + mock := newMockServiceServer() + mock.exposeErr = status.Errorf(codes.NotFound, "sandbox not found") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "missing", "api", 8080, true) + + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceGet(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // First expose, then get + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + + ep, err := client.Get(context.Background(), "web-app", "api") + + require.NoError(t, err) + require.NotNil(t, ep) + assert.Equal(t, "api", ep.ServiceName) + assert.Equal(t, "web-app", ep.SandboxName) +} + +func TestServiceGet_NotFound(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Get(context.Background(), "web-app", "nonexistent") + + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceGet_Error(t *testing.T) { + mock := newMockServiceServer() + mock.getErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Get(context.Background(), "web-app", "api") + + assert.Nil(t, ep) + require.Error(t, err) +} + +func TestServiceList(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // Expose two services + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + _, err = client.Expose(context.Background(), "web-app", "web", 3000, false) + require.NoError(t, err) + + endpoints, err := client.List(context.Background(), "web-app") + + require.NoError(t, err) + assert.Len(t, endpoints, 2) +} + +func TestServiceList_Empty(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.List(context.Background(), "web-app") + + require.NoError(t, err) + assert.Empty(t, endpoints) +} + +func TestServiceList_WithOptions(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + + endpoints, err := client.List(context.Background(), "web-app", ListOptions{Limit: 10, Offset: 0}) + + require.NoError(t, err) + assert.Len(t, endpoints, 1) +} + +func TestServiceList_Error(t *testing.T) { + mock := newMockServiceServer() + mock.listErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.List(context.Background(), "web-app") + + assert.Nil(t, endpoints) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +func TestServiceDelete(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // Expose then delete + _, err := client.Expose(context.Background(), "web-app", "api", 8080, true) + require.NoError(t, err) + + err = client.Delete(context.Background(), "web-app", "api") + + require.NoError(t, err) + + // Verify subsequent Get returns NotFound + ep, err := client.Get(context.Background(), "web-app", "api") + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceDelete_NotFound(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "web-app", "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceDelete_Error(t *testing.T) { + mock := newMockServiceServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "web-app", "api") + + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/ssh.go b/sdk/go/openshell/v1/ssh.go new file mode 100644 index 0000000000..282068e57d --- /dev/null +++ b/sdk/go/openshell/v1/ssh.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SSHSession represents an SSH session created for a sandbox. +type SSHSession = types.SSHSession + +// tunnelConfig accumulates options for the Tunnel method. +type tunnelConfig struct { + serviceID string +} + +// TunnelOption configures an SSH tunnel opened via [SSHInterface.Tunnel]. +type TunnelOption func(*tunnelConfig) + +// WithTunnelServiceID sets an optional service identifier on the tunnel's +// init frame for audit and correlation purposes. +func WithTunnelServiceID(id string) TunnelOption { + return func(c *tunnelConfig) { + c.serviceID = id + } +} + +// SSHInterface defines operations for managing SSH sessions. +type SSHInterface interface { + // CreateSession creates a new SSH session for the given sandbox. + // The returned SSHSession contains connection details including the + // sensitive Token field that must not be logged. + // + // Note: CreateSession accepts a raw sandbox ID, not a name. + // For name-based access with automatic session lifecycle management, + // prefer [SSHInterface.Tunnel] which resolves sandbox names internally + // and revokes the session on Close. + CreateSession(ctx context.Context, sandboxID string) (*SSHSession, error) + // RevokeSession revokes an existing SSH session by its token. + // Returns true if the session was actively revoked, false if it was + // already expired or not found. + RevokeSession(ctx context.Context, token string) (bool, error) + // Tunnel opens a bidirectional SSH tunnel to the given port inside a + // sandbox. It combines CreateSession and ForwardTcp(SshRelayTarget) + // into a single call with automatic session cleanup on Close. + // + // The sandboxName is resolved to a sandbox ID internally. Port must + // be in the range 1-65535. + // + // Errors: InvalidArgument if port is out of range or sandboxName is + // empty; NotFound if the sandbox does not exist; Unimplemented by + // the fake client; Unavailable if the client is closed. + Tunnel(ctx context.Context, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) +} diff --git a/sdk/go/openshell/v1/ssh_client.go b/sdk/go/openshell/v1/ssh_client.go new file mode 100644 index 0000000000..5d52e02d3c --- /dev/null +++ b/sdk/go/openshell/v1/ssh_client.go @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type sshClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newSSHClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *sshClient { + return &sshClient{ + client: pb.NewOpenShellClient(conn), + sandboxes: sandboxes, + } +} + +func (s *sshClient) CreateSession(ctx context.Context, sandboxID string) (*SSHSession, error) { + resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sandboxID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SSHSessionFromProto(resp), nil +} + +func (s *sshClient) RevokeSession(ctx context.Context, token string) (bool, error) { + resp, err := s.client.RevokeSshSession(ctx, &pb.RevokeSshSessionRequest{ + Token: token, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetRevoked(), nil +} + +func (s *sshClient) Tunnel(ctx context.Context, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) { + if sandboxName == "" { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "sandbox name must not be empty", + } + } + if port == 0 || port > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", port), + } + } + + var cfg tunnelConfig + for _, o := range opts { + o(&cfg) + } + + sandbox, err := s.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + session, err := s.CreateSession(ctx, sandbox.ID) + if err != nil { + return nil, err + } + + revokeSession := true + defer func() { + if revokeSession { + _, _ = s.RevokeSession(context.Background(), session.Token) + } + }() + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := s.client.ForwardTcp(streamCtx) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + initFrame := &pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Init{ + Init: &pb.TcpForwardInit{ + SandboxId: sandbox.ID, + ServiceId: cfg.serviceID, + AuthorizationToken: session.Token, + Target: &pb.TcpForwardInit_Ssh{ + Ssh: &pb.SshRelayTarget{}, + }, + }, + }, + } + + if err := stream.Send(initFrame); err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + conn := &tcpForwardConn{ + stream: stream, + streamCtx: streamCtx, + cancel: cancel, + dataCh: make(chan []byte, 64), + done: make(chan struct{}), + } + go conn.readLoop() + + revokeSession = false + + t := &sshTunnel{ + tcpForwardConn: conn, + revokeFunc: func() { + _, _ = s.RevokeSession(context.Background(), session.Token) + }, + } + + // Auto-revoke the SSH session when the parent context is cancelled. + // The done channel closes after readLoop exits (stream fully drained), + // so Close() won't race with an active stream. + go func() { + <-conn.done + _ = t.Close() + }() + + return t, nil +} + +type sshTunnel struct { + *tcpForwardConn + revokeFunc func() + closeOnce sync.Once + closeErr error +} + +func (t *sshTunnel) Close() error { + t.closeOnce.Do(func() { + t.closeErr = t.tcpForwardConn.Close() + t.revokeFunc() + }) + return t.closeErr +} diff --git a/sdk/go/openshell/v1/ssh_client_test.go b/sdk/go/openshell/v1/ssh_client_test.go new file mode 100644 index 0000000000..e0219567c1 --- /dev/null +++ b/sdk/go/openshell/v1/ssh_client_test.go @@ -0,0 +1,609 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + 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" +) + +// --- Mock server for SSH sessions --- + +type mockSSHServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + sessions map[string]*pb.CreateSshSessionResponse // key: sandbox ID + tokens map[string]bool // track active tokens + revokeCount int // total revocation attempts + createErr error + revokeErr error + forwardErr error + nextToken string // override token for testing + lastInit *pb.TcpForwardInit +} + +func newMockSSHServer() *mockSSHServer { + return &mockSSHServer{ + sessions: make(map[string]*pb.CreateSshSessionResponse), + tokens: make(map[string]bool), + } +} + +func (s *mockSSHServer) CreateSshSession(_ context.Context, req *pb.CreateSshSessionRequest) (*pb.CreateSshSessionResponse, error) { //nolint:revive // proto-generated method name + s.mu.Lock() + defer s.mu.Unlock() + if s.createErr != nil { + return nil, s.createErr + } + + token := "tok-" + req.GetSandboxId() + if s.nextToken != "" { + token = s.nextToken + } + + resp := &pb.CreateSshSessionResponse{ + SandboxId: req.GetSandboxId(), + Token: token, + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + s.sessions[req.GetSandboxId()] = resp + s.tokens[token] = true + return resp, nil +} + +func (s *mockSSHServer) RevokeSshSession(_ context.Context, req *pb.RevokeSshSessionRequest) (*pb.RevokeSshSessionResponse, error) { //nolint:revive // proto-generated method name + s.mu.Lock() + defer s.mu.Unlock() + s.revokeCount++ + if s.revokeErr != nil { + return nil, s.revokeErr + } + + token := req.GetToken() + active, exists := s.tokens[token] + if exists && active { + s.tokens[token] = false + return &pb.RevokeSshSessionResponse{Revoked: true}, nil + } + // Already revoked or not found — not an error, just revoked=false. + return &pb.RevokeSshSessionResponse{Revoked: false}, nil +} + +func (s *mockSSHServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name + s.mu.Lock() + earlyErr := s.forwardErr + s.mu.Unlock() + if earlyErr != nil { + return earlyErr + } + + frame, err := stream.Recv() + if err != nil { + return err + } + init := frame.GetInit() + if init == nil { + return status.Errorf(codes.InvalidArgument, "first frame must be init") + } + + s.mu.Lock() + s.lastInit = init + s.mu.Unlock() + + for { + frame, err = stream.Recv() + if err != nil { + return err + } + data := frame.GetData() + if data == nil { + continue + } + if err := stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: data}, + }); err != nil { + return err + } + } +} + +// --- Mock sandbox resolver --- + +type mockSandboxResolver struct { + sandboxes map[string]*Sandbox + err error +} + +func (m *mockSandboxResolver) Create(_ context.Context, _ string, _ *SandboxSpec, _ map[string]string) (*Sandbox, error) { + return nil, nil +} + +func (m *mockSandboxResolver) Get(_ context.Context, name string) (*Sandbox, error) { + if m.err != nil { + return nil, m.err + } + sb, ok := m.sandboxes[name] + if !ok { + return nil, &StatusError{Code: ErrorNotFound, Message: "sandbox not found: " + name} + } + return sb, nil +} + +func (m *mockSandboxResolver) List(_ context.Context, _ ...ListOptions) ([]*Sandbox, error) { + return nil, nil +} +func (m *mockSandboxResolver) Delete(_ context.Context, _ string) error { return nil } +func (m *mockSandboxResolver) AttachProvider(_ context.Context, _, _ string, _ uint64) (*AttachProviderResult, error) { + return nil, nil +} +func (m *mockSandboxResolver) DetachProvider(_ context.Context, _, _ string, _ uint64) (*DetachProviderResult, error) { + return nil, nil +} +func (m *mockSandboxResolver) ListProviders(_ context.Context, _ string) ([]*Provider, error) { + return nil, nil +} +func (m *mockSandboxResolver) WaitReady(_ context.Context, _ string, _ ...WaitOptions) (*Sandbox, error) { + return nil, nil +} +func (m *mockSandboxResolver) Watch(_ context.Context, _ string, _ ...WatchOptions) (WatchInterface[*Sandbox], error) { + return nil, nil +} +func (m *mockSandboxResolver) GetLogs(_ context.Context, _ string, _ ...LogOption) (*LogResult, error) { + return nil, nil +} + +// --- Test setup --- + +func setupSSHTest(t *testing.T, mock *mockSSHServer) (*sshClient, func()) { + t.Helper() + return setupSSHTestWithSandboxes(t, mock, nil) +} + +func setupSSHTestWithSandboxes(t *testing.T, mock *mockSSHServer, sandboxes SandboxInterface) (*sshClient, 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 newSSHClient(conn, sandboxes), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestSSHCreateSession(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + session, err := client.CreateSession(context.Background(), "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "my-sandbox", session.SandboxID) + assert.Equal(t, "tok-my-sandbox", session.Token) + assert.Equal(t, "gw.example.com", session.GatewayHost) + assert.Equal(t, uint32(2222), session.GatewayPort) + assert.Equal(t, "https", session.GatewayScheme) + assert.Equal(t, "SHA256:abc123", session.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), session.ExpiresAtMs) +} + +func TestSSHCreateSession_Error(t *testing.T) { + mock := newMockSSHServer() + mock.createErr = status.Errorf(codes.NotFound, "sandbox not found") + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + session, err := client.CreateSession(context.Background(), "missing") + + assert.Nil(t, session) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSSHRevokeSession(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + // Create a session first. + session, err := client.CreateSession(context.Background(), "my-sandbox") + require.NoError(t, err) + + // Revoke it — should return true. + revoked, err := client.RevokeSession(context.Background(), session.Token) + + require.NoError(t, err) + assert.True(t, revoked) +} + +func TestSSHRevokeSession_AlreadyRevoked(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + // Create and revoke. + session, err := client.CreateSession(context.Background(), "my-sandbox") + require.NoError(t, err) + _, err = client.RevokeSession(context.Background(), session.Token) + require.NoError(t, err) + + // Revoke again — should return false (already revoked). + revoked, err := client.RevokeSession(context.Background(), session.Token) + + require.NoError(t, err) + assert.False(t, revoked) +} + +func TestSSHRevokeSession_Error(t *testing.T) { + mock := newMockSSHServer() + mock.revokeErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + revoked, err := client.RevokeSession(context.Background(), "some-token") + + assert.False(t, revoked) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +// --- Tunnel tests (T012) --- + +func defaultSandboxResolver() *mockSandboxResolver { + return &mockSandboxResolver{ + sandboxes: map[string]*Sandbox{ + "my-sandbox": {ID: "sb-123", Name: "my-sandbox"}, + }, + } +} + +func TestSSHTunnel_Success(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Round-trip to verify the stream works. + _, err = rwc.Write([]byte("hello")) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + + // Verify init frame sent to server. + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "sb-123", init.GetSandboxId()) + assert.NotEmpty(t, init.GetAuthorizationToken()) + assert.NotNil(t, init.GetSsh(), "target should be SshRelayTarget") +} + +func TestSSHTunnel_WithServiceID(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22, WithTunnelServiceID("audit-svc")) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "audit-svc", init.GetServiceId()) +} + +func TestSSHTunnel_InvalidPort(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwc, err := client.Tunnel(context.Background(), "my-sandbox", tt.port) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + }) + } +} + +func TestSSHTunnel_EmptySandboxName(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "", 22) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSSHTunnel_SandboxNotFound(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "nonexistent", 22) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSSHTunnel_SessionRevokedOnForwardFailure(t *testing.T) { + mock := newMockSSHServer() + mock.forwardErr = status.Errorf(codes.Internal, "forward failed") + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + + if err != nil { + assert.Nil(t, rwc) + } else { + require.NotNil(t, rwc) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + _ = rwc.Close() + } + + // Session should have been revoked since the forward failed. + mock.mu.Lock() + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + assert.True(t, tokenRevoked, "session token should be revoked after forward failure") +} + +func TestSSHTunnel_SessionRevokedOnClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + + // Close the tunnel, which should revoke the session. + err = rwc.Close() + require.NoError(t, err) + + mock.mu.Lock() + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + assert.True(t, tokenRevoked, "session token should be revoked after tunnel close") +} + +func TestSSHTunnel_DoubleClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + + err = rwc.Close() + require.NoError(t, err) + + // Second close should not panic or return a different error. + err = rwc.Close() + assert.NoError(t, err) +} + +func TestSSHTunnel_ContextCancellation(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) + + _ = rwc.Close() +} + +func TestSSHTunnel_TokenNotExposed(t *testing.T) { + mock := newMockSSHServer() + mock.nextToken = "secret-tunnel-token-xyz" + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + repr := fmt.Sprintf("%v", rwc) + assert.NotContains(t, repr, "secret-tunnel-token-xyz", + "token must not leak through the returned value's string representation") +} + +func TestSSHTunnel_ContextCancelRevokesSession(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + require.Eventually(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + for _, active := range mock.tokens { + if !active { + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond, "session token should be revoked after context cancel") +} + +func TestSSHTunnel_ContextCancelThenClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + // Wait for the cleanup goroutine to complete its revocation. + require.Eventually(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + for _, active := range mock.tokens { + if !active { + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond) + + // Explicit Close() after the cleanup goroutine already ran. + err = rwc.Close() + assert.NoError(t, err) + + mock.mu.Lock() + count := mock.revokeCount + mock.mu.Unlock() + assert.Equal(t, 1, count, "exactly one revocation should occur (closeOnce idempotency)") +} + +func TestSSHTunnel_CloseBeforeContextCancel(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + // Explicit Close() first (revokes the session). + err = rwc.Close() + require.NoError(t, err) + + // Cancel context after Close() already completed. + cancel() + + // Verify the cleanup goroutine does not trigger a second revocation. + require.Never(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + return mock.revokeCount > 1 + }, 200*time.Millisecond, 10*time.Millisecond, "cleanup goroutine should not revoke again") + + mock.mu.Lock() + count := mock.revokeCount + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + + assert.True(t, tokenRevoked, "session should be revoked") + assert.Equal(t, 1, count, "exactly one revocation should occur") +} diff --git a/sdk/go/openshell/v1/tcp.go b/sdk/go/openshell/v1/tcp.go new file mode 100644 index 0000000000..94a3c108e2 --- /dev/null +++ b/sdk/go/openshell/v1/tcp.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "net" +) + +// forwardConfig accumulates options for the Forward method. +type forwardConfig struct { + serviceID string +} + +// ForwardOption configures a TCP forward opened via [TCPInterface.Forward]. +type ForwardOption func(*forwardConfig) + +// WithForwardServiceID sets an optional service identifier on the forward's +// init frame for audit and correlation purposes. +func WithForwardServiceID(id string) ForwardOption { + return func(c *forwardConfig) { + c.serviceID = id + } +} + +// listenConfig accumulates options for the Listen method. +type listenConfig struct { + bindAddress string + useSSHTunnel bool + serviceID string +} + +// ListenOption configures a local listener opened via [TCPInterface.Listen]. +type ListenOption func(*listenConfig) + +// WithBindAddress overrides the default local bind address ("127.0.0.1"). +// Pass "0.0.0.0" to accept connections from any interface. +func WithBindAddress(addr string) ListenOption { + return func(c *listenConfig) { + c.bindAddress = addr + } +} + +// WithSSHTunnel routes each accepted connection through an SSH tunnel +// ([SSHInterface.Tunnel]) instead of the default TCP forward +// ([TCPInterface.Forward]). +func WithSSHTunnel() ListenOption { + return func(c *listenConfig) { + c.useSSHTunnel = true + } +} + +// WithListenServiceID sets an optional service identifier on each tunneled +// connection's init frame for audit and correlation purposes. +func WithListenServiceID(id string) ListenOption { + return func(c *listenConfig) { + c.serviceID = id + } +} + +// TCPInterface defines operations for TCP port forwarding to sandboxes. +// Methods accept a sandbox name and resolve it to an ID internally. +type TCPInterface interface { + // Forward opens a bidirectional TCP connection to the given port inside a + // sandbox. The sandbox is identified by name; the SDK resolves it to an + // ID internally. The returned io.ReadWriteCloser wraps the underlying + // gRPC stream; closing it terminates the stream. Port must be in the + // range 1-65535; out-of-range values are rejected client-side with an + // InvalidArgument error before opening the gRPC stream. + // + // The connection respects context cancellation: if ctx is cancelled, + // the stream is closed and pending Read/Write calls return a context error. + Forward(ctx context.Context, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error) + + // Listen binds a local TCP port and tunnels every accepted connection to + // the given port inside a sandbox, returning a standard [net.Listener]. + // Each call to Accept on the returned listener establishes a new tunnel + // to the sandbox port, bridging data bidirectionally. + // + // The sandbox is identified by name; the SDK resolves it to an ID + // internally. remotePort must be in the range 1-65535; localPort must be + // in the range 0-65535, where 0 lets the OS assign an ephemeral port + // (discoverable via Addr). + // + // Closing the listener stops accepting new connections, tears down all + // active tunnels, and blocks until all bridge goroutines finish. + // Cancelling ctx triggers the same shutdown behavior. + // + // Errors: + // - InvalidArgument: sandboxName is empty, remotePort is 0 or > 65535, + // or localPort is > 65535 + // - Unimplemented: returned by the fake client + // - Unavailable: client is closed + Listen(ctx context.Context, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error) +} diff --git a/sdk/go/openshell/v1/tcp_client.go b/sdk/go/openshell/v1/tcp_client.go new file mode 100644 index 0000000000..4d47a08681 --- /dev/null +++ b/sdk/go/openshell/v1/tcp_client.go @@ -0,0 +1,376 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type tcpClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface + ssh SSHInterface +} + +func newTCPClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface, ssh SSHInterface) *tcpClient { + return &tcpClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes, ssh: ssh} +} + +func (t *tcpClient) Forward(ctx context.Context, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", port), + } + } + + // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. + sb, err := t.sandboxes.Get(ctx, sandboxName) + if err != nil { + return nil, err + } + + var cfg forwardConfig + for _, o := range opts { + o(&cfg) + } + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := t.client.ForwardTcp(streamCtx) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + initFrame := &pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Init{ + Init: &pb.TcpForwardInit{ + SandboxId: sb.ID, + ServiceId: cfg.serviceID, + Target: &pb.TcpForwardInit_Tcp{ + Tcp: &pb.TcpRelayTarget{ + Host: "127.0.0.1", + Port: port, + }, + }, + }, + }, + } + + if err := stream.Send(initFrame); err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + conn := &tcpForwardConn{ + stream: stream, + streamCtx: streamCtx, + cancel: cancel, + dataCh: make(chan []byte, 64), + done: make(chan struct{}), + } + go conn.readLoop() + return conn, nil +} + +func (t *tcpClient) Listen(ctx context.Context, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort), + } + } + if localPort > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort), + } + } + + cfg := listenConfig{bindAddress: "127.0.0.1"} + for _, o := range opts { + o(&cfg) + } + + if cfg.useSSHTunnel && t.ssh == nil { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "WithSSHTunnel requires an SSH client, but none is available", + } + } + + addr := net.JoinHostPort(cfg.bindAddress, strconv.FormatUint(uint64(localPort), 10)) + inner, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", addr, err) + } + + listenCtx, cancel := context.WithCancel(ctx) + tl := &tunnelListener{ + inner: inner, + ctx: listenCtx, + cancel: cancel, + tcp: t, + ssh: t.ssh, + sandboxName: sandboxName, + remotePort: remotePort, + cfg: cfg, + } + + // Context-watcher: if the parent context is cancelled, close the listener. + go func() { + <-listenCtx.Done() + _ = tl.Close() + }() + + return tl, nil +} + +// tunnelListener implements net.Listener. It accepts local TCP connections +// and bridges each one to a sandbox port via Forward (or Tunnel in SSH mode). +type tunnelListener struct { + inner net.Listener + ctx context.Context + cancel context.CancelFunc + tcp *tcpClient + ssh SSHInterface + sandboxName string + remotePort uint32 + cfg listenConfig + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +// Accept waits for and returns the next tunneled connection. Each accepted +// local connection triggers a Forward (or SSH Tunnel) to the sandbox port. +// If tunnel setup fails, the local connection is closed and Accept retries. +// Accept returns an error when the listener is closed. +func (tl *tunnelListener) Accept() (net.Conn, error) { + for { + conn, err := tl.inner.Accept() + if err != nil { + return nil, err + } + + // Establish the tunnel to the sandbox. + var tunnel io.ReadWriteCloser + if tl.cfg.useSSHTunnel && tl.ssh != nil { + var tunnelOpts []TunnelOption + if tl.cfg.serviceID != "" { + tunnelOpts = append(tunnelOpts, WithTunnelServiceID(tl.cfg.serviceID)) + } + tunnel, err = tl.ssh.Tunnel(tl.ctx, tl.sandboxName, tl.remotePort, tunnelOpts...) + } else { + var fwdOpts []ForwardOption + if tl.cfg.serviceID != "" { + fwdOpts = append(fwdOpts, WithForwardServiceID(tl.cfg.serviceID)) + } + tunnel, err = tl.tcp.Forward(tl.ctx, tl.sandboxName, tl.remotePort, fwdOpts...) + } + + if err != nil { + _ = conn.Close() + select { + case <-tl.ctx.Done(): + return nil, tl.ctx.Err() + default: + continue + } + } + + // Wrap the local conn + tunnel into a bridged connection and + // spawn goroutines that copy data bidirectionally. + bc := &bridgedConn{ + Conn: conn, + tunnel: tunnel, + } + + tl.mu.Lock() + if tl.closing { + tl.mu.Unlock() + _ = bc.Close() + return nil, net.ErrClosed + } + tl.wg.Add(1) + tl.mu.Unlock() + go tl.bridge(bc) + + return bc, nil + } +} + +// bridge copies data bidirectionally between the local connection and the +// tunnel. It runs in its own goroutine and decrements the WaitGroup on exit. +func (tl *tunnelListener) bridge(bc *bridgedConn) { + defer tl.wg.Done() + + done := make(chan struct{}, 2) + + // Local → tunnel + go func() { + _, _ = io.Copy(bc.tunnel, bc.Conn) + done <- struct{}{} + }() + + // Tunnel → local + go func() { + _, _ = io.Copy(bc.Conn, bc.tunnel) + done <- struct{}{} + }() + + <-done + _ = bc.Close() + <-done +} + +// Close stops the listener from accepting new connections, cancels all +// active tunnels, and blocks until all bridge goroutines finish. +func (tl *tunnelListener) Close() error { + tl.closeOnce.Do(func() { + tl.mu.Lock() + tl.closing = true + tl.mu.Unlock() + tl.closeErr = tl.inner.Close() + tl.cancel() + tl.wg.Wait() + }) + return tl.closeErr +} + +// Addr returns the listener's network address (the bound local address). +func (tl *tunnelListener) Addr() net.Addr { + return tl.inner.Addr() +} + +// bridgedConn wraps a local net.Conn and its associated tunnel. Closing +// the bridgedConn closes both the local connection and the tunnel. +type bridgedConn struct { + net.Conn + tunnel io.ReadWriteCloser + closeOnce sync.Once + closeErr error +} + +// Close closes both the tunnel and the underlying local connection. +// Safe to call concurrently from bridge teardown and consumer code. +func (bc *bridgedConn) Close() error { + bc.closeOnce.Do(func() { + tErr := bc.tunnel.Close() + cErr := bc.Conn.Close() + if tErr != nil { + bc.closeErr = tErr + } else { + bc.closeErr = cErr + } + }) + return bc.closeErr +} + +// tcpForwardConn wraps a bidirectional TcpForwardFrame stream into an +// io.ReadWriteCloser. A background goroutine owns the Recv loop and routes +// data frames to dataCh. Read and Write may be called from different +// goroutines, but multiple concurrent Read callers are not supported. +type tcpForwardConn struct { + stream grpc.BidiStreamingClient[pb.TcpForwardFrame, pb.TcpForwardFrame] + streamCtx context.Context + cancel context.CancelFunc + sendMu sync.Mutex + dataCh chan []byte + done chan struct{} + errOnce sync.Once + err error + buf []byte +} + +func (c *tcpForwardConn) setErr(err error) { + c.errOnce.Do(func() { c.err = err }) +} + +func (c *tcpForwardConn) readLoop() { + defer close(c.dataCh) + defer close(c.done) + for { + frame, err := c.stream.Recv() + if err != nil { + if err != io.EOF { + c.setErr(converter.FromGRPCError(err)) + } + return + } + data := frame.GetData() + if data == nil { + continue + } + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + select { + case c.dataCh <- dataCopy: + case <-c.streamCtx.Done(): + return + } + } +} + +func (c *tcpForwardConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if len(c.buf) > 0 { + n := copy(p, c.buf) + c.buf = c.buf[n:] + return n, nil + } + + data, ok := <-c.dataCh + if !ok { + if c.err != nil { + return 0, c.err + } + return 0, io.EOF + } + n := copy(p, data) + if n < len(data) { + c.buf = append(c.buf, data[n:]...) + } + return n, nil +} + +func (c *tcpForwardConn) Write(p []byte) (int, error) { + c.sendMu.Lock() + defer c.sendMu.Unlock() + err := c.stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: p}, + }) + if err != nil { + return 0, converter.FromGRPCError(err) + } + return len(p), nil +} + +func (c *tcpForwardConn) Close() error { + c.sendMu.Lock() + err := c.stream.CloseSend() + c.sendMu.Unlock() + c.cancel() + <-c.done + return err +} diff --git a/sdk/go/openshell/v1/tcp_client_test.go b/sdk/go/openshell/v1/tcp_client_test.go new file mode 100644 index 0000000000..30d5248574 --- /dev/null +++ b/sdk/go/openshell/v1/tcp_client_test.go @@ -0,0 +1,1286 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "testing" + "time" + + 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" +) + +// --- Mock server for TCP forwarding --- + +// mockTCPServer implements the ForwardTcp bidi stream. It records the init +// frame and echoes every data frame back to the client. +type mockTCPServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + lastInit *pb.TcpForwardInit + err error // if non-nil, return this error immediately on stream open +} + +func newMockTCPServer() *mockTCPServer { + return &mockTCPServer{} +} + +func (s *mockTCPServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name + s.mu.Lock() + earlyErr := s.err + s.mu.Unlock() + if earlyErr != nil { + return earlyErr + } + + // First frame must be init. + frame, err := stream.Recv() + if err != nil { + return err + } + init := frame.GetInit() + if init == nil { + return status.Errorf(codes.InvalidArgument, "first frame must be init") + } + + s.mu.Lock() + s.lastInit = init + s.mu.Unlock() + + // Echo loop: every data frame is sent back verbatim. + for { + frame, err = stream.Recv() + if err != nil { + return err + } + data := frame.GetData() + if data == nil { + continue + } + if err := stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: data}, + }); err != nil { + return err + } + } +} + +// --- Test setup --- + +func setupTCPTest(t *testing.T, mock *mockTCPServer) (*tcpClient, 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 newTCPClient(conn, &stubSandboxResolver{}, nil), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestTCPForward_InitFrame(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Write something to trigger the init frame to be sent (init is sent + // on Forward, before any Write — but we need a brief moment for the + // server to process it). + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + + // Read back the echo. + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Verify the init frame the server received. + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) + assert.Empty(t, init.GetServiceId(), "service_id should be empty per FR-007a") + assert.Empty(t, init.GetAuthorizationToken()) + + tcp := init.GetTcp() + require.NotNil(t, tcp, "target should be TcpRelayTarget") + assert.Equal(t, "127.0.0.1", tcp.GetHost()) + assert.Equal(t, uint32(8080), tcp.GetPort()) +} + +func TestTCPForward_ReadWrite(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "test-sandbox", 3000) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + // Write data and read the echo back. + payload := []byte("hello, sandbox!") + _, err = rwc.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + // Second round-trip. + _, err = rwc.Write([]byte("round2")) + require.NoError(t, err) + + n, err = rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "round2", string(buf[:n])) +} + +func TestTCPForward_Close(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 5432) + require.NoError(t, err) + + err = rwc.Close() + require.NoError(t, err) + + // Subsequent writes should fail. + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) + + // Subsequent reads should also fail. + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) +} + +func TestTCPForward_PartialRead(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + // Write a payload larger than the read buffer. + payload := []byte("abcdefghijklmnopqrstuvwxyz") + _, err = rwc.Write(payload) + require.NoError(t, err) + + // Read with a small buffer — should get partial data and buffer the rest. + var collected []byte + buf := make([]byte, 10) + for len(collected) < len(payload) { + n, readErr := rwc.Read(buf) + require.NoError(t, readErr) + collected = append(collected, buf[:n]...) + } + assert.Equal(t, payload, collected) +} + +func TestTCPForward_ConcurrentReadWrite(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + const iterations = 50 + var wg sync.WaitGroup + errCh := make(chan error, 2) + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + _, writeErr := rwc.Write([]byte("ping")) + if writeErr != nil { + errCh <- writeErr + return + } + } + }() + + go func() { + defer wg.Done() + buf := make([]byte, 64) + for range iterations { + _, readErr := rwc.Read(buf) + if readErr != nil { + errCh <- readErr + return + } + } + }() + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent goroutine failed: %v", err) + } +} + +func TestTCPForward_PortValidation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + {"port way too high", 100000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwc, err := client.Forward(context.Background(), "my-sandbox", tt.port) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + }) + } + + // Valid boundary ports should not get client-side rejection. + for _, port := range []uint32{1, 65535} { + rwc, err := client.Forward(context.Background(), "my-sandbox", port) + require.NoError(t, err, "port %d should be valid", port) + require.NotNil(t, rwc) + _ = rwc.Close() + } +} + +func TestTCPForward_ContextCancellation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Forward(ctx, "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + + // Cancel the context. + cancel() + + // Reads should return an error (context cancelled propagates through the gRPC stream). + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + + // Writes should also fail after context cancellation. + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) +} + +func TestTCPForward_WithServiceID(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080, WithForwardServiceID("audit-svc")) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Trigger a round-trip so the server has processed the init frame. + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "audit-svc", init.GetServiceId()) + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) +} + +func TestTCPForward_WithoutOptions_BackwardCompat(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Empty(t, init.GetServiceId(), "service_id should be empty when no option provided") +} + +func TestTCPForward_ServerError(t *testing.T) { + mock := newMockTCPServer() + mock.err = status.Errorf(codes.Unavailable, "server unavailable") + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + + // The stream opens successfully (gRPC bidi streams don't fail on open), + // but the first write or read should surface the server error. + if err != nil { + // When Send(initFrame) races with the server returning the error, + // the client may get the server status or a transport-level error. + assert.Nil(t, rwc) + require.Error(t, err) + return + } + + // If stream opened, the error surfaces on Read (the server returns it + // immediately, which closes the recv side). + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) +} + +// --- Name-to-ID resolution tests --- + +func TestTCPForward_ResolvesNameToID(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Trigger a round-trip so the server has processed the init frame. + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId(), "Forward should send resolved sandbox ID, not the name") +} + +func TestTCPForward_ResolutionError(t *testing.T) { + mock := newMockTCPServer() + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newTCPClient(conn, resolver, nil) + + rwc, err := client.Forward(context.Background(), "nonexistent", 8080) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestTCPForward_EmptySandboxName(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "", 8080) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Listen tests --- + +func TestTCPListen_ReturnsValidListener(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Addr should return a non-nil TCP address with a non-zero port. + addr := ln.Addr() + require.NotNil(t, addr) + tcpAddr, ok := addr.(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr, got %T", addr) + assert.NotZero(t, tcpAddr.Port, "OS-assigned port should be non-zero") +} + +func TestTCPListen_ConcurrentConnections(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + const numConns = 10 + var wg sync.WaitGroup + errCh := make(chan error, numConns*2) // space for accept + dial errors + + // Accept goroutine: accepts all connections. + wg.Add(1) + go func() { + defer wg.Done() + for range numConns { + _, acceptErr := ln.Accept() + if acceptErr != nil { + errCh <- fmt.Errorf("accept: %w", acceptErr) + return + } + } + }() + + // Dial numConns goroutines, each independently writes and reads. + for i := range numConns { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, dialErr := net.Dial("tcp", ln.Addr().String()) + if dialErr != nil { + errCh <- fmt.Errorf("dial %d: %w", idx, dialErr) + return + } + defer func() { _ = conn.Close() }() + + payload := []byte(fmt.Sprintf("msg-%d", idx)) + _, writeErr := conn.Write(payload) + if writeErr != nil { + errCh <- fmt.Errorf("write %d: %w", idx, writeErr) + return + } + + buf := make([]byte, 256) + n, readErr := conn.Read(buf) + if readErr != nil { + errCh <- fmt.Errorf("read %d: %w", idx, readErr) + return + } + + if string(buf[:n]) != string(payload) { + errCh <- fmt.Errorf("conn %d: expected %q, got %q", idx, payload, buf[:n]) + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Errorf("concurrent connection error: %v", err) + } +} + +func TestTCPListen_EphemeralPort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + // localPort=0 → OS assigns an ephemeral port. + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Addr() should expose the assigned port. + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok) + assert.NotZero(t, tcpAddr.Port, "OS-assigned port should be non-zero") + + // Verify a connection through the ephemeral port actually works. + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + _, _ = ln.Accept() + }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + payload := []byte("ephemeral-test") + _, err = conn.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 256) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) +} + +func TestTCPListen_EmptySandboxName(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestTCPListen_BidirectionalDataFlow(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Accept in a goroutine (Accept blocks until a connection arrives). + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + // The bridge goroutines handle data flow; we just need to keep + // the accepted connection alive until the test completes. + _ = conn + }() + + // Connect to the listener's local address. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write data through the local connection → tunnel → mock echo → back. + payload := []byte("hello through the tunnel") + _, err = conn.Write(payload) + require.NoError(t, err) + + // Read the echoed data back. + buf := make([]byte, 256) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + // Second round-trip to confirm bidirectionality. + payload2 := []byte("round two") + _, err = conn.Write(payload2) + require.NoError(t, err) + + n, err = conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload2, buf[:n]) +} + +func TestTCPListen_InvalidRemotePort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := client.Listen(context.Background(), "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + }) + } +} + +// --- Graceful shutdown tests --- + +func TestTCPListen_CloseTerminatesConnections(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + + const numConns = 3 + conns := make([]net.Conn, numConns) + + // Accept goroutine. + go func() { + for range numConns { + _, _ = ln.Accept() + } + }() + + // Establish 3 connections. + for i := range numConns { + conns[i], err = net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + // Verify data flows before shutdown. + _, err = conns[i].Write([]byte("pre-close")) + require.NoError(t, err) + buf := make([]byte, 256) + _, err = conns[i].Read(buf) + require.NoError(t, err) + } + + // Close the listener. Per SC-003, this should complete within 5 seconds. + closeDone := make(chan error, 1) + go func() { + closeDone <- ln.Close() + }() + + select { + case closeErr := <-closeDone: + assert.NoError(t, closeErr) + case <-time.After(5 * time.Second): + t.Fatal("Close did not complete within 5 seconds") + } + + // All connections should now return errors on read. + for i, conn := range conns { + buf := make([]byte, 64) + _, readErr := conn.Read(buf) + assert.Error(t, readErr, "connection %d should be closed after listener.Close()", i) + _ = conn.Close() + } +} + +func TestTCPListen_ContextCancellation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + ln, err := client.Listen(ctx, "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + + // Accept a connection so there's an active bridge. + go func() { _, _ = ln.Accept() }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + // Verify data flows before cancellation. + _, err = conn.Write([]byte("before-cancel")) + require.NoError(t, err) + buf := make([]byte, 256) + _, err = conn.Read(buf) + require.NoError(t, err) + + // Cancel the context — should trigger listener close. + cancel() + + // The connection should eventually fail. + // Give the context-watcher goroutine a moment to close the listener. + time.Sleep(50 * time.Millisecond) + + _, err = conn.Write([]byte("after-cancel")) + if err == nil { + // Write may succeed if buffered, but Read should fail. + buf = make([]byte, 64) + _, err = conn.Read(buf) + } + assert.Error(t, err, "connection should fail after context cancellation") + _ = conn.Close() +} + +func TestTCPListen_AcceptOnClosedListener(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + + // Close the listener immediately. + err = ln.Close() + require.NoError(t, err) + + // Accept should return an error. + conn, err := ln.Accept() + assert.Nil(t, conn) + assert.Error(t, err) +} + +// --- Custom bind address tests --- + +func TestTCPListen_WithBindAddress(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + // Verify WithBindAddress is accepted and the listener binds to the + // specified address. We use 127.0.0.1 explicitly since it is the only + // loopback address guaranteed on all platforms (macOS does not enable + // 127.0.0.2+ by default). The default-case assertion below confirms + // that omitting the option also produces 127.0.0.1. + ln, err := client.Listen( + context.Background(), "my-sandbox", 8080, 0, + WithBindAddress("127.0.0.1"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr") + assert.Equal(t, "127.0.0.1", tcpAddr.IP.String(), + "listener should bind to the address specified by WithBindAddress") + + // Also verify that without WithBindAddress the default is 127.0.0.1. + lnDefault, err := client.Listen( + context.Background(), "my-sandbox", 8080, 0, + ) + require.NoError(t, err) + defer func() { _ = lnDefault.Close() }() + + defaultAddr, ok := lnDefault.Addr().(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr") + assert.Equal(t, "127.0.0.1", defaultAddr.IP.String(), + "default bind address should be 127.0.0.1") +} + +// --- SSH tunnel transport tests --- + +// mockSSHClient implements SSHInterface for testing the SSH tunnel path. +type mockSSHClient struct { + mu sync.Mutex + tunnelCalls int +} + +func (m *mockSSHClient) CreateSession(_ context.Context, _ string) (*SSHSession, error) { + return nil, fmt.Errorf("not implemented in mock") +} + +func (m *mockSSHClient) RevokeSession(_ context.Context, _ string) (bool, error) { + return false, fmt.Errorf("not implemented in mock") +} + +// Tunnel returns a pipe that echoes data back, and increments the call counter. +func (m *mockSSHClient) Tunnel(_ context.Context, _ string, _ uint32, _ ...TunnelOption) (io.ReadWriteCloser, error) { + m.mu.Lock() + m.tunnelCalls++ + m.mu.Unlock() + + // Create a pipe-based echo tunnel: read from one end, write back to the other. + clientReader, serverWriter := io.Pipe() + serverReader, clientWriter := io.Pipe() + + // Echo goroutine: copy everything from server reader to server writer. + go func() { + buf := make([]byte, 4096) + for { + n, err := serverReader.Read(buf) + if err != nil { + _ = serverWriter.Close() + return + } + if _, wErr := serverWriter.Write(buf[:n]); wErr != nil { + return + } + } + }() + + return &pipeRWC{Reader: clientReader, Writer: clientWriter, closers: []io.Closer{clientReader, clientWriter, serverReader, serverWriter}}, nil +} + +// pipeRWC wraps a Reader and Writer into an io.ReadWriteCloser. +type pipeRWC struct { + io.Reader + io.Writer + closers []io.Closer +} + +func (p *pipeRWC) Close() error { + for _, c := range p.closers { + _ = c.Close() + } + return nil +} + +func TestTCPListen_WithSSHTunnel(t *testing.T) { + mock := newMockTCPServer() + + // Set up the gRPC connection (needed for tcpClient even though SSH path + // won't use Forward). + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + sshMock := &mockSSHClient{} + client := newTCPClient(conn, &stubSandboxResolver{}, sshMock) + + ln, err := client.Listen( + context.Background(), "my-sandbox", 8080, 0, + WithSSHTunnel(), + WithListenServiceID("ssh-svc"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + // Accept in background. + go func() { _, _ = ln.Accept() }() + + // Connect and send data through the SSH tunnel path. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + payload := []byte("ssh-tunnel-test") + _, err = c.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 256) + n, err := c.Read(buf) + require.NoError(t, err) + assert.Equal(t, string(payload), string(buf[:n]), + "data should echo through SSH tunnel") + + // Verify that Tunnel was called (not Forward). + sshMock.mu.Lock() + calls := sshMock.tunnelCalls + sshMock.mu.Unlock() + assert.Equal(t, 1, calls, "SSH Tunnel should have been called exactly once") + + // Verify no Forward calls happened on the mock TCP server. + mock.mu.Lock() + initFrame := mock.lastInit + mock.mu.Unlock() + assert.Nil(t, initFrame, "TCP Forward should not have been called when using SSH tunnel") + + _ = c.Close() +} + +func TestTCPListen_CallerSpecifiedPort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + const wantPort = 19876 + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, wantPort) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok) + assert.Equal(t, wantPort, tcpAddr.Port, "listener should bind to the exact port requested") + + go func() { _, _ = ln.Accept() }() + + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = c.Close() }() + + _, err = c.Write([]byte("fixed-port")) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := c.Read(buf) + require.NoError(t, err) + assert.Equal(t, "fixed-port", string(buf[:n])) +} + +func TestTCPListen_ServiceIDPropagated(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0, + WithListenServiceID("test-svc-id"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + go func() { _, _ = ln.Accept() }() + + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + _, err = c.Write([]byte("svc-id-test")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = c.Read(buf) + require.NoError(t, err) + _ = c.Close() + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init, "mock should have received the init frame") + assert.Equal(t, "test-svc-id", init.GetServiceId(), + "Listen should propagate service ID to the Forward init frame") +} + +func TestTCPListen_ConcurrentAccept(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "my-sandbox", 8080, 0) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + const numAcceptors = 3 + const numConns = 6 + accepted := make(chan net.Conn, numConns) + var wg sync.WaitGroup + + for range numAcceptors { + wg.Add(1) + go func() { + defer wg.Done() + for { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + accepted <- conn + } + }() + } + + for range numConns { + c, dialErr := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, dialErr) + _, err = c.Write([]byte("hello")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = c.Read(buf) + require.NoError(t, err) + _ = c.Close() + } + + _ = ln.Close() + wg.Wait() + close(accepted) + + count := 0 + for conn := range accepted { + _ = conn.Close() + count++ + } + assert.Equal(t, numConns, count, "all connections should be accepted across concurrent acceptors") +} + +func TestTCPListen_WithSSHTunnel_NilSSH(t *testing.T) { + mock := newMockTCPServer() + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + client := newTCPClient(conn, &stubSandboxResolver{}, nil) + _, err = client.Listen(context.Background(), "my-sandbox", 8080, 0, WithSSHTunnel()) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "WithSSHTunnel with nil SSH client should return InvalidArgument") +} + +// --- Failure injection helpers --- + +// flippableResolver extends stubSandboxResolver with a mutex-guarded error +// that can be toggled at runtime (set to nil to stop failing). +type flippableResolver struct { + mu sync.Mutex + failErr error +} + +func (r *flippableResolver) Get(_ context.Context, name string) (*Sandbox, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failErr != nil { + return nil, r.failErr + } + return &Sandbox{ID: "sb-" + name, Name: name}, nil +} + +func (r *flippableResolver) Create(context.Context, string, *SandboxSpec, map[string]string) (*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) List(context.Context, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) Delete(context.Context, string) error { panic("not implemented") } +func (r *flippableResolver) AttachProvider(context.Context, string, string, uint64) (*AttachProviderResult, error) { + panic("not implemented") +} +func (r *flippableResolver) DetachProvider(context.Context, string, string, uint64) (*DetachProviderResult, error) { + panic("not implemented") +} +func (r *flippableResolver) ListProviders(context.Context, string) ([]*Provider, error) { + panic("not implemented") +} +func (r *flippableResolver) WaitReady(context.Context, string, ...WaitOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) Watch(context.Context, string, ...WatchOptions) (WatchInterface[*Sandbox], error) { + panic("not implemented") +} +func (r *flippableResolver) GetLogs(context.Context, string, ...LogOption) (*LogResult, error) { + panic("not implemented") +} + +// --- Failure injection tests --- + +func TestTCPListen_TunnelSetupRetry(t *testing.T) { + mock := newMockTCPServer() + + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + // Use a resolver that fails initially, then succeeds. + resolver := &flippableResolver{ + failErr: &StatusError{Code: ErrorUnavailable, Message: "sandbox unreachable"}, + } + client := newTCPClient(conn, resolver, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inner, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + tl := &tunnelListener{ + inner: inner, + ctx: ctx, + cancel: cancel, + tcp: client, + sandboxName: "my-sandbox", + remotePort: 8080, + cfg: listenConfig{bindAddress: "127.0.0.1"}, + } + + accepted := make(chan net.Conn, 1) + acceptErr := make(chan error, 1) + + go func() { + c, e := tl.Accept() + if e != nil { + acceptErr <- e + return + } + accepted <- c + }() + + // First connection triggers Forward which fails (resolver returns error). + c1, err := net.Dial("tcp", inner.Addr().String()) + require.NoError(t, err) + defer func() { _ = c1.Close() }() + + time.Sleep(50 * time.Millisecond) + + // Clear the error so the next Forward succeeds. + resolver.mu.Lock() + resolver.failErr = nil + resolver.mu.Unlock() + + // Second connection should succeed through the retry loop. + c2, err := net.Dial("tcp", inner.Addr().String()) + require.NoError(t, err) + defer func() { _ = c2.Close() }() + + select { + case c := <-accepted: + require.NotNil(t, c) + _ = c.Close() + case e := <-acceptErr: + t.Fatalf("Accept returned error instead of retrying: %v", e) + case <-time.After(5 * time.Second): + t.Fatal("Accept did not return after retry") + } + + _ = tl.Close() +} + +func TestTCPListen_TunnelFailureWithContextCancel(t *testing.T) { + mock := newMockTCPServer() + + 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) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + // Resolver always fails: Forward will error on every attempt. + resolver := &flippableResolver{ + failErr: &StatusError{Code: ErrorUnavailable, Message: "permanent failure"}, + } + client := newTCPClient(conn, resolver, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + ln, err := client.Listen(ctx, "my-sandbox", 8080, 0) + require.NoError(t, err) + + acceptErr := make(chan error, 1) + go func() { + _, e := ln.Accept() + acceptErr <- e + }() + + // Trigger a connection that will fail tunnel setup. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + _ = c.Close() + + // Give Accept time to enter the retry loop. + time.Sleep(50 * time.Millisecond) + + // Cancel context: the context-watcher goroutine in Listen() calls + // Close(), which closes the inner listener and unblocks Accept. + cancel() + + select { + case e := <-acceptErr: + require.Error(t, e) + case <-time.After(5 * time.Second): + t.Fatal("Accept did not return after context cancellation") + } +} + +func TestTCPListen_BridgedConnCloseIdempotent(t *testing.T) { + r1, w1 := io.Pipe() + r2, w2 := io.Pipe() + tunnel := &pipeRWC{Reader: r1, Writer: w2, closers: []io.Closer{r1, w1, r2, w2}} + + server, client := net.Pipe() + defer func() { _ = server.Close() }() + + bc := &bridgedConn{ + Conn: client, + tunnel: tunnel, + } + + err1 := bc.Close() + err2 := bc.Close() + + // Second close must not panic and must return the same error. + assert.Equal(t, err1, err2, "idempotent Close should return the same error") +} diff --git a/sdk/go/openshell/v1/types.go b/sdk/go/openshell/v1/types.go new file mode 100644 index 0000000000..012811cabb --- /dev/null +++ b/sdk/go/openshell/v1/types.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// SandboxPhase represents the lifecycle phase of a sandbox. +type SandboxPhase = types.SandboxPhase + +// SandboxPhase values for sandbox lifecycle. +const ( + SandboxProvisioning = types.SandboxProvisioning + SandboxReady = types.SandboxReady + SandboxError = types.SandboxError + SandboxDeleting = types.SandboxDeleting + SandboxUnknown = types.SandboxUnknown +) + +// EventType classifies watch events. +type EventType = types.EventType + +// EventType values for watch events. +const ( + EventAdded = types.EventAdded + EventModified = types.EventModified + EventDeleted = types.EventDeleted + EventError = types.EventError +) + +// StreamType identifies which output stream a chunk belongs to. +type StreamType = types.StreamType + +// StreamType values for exec output. +const ( + StreamStdout = types.StreamStdout + StreamStderr = types.StreamStderr +) + +// TLSConfig holds TLS connection settings. +type TLSConfig = types.TLSConfig + +// RetryPolicy configures automatic retry behavior for failed RPCs. +type RetryPolicy = types.RetryPolicy diff --git a/sdk/go/openshell/v1/types/auth.go b/sdk/go/openshell/v1/types/auth.go new file mode 100644 index 0000000000..90da224739 --- /dev/null +++ b/sdk/go/openshell/v1/types/auth.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "context" + +// AuthProvider supplies per-RPC credentials. It implements the +// grpc credentials.PerRPCCredentials interface. +type AuthProvider interface { + GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) + RequireTransportSecurity() bool +} diff --git a/sdk/go/openshell/v1/types/config.go b/sdk/go/openshell/v1/types/config.go new file mode 100644 index 0000000000..a0edf5b275 --- /dev/null +++ b/sdk/go/openshell/v1/types/config.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// Config holds all settings needed to create a Client. +type Config struct { + Address string + TLS *TLSConfig + Auth AuthProvider + Timeout time.Duration + RetryPolicy *RetryPolicy + Logger Logger +} diff --git a/sdk/go/openshell/v1/types/doc.go b/sdk/go/openshell/v1/types/doc.go new file mode 100644 index 0000000000..c6577baf45 --- /dev/null +++ b/sdk/go/openshell/v1/types/doc.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package types defines all domain data types for the OpenShell SDK v1 API. +// +// These types are the canonical definitions used by both the client layer +// (openshell/v1) and the converter layer (openshell/v1/internal/converter). +// The v1 package re-exports all types via type aliases for backward +// compatibility. +package types diff --git a/sdk/go/openshell/v1/types/errors.go b/sdk/go/openshell/v1/types/errors.go new file mode 100644 index 0000000000..426ff330c8 --- /dev/null +++ b/sdk/go/openshell/v1/types/errors.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import ( + "errors" + "fmt" +) + +// ErrorCode classifies SDK errors by their gRPC origin. +type ErrorCode int + +// ErrorCode values for classifying gRPC errors. +const ( + ErrorNotFound ErrorCode = iota + 1 + ErrorAlreadyExists + ErrorUnavailable + ErrorPermissionDenied + ErrorInvalidArgument + ErrorDeadlineExceeded + ErrorCancelled + ErrorInternal + ErrorUnimplemented + ErrorConflict +) + +// String returns the human-readable name of the error code. +func (c ErrorCode) String() string { + switch c { + case ErrorNotFound: + return "NotFound" + case ErrorAlreadyExists: + return "AlreadyExists" + case ErrorUnavailable: + return "Unavailable" + case ErrorPermissionDenied: + return "PermissionDenied" + case ErrorInvalidArgument: + return "InvalidArgument" + case ErrorDeadlineExceeded: + return "DeadlineExceeded" + case ErrorCancelled: + return "Cancelled" + case ErrorInternal: + return "Internal" + case ErrorUnimplemented: + return "Unimplemented" + case ErrorConflict: + return "Conflict" + default: + return fmt.Sprintf("Unknown(%d)", int(c)) + } +} + +// StatusError is the typed error returned by all SDK operations. +type StatusError struct { + Code ErrorCode + Message string + Details map[string]string +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// IsNotFound returns true if the error indicates a resource was not found. +func IsNotFound(err error) bool { + return hasCode(err, ErrorNotFound) +} + +// IsAlreadyExists returns true if the error indicates a resource already exists. +func IsAlreadyExists(err error) bool { + return hasCode(err, ErrorAlreadyExists) +} + +// IsUnavailable returns true if the error indicates the service is unavailable. +func IsUnavailable(err error) bool { + return hasCode(err, ErrorUnavailable) +} + +// IsPermissionDenied returns true if the error indicates insufficient permissions. +func IsPermissionDenied(err error) bool { + return hasCode(err, ErrorPermissionDenied) +} + +// IsInvalidArgument returns true if the error indicates an invalid argument. +func IsInvalidArgument(err error) bool { + return hasCode(err, ErrorInvalidArgument) +} + +// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded. +func IsDeadlineExceeded(err error) bool { + return hasCode(err, ErrorDeadlineExceeded) +} + +// IsCancelled returns true if the error indicates the operation was cancelled. +func IsCancelled(err error) bool { + return hasCode(err, ErrorCancelled) +} + +// IsUnimplemented returns true if the error indicates the operation is not implemented. +func IsUnimplemented(err error) bool { + return hasCode(err, ErrorUnimplemented) +} + +// IsConflict returns true if the error indicates a conflict, such as +// optimistic concurrency or an invalid state transition. +func IsConflict(err error) bool { + return hasCode(err, ErrorConflict) +} + +func hasCode(err error, code ErrorCode) bool { + if err == nil { + return false + } + var se *StatusError + if errors.As(err, &se) { + return se.Code == code + } + return false +} diff --git a/sdk/go/openshell/v1/types/exec.go b/sdk/go/openshell/v1/types/exec.go new file mode 100644 index 0000000000..ecc322a2df --- /dev/null +++ b/sdk/go/openshell/v1/types/exec.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// ExecResult holds the collected output of a completed command execution. +type ExecResult struct { + ExitCode int + Stdout []byte + Stderr []byte +} + +// ExecChunk represents a single chunk of output from a streaming command execution. +type ExecChunk struct { + Stream StreamType + Data []byte +} diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go new file mode 100644 index 0000000000..0036183180 --- /dev/null +++ b/sdk/go/openshell/v1/types/health.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// HealthResult holds the result of a health check. +type HealthResult struct { + Healthy bool + Version string +} diff --git a/sdk/go/openshell/v1/types/log.go b/sdk/go/openshell/v1/types/log.go new file mode 100644 index 0000000000..85a62c29db --- /dev/null +++ b/sdk/go/openshell/v1/types/log.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// LogLine represents a single log entry from a sandbox. +type LogLine struct { + // Timestamp is when the log entry was recorded. + Timestamp time.Time + // Level is the log severity level (e.g., "INFO", "WARN", "ERROR"). + Level string + // Target is the log target/module. + Target string + // Message is the log message text. + Message string + // Source is the log source: "gateway" or "sandbox". + Source string + // Fields contains structured key-value fields from the tracing event. + Fields map[string]string +} + +// LogResult contains the result of a GetLogs call. +type LogResult struct { + // Lines contains the log entries in chronological order. + Lines []LogLine + // BufferTotal is the total number of lines in the server's buffer. + BufferTotal uint32 +} + +// logConfig holds configuration for GetLogs calls. +type logConfig struct { + lines uint32 + since time.Time + sources []string + minLevel string +} + +// LogOption configures a GetLogs call. +type LogOption func(*logConfig) + +// WithLogLines sets the maximum number of log lines to return. +func WithLogLines(n uint32) LogOption { + return func(c *logConfig) { + c.lines = n + } +} + +// WithLogSince filters logs to entries at or after the given time. +func WithLogSince(t time.Time) LogOption { + return func(c *logConfig) { + c.since = t + } +} + +// WithLogSources filters logs by source (e.g., "gateway", "sandbox"). +func WithLogSources(sources ...string) LogOption { + return func(c *logConfig) { + c.sources = sources + } +} + +// WithLogMinLevel sets the minimum log level to include. +func WithLogMinLevel(level string) LogOption { + return func(c *logConfig) { + c.minLevel = level + } +} + +// ApplyLogOptions applies options and returns the config. +func ApplyLogOptions(opts []LogOption) logConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg logConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// Lines returns the configured max lines (0 means server default). +func (c *logConfig) Lines() uint32 { + return c.lines +} + +// Since returns the configured since timestamp (zero means no filter). +func (c *logConfig) Since() time.Time { + return c.since +} + +// Sources returns the configured source filters. +func (c *logConfig) Sources() []string { + return c.sources +} + +// MinLevel returns the configured minimum log level. +func (c *logConfig) MinLevel() string { + return c.minLevel +} diff --git a/sdk/go/openshell/v1/types/logger.go b/sdk/go/openshell/v1/types/logger.go new file mode 100644 index 0000000000..351630cb0b --- /dev/null +++ b/sdk/go/openshell/v1/types/logger.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// Logger defines structured logging for the SDK. Compatible with logr.Logger +// and slog.Logger adapters. +type Logger interface { + Debug(msg string, keysAndValues ...any) + Info(msg string, keysAndValues ...any) + Error(err error, msg string, keysAndValues ...any) +} diff --git a/sdk/go/openshell/v1/types/network_policy.go b/sdk/go/openshell/v1/types/network_policy.go new file mode 100644 index 0000000000..99084a8499 --- /dev/null +++ b/sdk/go/openshell/v1/types/network_policy.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// NetworkPolicyRule defines a named network policy rule containing endpoints and binaries. +type NetworkPolicyRule struct { + // Name is the map key for this rule in the sandbox policy. + Name string + // Endpoints lists the network endpoints governed by this rule. + Endpoints []PolicyNetworkEndpoint + // Binaries lists the binaries governed by this rule. + Binaries []PolicyNetworkBinary +} + +// PolicyNetworkEndpoint describes a full network endpoint with its access controls +// as used in sandbox network policy rules. This is distinct from [NetworkEndpoint] +// which is the simplified profile-level endpoint (Host, Port, Protocol only). +type PolicyNetworkEndpoint struct { + Host string + Port uint32 + Ports []uint32 + Protocol string + TLS string + Enforcement string + Access string + Rules []L7Rule + AllowedIPs []string + DenyRules []L7DenyRule + AllowEncodedSlash bool + PersistedQueries string + GraphqlPersistedQueries map[string]GraphqlOperation + GraphqlMaxBodyBytes uint32 + Path string + WebsocketCredentialRewrite bool + RequestBodyCredentialRewrite bool + AdvisorProposed bool +} + +// PolicyNetworkBinary identifies a binary subject to network policy enforcement. +// This is distinct from [NetworkBinary] which is the simplified profile-level binary. +type PolicyNetworkBinary struct { + // Path is the filesystem path to the binary. + Path string +} + +// L7Rule wraps an L7 allow rule. +type L7Rule struct { + // Allow holds the layer-7 allow criteria. + Allow *L7Allow +} + +// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL traffic. +type L7Allow struct { + Method string + Path string + Command string + Query map[string]L7QueryMatcher + OperationType string + OperationName string + Fields []string +} + +// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. +type L7DenyRule struct { + Method string + Path string + Command string + Query map[string]L7QueryMatcher + OperationType string + OperationName string + Fields []string +} + +// L7QueryMatcher matches query parameters by glob pattern or exact values. +type L7QueryMatcher struct { + Glob string + Any []string +} + +// GraphqlOperation describes a GraphQL operation for persisted-query validation. +type GraphqlOperation struct { + OperationType string + OperationName string + Fields []string +} + +// --- MergeOperation types --- + +// PolicyMergeOperation represents a single atomic policy mutation. +// Exactly one of the pointer fields must be non-nil, modelling the proto oneof. +type PolicyMergeOperation struct { + // AddRule adds a new named network policy rule. + AddRule *AddNetworkRule + // RemoveEndpoint removes a single endpoint from a rule. + RemoveEndpoint *RemoveNetworkEndpoint + // RemoveRule removes an entire named rule. + RemoveRule *RemoveNetworkRule + // AddDenyRules appends deny rules to an endpoint. + AddDenyRules *AddDenyRules + // AddAllowRules appends allow rules to an endpoint. + AddAllowRules *AddAllowRules + // RemoveBinary removes a binary from a rule. + RemoveBinary *RemoveNetworkBinary +} + +// AddNetworkRule adds a named network policy rule with a full rule definition. +type AddNetworkRule struct { + // RuleName is the name key for the rule. + RuleName string + // Rule is the full network policy rule to add. + Rule NetworkPolicyRule +} + +// RemoveNetworkEndpoint removes a specific endpoint from a named rule. +type RemoveNetworkEndpoint struct { + // RuleName is the name of the rule containing the endpoint. + RuleName string + // Host is the endpoint host to remove. + Host string + // Port is the endpoint port to remove. + Port uint32 +} + +// RemoveNetworkRule removes an entire named rule from the policy. +type RemoveNetworkRule struct { + // RuleName is the name of the rule to remove. + RuleName string +} + +// AddDenyRules appends layer-7 deny rules to a specific endpoint. +type AddDenyRules struct { + // Host identifies the target endpoint host. + Host string + // Port identifies the target endpoint port. + Port uint32 + // DenyRules are the deny rules to append. + DenyRules []L7DenyRule +} + +// AddAllowRules appends layer-7 allow rules to a specific endpoint. +type AddAllowRules struct { + // Host identifies the target endpoint host. + Host string + // Port identifies the target endpoint port. + Port uint32 + // Rules are the allow rules to append. + Rules []L7Rule +} + +// RemoveNetworkBinary removes a binary from a named rule. +type RemoveNetworkBinary struct { + // RuleName is the name of the rule containing the binary. + RuleName string + // BinaryPath is the filesystem path of the binary to remove. + BinaryPath string +} diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go new file mode 100644 index 0000000000..c4be36b088 --- /dev/null +++ b/sdk/go/openshell/v1/types/options.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// CreateOptions configures resource creation. +type CreateOptions struct{} + +// GetOptions configures resource retrieval. +type GetOptions struct{} + +// ListOptions configures resource listing with pagination and filtering. +type ListOptions struct { + Limit int + Offset int + LabelSelector string +} + +// DeleteOptions configures resource deletion. +type DeleteOptions struct{} + +// UpdateOptions configures resource updates. +type UpdateOptions struct{} + +// WatchOptions configures watch behavior. +type WatchOptions struct { + TimeoutSeconds int64 + LabelSelector string + // StopOnTerminal causes the watch to close automatically when the sandbox + // reaches a terminal phase (Ready or Error). + StopOnTerminal bool +} + +// WaitOptions configures wait behavior. Use context for timeout control. +type WaitOptions struct { + PollInterval time.Duration +} + +// ExecOptions configures command execution. +type ExecOptions struct { + Env map[string]string + WorkDir string +} diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go new file mode 100644 index 0000000000..f71aeca1dc --- /dev/null +++ b/sdk/go/openshell/v1/types/policy.go @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// PolicyLoadStatus represents the load state of a policy revision. +type PolicyLoadStatus int + +const ( + // PolicyLoadStatusUnspecified is the default zero value. + PolicyLoadStatusUnspecified PolicyLoadStatus = iota + // PolicyLoadStatusPending means the policy is queued for loading. + PolicyLoadStatusPending + // PolicyLoadStatusLoaded means the policy was successfully loaded. + PolicyLoadStatusLoaded + // PolicyLoadStatusFailed means the policy failed to load. + PolicyLoadStatusFailed + // PolicyLoadStatusSuperseded means a newer revision replaced this one. + PolicyLoadStatusSuperseded +) + +// String returns the human-readable name of the load status. +func (s PolicyLoadStatus) String() string { + switch s { + case PolicyLoadStatusUnspecified: + return "Unspecified" + case PolicyLoadStatusPending: + return "Pending" + case PolicyLoadStatusLoaded: + return "Loaded" + case PolicyLoadStatusFailed: + return "Failed" + case PolicyLoadStatusSuperseded: + return "Superseded" + default: + return "Unknown" + } +} + +// PolicyChunk represents a single proposed policy change in the draft inbox. +type PolicyChunk struct { + // ID is the unique chunk identifier. + ID string + // Status is the approval status: "pending", "approved", "rejected". + Status string + // RuleName is the proposed network_policies map key. + RuleName string + // ProposedRule is the proposed network policy rule. + ProposedRule *NetworkPolicyRule + + // Rationale is a human-readable explanation of why this rule is proposed. + Rationale string + // SecurityNotes contains security concerns flagged by analysis (empty if none). + SecurityNotes string + // Confidence is the analysis confidence score (0.0-1.0). + Confidence float32 + // DenialSummaryIDs lists the IDs of denial summaries that led to this chunk. + DenialSummaryIDs []string + // CreatedAt is when the chunk was created. + CreatedAt time.Time + // DecidedAt is when the user approved/rejected (zero if undecided). + DecidedAt time.Time + // Stage is the recommendation stage: "initial" or "refined". + Stage string + // SupersedesChunkID is the initial chunk ID this refined chunk replaces. + SupersedesChunkID string + // HitCount is how many times this endpoint was seen across denial flush cycles. + HitCount int32 + // FirstSeen is the first time this endpoint was proposed. + FirstSeen time.Time + // LastSeen is the most recent time this endpoint was re-proposed. + LastSeen time.Time + // Binary is the binary path that triggered the denial. + Binary string + // ValidationResult is the prover output from gateway-side static checks. + ValidationResult string + // RejectionReason is the operator-supplied text accompanying a rejection. + RejectionReason string +} + +// DraftPolicy contains the full draft policy state returned by GetDraft. +type DraftPolicy struct { + // Chunks contains the draft policy chunks. + Chunks []PolicyChunk + // RollingSummary is an LLM-generated summary of all analysis. + RollingSummary string + // DraftVersion is the current draft version number. + DraftVersion uint64 + // LastAnalyzedAt is when the last analysis completed. + LastAnalyzedAt time.Time +} + +// SandboxPolicy is the top-level security policy configuration for a sandbox. +// It contains filesystem access rules, Landlock LSM configuration, process +// identity rules, and named network access policies. +type SandboxPolicy struct { + // Version is the policy version number. The server may override this on write. + Version uint32 + // Filesystem controls which directories the sandbox can access. + // Nil means no filesystem policy is specified. + Filesystem *FilesystemPolicy + // Landlock configures the Linux Landlock LSM. + // Nil means no landlock policy is specified. + Landlock *LandlockPolicy + // Process controls the user and group identity for sandboxed processes. + // Nil means no process policy is specified. + Process *ProcessPolicy + // NetworkPolicies contains named network access rules. + // Nil means no network policies are specified; an empty map is distinct from nil. + NetworkPolicies map[string]NetworkPolicyRule +} + +// FilesystemPolicy controls which directories the sandbox can access +// in read-only or read-write mode. +type FilesystemPolicy struct { + // IncludeWorkdir auto-includes the working directory as read-write. + IncludeWorkdir bool + // ReadOnly is the list of read-only directory paths. + // Nil means no read-only directories; an empty slice is distinct from nil. + ReadOnly []string + // ReadWrite is the list of read-write directory paths. + // Nil means no read-write directories; an empty slice is distinct from nil. + ReadWrite []string +} + +// LandlockPolicy configures the Linux Landlock LSM for filesystem restriction enforcement. +type LandlockPolicy struct { + // Compatibility is the compatibility mode (e.g., "best_effort", "hard_requirement"). + Compatibility string +} + +// ProcessPolicy controls the user and group identity under which sandboxed processes execute. +type ProcessPolicy struct { + // RunAsUser is the user name for sandboxed processes. + RunAsUser string + // RunAsGroup is the group name for sandboxed processes. + RunAsGroup string +} + +// SandboxPolicyRevision represents a versioned policy revision for a sandbox. +type SandboxPolicyRevision struct { + // Version is the policy version (monotonically increasing per sandbox). + Version uint32 + // PolicyHash is the SHA-256 hash of the serialized policy payload. + PolicyHash string + // Status is the load status of this revision. + Status PolicyLoadStatus + // LoadError is the error message if status is Failed. + LoadError string + // CreatedAt is when this revision was created. + CreatedAt time.Time + // LoadedAt is when this revision was loaded by the sandbox. + LoadedAt time.Time + // Policy is the typed security policy for this revision. Nil when not requested or absent. + Policy *SandboxPolicy +} + +// PolicyStatusResult contains the status of a sandbox's policy. +type PolicyStatusResult struct { + // Revision is the queried policy revision. + Revision SandboxPolicyRevision + // ActiveVersion is the currently active (loaded) policy version. + ActiveVersion uint32 +} + +// ApproveResult contains the result of approving a single draft chunk. +type ApproveResult struct { + // PolicyVersion is the new policy version after merge. + PolicyVersion uint32 + // PolicyHash is the SHA-256 hash of the new policy. + PolicyHash string +} + +// ApproveAllResult contains the result of approving all draft chunks. +type ApproveAllResult struct { + // PolicyVersion is the new policy version after merge. + PolicyVersion uint32 + // PolicyHash is the SHA-256 hash of the new policy. + PolicyHash string + // ChunksApproved is the number of chunks approved. + ChunksApproved uint32 + // ChunksSkipped is the number of chunks skipped (security-flagged). + ChunksSkipped uint32 +} + +// UndoResult contains the result of undoing a draft chunk approval. +type UndoResult struct { + // PolicyVersion is the new policy version after removal. + PolicyVersion uint32 + // PolicyHash is the SHA-256 hash of the updated policy. + PolicyHash string +} + +// ClearResult contains the result of clearing all draft chunks. +type ClearResult struct { + // ChunksCleared is the number of chunks cleared. + ChunksCleared uint32 +} + +// DraftHistoryEntry represents a single event in the draft policy history. +type DraftHistoryEntry struct { + // Timestamp is when the event occurred. + Timestamp time.Time + // EventType is the event type (e.g., "approved", "rejected", "cleared"). + EventType string + // Description is a human-readable description. + Description string + // ChunkID is the associated chunk ID (if applicable). + ChunkID string +} + +// getDraftConfig holds configuration for GetDraft calls. +type getDraftConfig struct { + statusFilter string +} + +// GetDraftOption configures a GetDraft call. +type GetDraftOption func(*getDraftConfig) + +// WithStatusFilter filters draft chunks by approval status. +func WithStatusFilter(status string) GetDraftOption { + return func(c *getDraftConfig) { + c.statusFilter = status + } +} + +// ApplyGetDraftOptions applies options and returns the config. +func ApplyGetDraftOptions(opts []GetDraftOption) getDraftConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg getDraftConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// StatusFilter returns the configured status filter. +func (c *getDraftConfig) StatusFilter() string { + return c.statusFilter +} + +// approveAllConfig holds configuration for ApproveAllDraftChunks calls. +type approveAllConfig struct { + includeSecurityFlagged bool +} + +// ApproveAllOption configures an ApproveAllDraftChunks call. +type ApproveAllOption func(*approveAllConfig) + +// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval. +func WithIncludeSecurityFlagged() ApproveAllOption { + return func(c *approveAllConfig) { + c.includeSecurityFlagged = true + } +} + +// ApplyApproveAllOptions applies options and returns the config. +func ApplyApproveAllOptions(opts []ApproveAllOption) approveAllConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg approveAllConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// IncludeSecurityFlagged returns whether security-flagged chunks are included. +func (c *approveAllConfig) IncludeSecurityFlagged() bool { + return c.includeSecurityFlagged +} + +// getStatusConfig holds configuration for GetStatus calls. +type getStatusConfig struct { + version uint32 +} + +// GetStatusOption configures a GetStatus call. +type GetStatusOption func(*getStatusConfig) + +// WithVersion queries a specific policy version instead of the latest. +func WithVersion(version uint32) GetStatusOption { + return func(c *getStatusConfig) { + c.version = version + } +} + +// ApplyGetStatusOptions applies options and returns the config. +func ApplyGetStatusOptions(opts []GetStatusOption) getStatusConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg getStatusConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// Version returns the configured version (0 means latest). +func (c *getStatusConfig) Version() uint32 { + return c.version +} + +// listPolicyConfig holds configuration for List calls. +type listPolicyConfig struct { + limit uint32 + offset uint32 +} + +// ListPolicyOption configures a List call. +type ListPolicyOption func(*listPolicyConfig) + +// WithLimit sets the maximum number of revisions to return. +func WithLimit(limit uint32) ListPolicyOption { + return func(c *listPolicyConfig) { + c.limit = limit + } +} + +// WithOffset sets the pagination offset. +func WithOffset(offset uint32) ListPolicyOption { + return func(c *listPolicyConfig) { + c.offset = offset + } +} + +// ApplyListPolicyOptions applies options and returns the config. +func ApplyListPolicyOptions(opts []ListPolicyOption) listPolicyConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package + var cfg listPolicyConfig + for _, opt := range opts { + opt(&cfg) + } + return cfg +} + +// Limit returns the configured limit (0 means server default). +func (c *listPolicyConfig) Limit() uint32 { + return c.limit +} + +// Offset returns the configured offset. +func (c *listPolicyConfig) Offset() uint32 { + return c.offset +} diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go new file mode 100644 index 0000000000..9d57c6d920 --- /dev/null +++ b/sdk/go/openshell/v1/types/profile.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// ProfileCategory classifies a provider profile. +type ProfileCategory string + +// ProfileCategory values. +const ( + ProfileCategoryOther ProfileCategory = "Other" + ProfileCategoryInference ProfileCategory = "Inference" + ProfileCategoryAgent ProfileCategory = "Agent" + ProfileCategorySourceControl ProfileCategory = "SourceControl" + ProfileCategoryMessaging ProfileCategory = "Messaging" + ProfileCategoryData ProfileCategory = "Data" + ProfileCategoryKnowledge ProfileCategory = "Knowledge" +) + +// ProviderProfile defines a provider type template with credentials schema, +// endpoints, binaries, and discovery configuration. +type ProviderProfile struct { + ID string + DisplayName string + Description string + Category ProfileCategory + Credentials []ProfileCredential + Endpoints []NetworkEndpoint + Binaries []NetworkBinary + InferenceCapable bool + Discovery ProfileDiscovery + ResourceVersion uint64 +} + +// ProfileCredential defines a single credential required by a provider profile. +type ProfileCredential struct { + Name string + Description string + Required bool + Secret bool +} + +// NetworkEndpoint describes a network endpoint provided by a profile. +type NetworkEndpoint struct { + Host string + Port uint32 + Protocol string +} + +// NetworkBinary describes a binary artifact provided by a profile. +type NetworkBinary struct { + Path string +} + +// ProfileDiscovery holds local discovery configuration for a profile. +type ProfileDiscovery struct { + Credentials []string +} + +// ProfileImportItem is an item submitted for profile import or lint validation. +type ProfileImportItem struct { + Profile ProviderProfile + Source string +} + +// ProfileDiagnostic is a validation finding from Import, Update, or Lint. +type ProfileDiagnostic struct { + Source string + ProfileID string + Field string + Message string + Severity string +} + +// ImportResult holds the result of a profile import operation. +type ImportResult struct { + Diagnostics []ProfileDiagnostic + Profiles []ProviderProfile + Imported bool +} + +// UpdateResult holds the result of a profile update operation. +type UpdateResult struct { + Diagnostics []ProfileDiagnostic + Profile *ProviderProfile + Updated bool +} + +// LintResult holds the result of a profile lint operation. +type LintResult struct { + Diagnostics []ProfileDiagnostic + Valid bool +} diff --git a/sdk/go/openshell/v1/types/provider.go b/sdk/go/openshell/v1/types/provider.go new file mode 100644 index 0000000000..f7ea51dc7b --- /dev/null +++ b/sdk/go/openshell/v1/types/provider.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// Provider represents an AI provider registration. +type Provider struct { + ID string + Name string + Type string + CreatedAt time.Time + Labels map[string]string + ResourceVersion uint64 + Spec ProviderSpec +} + +// ProviderSpec holds provider-specific configuration and credentials. +type ProviderSpec struct { + Credentials map[string]string + Config map[string]string + CredentialExpiresAt map[string]time.Time +} diff --git a/sdk/go/openshell/v1/types/refresh.go b/sdk/go/openshell/v1/types/refresh.go new file mode 100644 index 0000000000..fc73315a01 --- /dev/null +++ b/sdk/go/openshell/v1/types/refresh.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// RefreshStrategy describes how credentials are refreshed. +type RefreshStrategy string + +// RefreshStrategy values. +const ( + RefreshStrategyStatic RefreshStrategy = "Static" + RefreshStrategyExternal RefreshStrategy = "External" + RefreshStrategyOAuth2RefreshToken RefreshStrategy = "OAuth2RefreshToken" + RefreshStrategyOAuth2ClientCredentials RefreshStrategy = "OAuth2ClientCredentials" + RefreshStrategyGoogleServiceAccountJWT RefreshStrategy = "GoogleServiceAccountJWT" +) + +// RefreshStatus reports the current state of credential refresh for a specific +// provider credential. +type RefreshStatus struct { + ProviderName string + ProviderID string + CredentialKey string + Strategy RefreshStrategy + Status string + ExpiresAt time.Time + NextRefreshAt time.Time + LastRefreshAt time.Time + LastError string +} + +// RefreshConfig holds configuration parameters for gateway-owned credential +// refresh on a provider credential. +type RefreshConfig struct { + Provider string + CredentialKey string + Strategy RefreshStrategy + Material map[string]string + SecretMaterialKeys []string + ExpiresAt *time.Time +} diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go new file mode 100644 index 0000000000..4404b8716e --- /dev/null +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// Sandbox represents a sandbox instance. +type Sandbox struct { + ID string + Name string + CreatedAt time.Time + Labels map[string]string + ResourceVersion uint64 + Spec SandboxSpec + Status SandboxStatus +} + +// SandboxSpec holds the desired state of a sandbox. +type SandboxSpec struct { + LogLevel string + Environment map[string]string + Template *SandboxTemplate + Providers []string + GPUCount *uint32 + // Policy is the security policy for the sandbox. Nil means no policy specified. + Policy *SandboxPolicy +} + +// SandboxTemplate defines the container template for a sandbox. +type SandboxTemplate struct { + Image string + RuntimeClassName string + AgentSocket string + Labels map[string]string + Annotations map[string]string + Environment map[string]string + UserNamespaces *bool +} + +// SandboxStatus holds the observed state of a sandbox. +type SandboxStatus struct { + SandboxName string + AgentPod string + AgentFd string + SandboxFd string + Phase SandboxPhase + Conditions []SandboxCondition + CurrentPolicyVersion uint32 +} + +// SandboxCondition describes an observed condition of a sandbox. +type SandboxCondition struct { + Type string + Status string + Reason string + Message string + LastTransitionTime string +} + +// AttachProviderResult holds the result of attaching a provider to a sandbox. +type AttachProviderResult struct { + Sandbox *Sandbox + Attached bool +} + +// DetachProviderResult holds the result of detaching a provider from a sandbox. +type DetachProviderResult struct { + Sandbox *Sandbox + Detached bool +} diff --git a/sdk/go/openshell/v1/types/service.go b/sdk/go/openshell/v1/types/service.go new file mode 100644 index 0000000000..c25cb9b63d --- /dev/null +++ b/sdk/go/openshell/v1/types/service.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// ServiceEndpoint represents an exposed HTTP service on a sandbox. +type ServiceEndpoint struct { + ID string + SandboxID string + SandboxName string + ServiceName string + TargetPort uint32 + Domain bool + URL string +} diff --git a/sdk/go/openshell/v1/types/setting.go b/sdk/go/openshell/v1/types/setting.go new file mode 100644 index 0000000000..341f245b64 --- /dev/null +++ b/sdk/go/openshell/v1/types/setting.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// SettingValueType identifies which typed field of a SettingValue is active. +type SettingValueType string + +// SettingValueType constants. +const ( + SettingValueString SettingValueType = "string" + SettingValueBool SettingValueType = "bool" + SettingValueInt SettingValueType = "int" + SettingValueBytes SettingValueType = "bytes" +) + +// SettingValue is a typed setting value supporting string, bool, int64, and bytes variants. +// The Type field indicates which value field is populated. +type SettingValue struct { + Type SettingValueType + StringVal string + BoolVal bool + IntVal int64 + BytesVal []byte +} + +// SettingScope indicates whether a setting is controlled at sandbox or global level. +type SettingScope string + +// SettingScope constants. +const ( + SettingScopeUnspecified SettingScope = "" + SettingScopeSandbox SettingScope = "sandbox" + SettingScopeGlobal SettingScope = "global" +) + +// PolicySource indicates the source of the policy payload in a SandboxConfig response. +type PolicySource string + +// PolicySource constants. +const ( + PolicySourceUnspecified PolicySource = "" + PolicySourceSandbox PolicySource = "sandbox" + PolicySourceGlobal PolicySource = "global" +) + +// EffectiveSetting is a setting value paired with the scope it was resolved from. +type EffectiveSetting struct { + Value SettingValue + Scope SettingScope +} + +// SandboxConfig represents the full configuration state of a sandbox, +// including policy, effective settings, and revision metadata. +type SandboxConfig struct { + // Policy is the typed security policy for this sandbox. Nil means no policy in the response. + Policy *SandboxPolicy + // PolicyVersion is monotonically increasing per sandbox. + PolicyVersion uint32 + // PolicyHash is the SHA-256 of the serialized policy payload. + PolicyHash string + // Settings is the effective settings resolved for this sandbox. + Settings map[string]EffectiveSetting + // ConfigRevision is the fingerprint for effective config (policy + settings). + ConfigRevision uint64 + // PolicySource indicates where the policy came from (sandbox or global). + PolicySource PolicySource + // GlobalPolicyVersion is the global policy version (0 if not applicable). + GlobalPolicyVersion uint32 + // ProviderEnvRevision is the fingerprint for provider credential inputs. + ProviderEnvRevision uint64 +} + +// GatewayConfig represents gateway-global settings. +type GatewayConfig struct { + // Settings is the global settings map. + Settings map[string]SettingValue + // SettingsRevision is a monotonically increasing revision for gateway-global settings. + SettingsRevision uint64 +} + +// ConfigUpdate represents a configuration mutation request. +// For sandbox-scoped updates, set Name to the sandbox name. +// For global-scoped updates, set Global to true. +type ConfigUpdate struct { + // Name is the sandbox name (required for sandbox-scoped updates). + Name string + // Policy is the typed security policy for a full policy replacement. Nil means no policy change. + Policy *SandboxPolicy + // SettingKey is a single setting key to mutate. + SettingKey string + // SettingValue is the setting value for upsert. Nil means no value change. + SettingValue *SettingValue + // DeleteSetting deletes the setting key when true. + DeleteSetting bool + // Global applies the update at gateway-global scope when true. + Global bool + // MergeOperations is a list of typed policy merge operations. + MergeOperations []PolicyMergeOperation + // ExpectedResourceVersion is for optimistic concurrency (0 = skip check). + ExpectedResourceVersion uint64 +} + +// ConfigUpdateResult holds the result of a configuration update operation. +// Named ConfigUpdateResult to avoid collision with profile.UpdateResult. +type ConfigUpdateResult struct { + // Version is the assigned policy version. + Version uint32 + // PolicyHash is the SHA-256 of the serialized policy. + PolicyHash string + // SettingsRevision is the settings revision for the modified scope. + SettingsRevision uint64 + // Deleted is true when a setting delete removed an existing key. + Deleted bool +} diff --git a/sdk/go/openshell/v1/types/ssh.go b/sdk/go/openshell/v1/types/ssh.go new file mode 100644 index 0000000000..ec5e58e518 --- /dev/null +++ b/sdk/go/openshell/v1/types/ssh.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "fmt" + +// SSHSession represents an SSH session created for a sandbox. +// The Token field is sensitive and MUST NOT be logged or included in error messages. +// The String() method redacts the token to prevent accidental exposure via fmt or logging. +type SSHSession struct { + // SandboxID is the sandbox this session connects to. + SandboxID string + // Token is the session token for gateway tunnel authentication. + // This is a sensitive credential — treat it like an API key. + Token string + // GatewayHost is the host for SSH proxy connection. + GatewayHost string + // GatewayPort is the gateway port (1-65535). + GatewayPort uint32 + // GatewayScheme is the gateway protocol scheme ("http" or "https"). + GatewayScheme string + // HostKeyFingerprint is the optional host key fingerprint. + HostKeyFingerprint string + // ExpiresAtMs is the session expiry in milliseconds since epoch. + // Zero means no expiry. + ExpiresAtMs int64 +} + +// String returns a human-readable representation with the Token redacted. +func (s SSHSession) String() string { + return fmt.Sprintf("SSHSession{SandboxID:%s, GatewayHost:%s, GatewayPort:%d, GatewayScheme:%s, Token:[REDACTED]}", + s.SandboxID, s.GatewayHost, s.GatewayPort, s.GatewayScheme) +} diff --git a/sdk/go/openshell/v1/types/types.go b/sdk/go/openshell/v1/types/types.go new file mode 100644 index 0000000000..cc59f86117 --- /dev/null +++ b/sdk/go/openshell/v1/types/types.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// SandboxPhase represents the lifecycle phase of a sandbox. +type SandboxPhase string + +// SandboxPhase values for sandbox lifecycle. +const ( + SandboxProvisioning SandboxPhase = "Provisioning" + SandboxReady SandboxPhase = "Ready" + SandboxError SandboxPhase = "Error" + SandboxDeleting SandboxPhase = "Deleting" + SandboxUnknown SandboxPhase = "Unknown" +) + +// EventType classifies watch events. +type EventType string + +// EventType values for watch events. +const ( + EventAdded EventType = "ADDED" + EventModified EventType = "MODIFIED" + EventDeleted EventType = "DELETED" + EventError EventType = "ERROR" +) + +// StreamType identifies which output stream a chunk belongs to. +type StreamType string + +// StreamType values for exec output. +const ( + StreamStdout StreamType = "stdout" + StreamStderr StreamType = "stderr" +) + +// TLSConfig holds TLS connection settings. +type TLSConfig struct { + CertFile string + KeyFile string + CAFile string + Insecure bool +} + +// RetryPolicy configures automatic retry behavior for failed RPCs. +type RetryPolicy struct { + MaxRetries int + InitialWait time.Duration + MaxWait time.Duration +} diff --git a/sdk/go/openshell/v1/types/watch.go b/sdk/go/openshell/v1/types/watch.go new file mode 100644 index 0000000000..d97e07e3d7 --- /dev/null +++ b/sdk/go/openshell/v1/types/watch.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// Event represents a watch event carrying a resource that changed. +type Event[T any] struct { + Type EventType + Object T +} + +// WatchInterface delivers a stream of typed events. Modeled after +// k8s.io/apimachinery/pkg/watch.Interface. +type WatchInterface[T any] interface { + ResultChan() <-chan Event[T] + Stop() +} diff --git a/sdk/go/openshell/v1/types_reexport.go b/sdk/go/openshell/v1/types_reexport.go new file mode 100644 index 0000000000..b92aa80bc8 --- /dev/null +++ b/sdk/go/openshell/v1/types_reexport.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- Network Policy types --- + +// NetworkPolicyRule defines a named network policy rule containing endpoints and binaries. +type NetworkPolicyRule = types.NetworkPolicyRule + +// PolicyNetworkEndpoint describes a full network endpoint in a sandbox network policy rule. +type PolicyNetworkEndpoint = types.PolicyNetworkEndpoint + +// PolicyNetworkBinary identifies a binary subject to network policy enforcement. +type PolicyNetworkBinary = types.PolicyNetworkBinary + +// L7Rule wraps an L7 allow rule. +type L7Rule = types.L7Rule + +// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL traffic. +type L7Allow = types.L7Allow + +// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. +type L7DenyRule = types.L7DenyRule + +// L7QueryMatcher matches query parameters by glob pattern or exact values. +type L7QueryMatcher = types.L7QueryMatcher + +// GraphqlOperation describes a GraphQL operation for persisted-query validation. +type GraphqlOperation = types.GraphqlOperation + +// --- MergeOperation types --- + +// PolicyMergeOperation represents a single atomic policy mutation. +type PolicyMergeOperation = types.PolicyMergeOperation + +// AddNetworkRule adds a named network policy rule with a full rule definition. +type AddNetworkRule = types.AddNetworkRule + +// RemoveNetworkEndpoint removes a specific endpoint from a named rule. +type RemoveNetworkEndpoint = types.RemoveNetworkEndpoint + +// RemoveNetworkRule removes an entire named rule from the policy. +type RemoveNetworkRule = types.RemoveNetworkRule + +// AddDenyRules appends layer-7 deny rules to a specific endpoint. +type AddDenyRules = types.AddDenyRules + +// AddAllowRules appends layer-7 allow rules to a specific endpoint. +type AddAllowRules = types.AddAllowRules + +// RemoveNetworkBinary removes a binary from a named rule. +type RemoveNetworkBinary = types.RemoveNetworkBinary diff --git a/sdk/go/openshell/v1/watch.go b/sdk/go/openshell/v1/watch.go new file mode 100644 index 0000000000..696d8a9bd1 --- /dev/null +++ b/sdk/go/openshell/v1/watch.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Event represents a watch event carrying a resource that changed. +type Event[T any] = types.Event[T] + +// WatchInterface delivers a stream of typed events. Modeled after +// k8s.io/apimachinery/pkg/watch.Interface. +type WatchInterface[T any] = types.WatchInterface[T] + +type watcher[T any] struct { + result chan Event[T] + done chan struct{} + cancel context.CancelFunc + stopOnce sync.Once +} + +func newWatcher[T any](ch chan Event[T], cancel context.CancelFunc) *watcher[T] { + return &watcher[T]{ + result: ch, + done: make(chan struct{}), + cancel: cancel, + } +} + +func (w *watcher[T]) ResultChan() <-chan Event[T] { + return w.result +} + +func (w *watcher[T]) Stop() { + w.stopOnce.Do(func() { + close(w.done) + if w.cancel != nil { + w.cancel() + } + }) +} diff --git a/sdk/go/openshell/v1/watch_test.go b/sdk/go/openshell/v1/watch_test.go new file mode 100644 index 0000000000..1d6d5dc07a --- /dev/null +++ b/sdk/go/openshell/v1/watch_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestWatcher creates a watcher with a simulated producer goroutine that +// forwards events from src to the watcher's channel and closes it when the +// producer finishes or Stop is called. +func newTestWatcher(src <-chan Event[string]) *watcher[string] { + ch := make(chan Event[string], 10) + w := newWatcher(ch, nil) + go func() { + defer close(ch) + for { + select { + case ev, ok := <-src: + if !ok { + return + } + select { + case ch <- ev: + case <-w.done: + return + } + case <-w.done: + return + } + } + }() + return w +} + +// --- T038: WatchInterface event delivery, Stop, and error handling --- + +func TestWatcher_DeliversEvents(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventAdded, Object: "sandbox-1"} + src <- Event[string]{Type: EventModified, Object: "sandbox-1"} + + resultCh := w.ResultChan() + + ev1 := <-resultCh + assert.Equal(t, EventAdded, ev1.Type) + assert.Equal(t, "sandbox-1", ev1.Object) + + ev2 := <-resultCh + assert.Equal(t, EventModified, ev2.Type) + assert.Equal(t, "sandbox-1", ev2.Object) +} + +func TestWatcher_StopClosesChannel(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + w.Stop() + + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should be closed after Stop") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } +} + +func TestWatcher_StopIsIdempotent(_ *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + w.Stop() + w.Stop() // must not panic +} + +func TestWatcher_ErrorEvent(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventError, Object: "error details"} + + ev := <-w.ResultChan() + assert.Equal(t, EventError, ev.Type) + assert.Equal(t, "error details", ev.Object) +} + +func TestWatcher_ChannelClosesWhenSourceEnds(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventAdded, Object: "sb-1"} + close(src) + + ev := <-w.ResultChan() + require.Equal(t, "sb-1", ev.Object) + + select { + case _, ok := <-w.ResultChan(): + assert.False(t, ok, "channel should close when source ends") + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel to close") + } +} + +func TestWatcher_DrainAfterStop(t *testing.T) { + src := make(chan Event[string], 10) + w := newTestWatcher(src) + + src <- Event[string]{Type: EventAdded, Object: "sb-1"} + + ev := <-w.ResultChan() + require.Equal(t, "sb-1", ev.Object) + + w.Stop() + + timeout := time.After(time.Second) + for { + select { + case _, ok := <-w.ResultChan(): + if !ok { + return // success: channel closed + } + case <-timeout: + t.Fatal("timed out waiting for channel to close after Stop") + } + } +} diff --git a/sdk/go/proto/UPSTREAM_VERSION b/sdk/go/proto/UPSTREAM_VERSION new file mode 100644 index 0000000000..0ab4d79066 --- /dev/null +++ b/sdk/go/proto/UPSTREAM_VERSION @@ -0,0 +1 @@ +29ce6a704cba222c29b5e0d73b90280cf5ed3b9f diff --git a/sdk/go/proto/datamodel.proto b/sdk/go/proto/datamodel.proto new file mode 100644 index 0000000000..f92d7b7a36 --- /dev/null +++ b/sdk/go/proto/datamodel.proto @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.datamodel.v1; + +// Kubernetes-style metadata shared by all top-level OpenShell domain objects. +// +// This structure provides consistent metadata (identity, labels, timestamps, +// resource versioning) across Sandbox, Provider, SshSession, and other resources. +message ObjectMeta { + // Stable object ID generated by the gateway. + string id = 1; + + // Human-readable object name (unique per object type). + string name = 2; + + // Milliseconds since Unix epoch when the object was created. + int64 created_at_ms = 3; + + // Key-value labels for filtering and organization. + // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. + map labels = 4; + + // Optimistic concurrency control version. + // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. + uint64 resource_version = 5; +} + +// Provider model stored by OpenShell. +message Provider { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + ObjectMeta metadata = 1; + // Canonical provider type slug (for example: "claude", "gitlab"). + string type = 2; + // Secret values used for authentication. + map credentials = 3; + // Non-secret provider configuration. + map config = 4; + // Expiration timestamps for credential values, keyed by credential/env var + // name. A zero or missing value means the credential does not expire. + map credential_expires_at_ms = 5; +} diff --git a/sdk/go/proto/datamodelv1/datamodel.pb.go b/sdk/go/proto/datamodelv1/datamodel.pb.go new file mode 100644 index 0000000000..04e6848414 --- /dev/null +++ b/sdk/go/proto/datamodelv1/datamodel.pb.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.6 +// source: datamodel.proto + +package datamodelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Kubernetes-style metadata shared by all top-level OpenShell domain objects. +// +// This structure provides consistent metadata (identity, labels, timestamps, +// resource versioning) across Sandbox, Provider, SshSession, and other resources. +type ObjectMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable object ID generated by the gateway. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Human-readable object name (unique per object type). + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Milliseconds since Unix epoch when the object was created. + CreatedAtMs int64 `protobuf:"varint,3,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // Key-value labels for filtering and organization. + // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optimistic concurrency control version. + // Incremented by the gateway on each update. Clients can use this for compare-and-swap operations. + ResourceVersion uint64 `protobuf:"varint,5,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ObjectMeta) Reset() { + *x = ObjectMeta{} + mi := &file_datamodel_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ObjectMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ObjectMeta) ProtoMessage() {} + +func (x *ObjectMeta) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[0] + 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 ObjectMeta.ProtoReflect.Descriptor instead. +func (*ObjectMeta) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{0} +} + +func (x *ObjectMeta) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ObjectMeta) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ObjectMeta) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *ObjectMeta) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *ObjectMeta) GetResourceVersion() uint64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +// Provider model stored by OpenShell. +type Provider struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Canonical provider type slug (for example: "claude", "gitlab"). + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + // Secret values used for authentication. + Credentials map[string]string `protobuf:"bytes,3,rep,name=credentials,proto3" json:"credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Non-secret provider configuration. + Config map[string]string `protobuf:"bytes,4,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Expiration timestamps for credential values, keyed by credential/env var + // name. A zero or missing value means the credential does not expire. + CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,5,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Provider) Reset() { + *x = Provider{} + mi := &file_datamodel_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Provider) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Provider) ProtoMessage() {} + +func (x *Provider) ProtoReflect() protoreflect.Message { + mi := &file_datamodel_proto_msgTypes[1] + 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 Provider.ProtoReflect.Descriptor instead. +func (*Provider) Descriptor() ([]byte, []int) { + return file_datamodel_proto_rawDescGZIP(), []int{1} +} + +func (x *Provider) GetMetadata() *ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Provider) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Provider) GetCredentials() map[string]string { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *Provider) GetConfig() map[string]string { + if x != nil { + return x.Config + } + return nil +} + +func (x *Provider) GetCredentialExpiresAtMs() map[string]int64 { + if x != nil { + return x.CredentialExpiresAtMs + } + return nil +} + +var File_datamodel_proto protoreflect.FileDescriptor + +const file_datamodel_proto_rawDesc = "" + + "\n" + + "\x0fdatamodel.proto\x12\x16openshell.datamodel.v1\"\x82\x02\n" + + "\n" + + "ObjectMeta\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\"\n" + + "\rcreated_at_ms\x18\x03 \x01(\x03R\vcreatedAtMs\x12F\n" + + "\x06labels\x18\x04 \x03(\v2..openshell.datamodel.v1.ObjectMeta.LabelsEntryR\x06labels\x12)\n" + + "\x10resource_version\x18\x05 \x01(\x04R\x0fresourceVersion\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb4\x04\n" + + "\bProvider\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12S\n" + + "\vcredentials\x18\x03 \x03(\v21.openshell.datamodel.v1.Provider.CredentialsEntryR\vcredentials\x12D\n" + + "\x06config\x18\x04 \x03(\v2,.openshell.datamodel.v1.Provider.ConfigEntryR\x06config\x12t\n" + + "\x18credential_expires_at_ms\x18\x05 \x03(\v2;.openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x1a>\n" + + "\x10CredentialsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a9\n" + + "\vConfigEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01b\x06proto3" + +var ( + file_datamodel_proto_rawDescOnce sync.Once + file_datamodel_proto_rawDescData []byte +) + +func file_datamodel_proto_rawDescGZIP() []byte { + file_datamodel_proto_rawDescOnce.Do(func() { + file_datamodel_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_datamodel_proto_rawDesc), len(file_datamodel_proto_rawDesc))) + }) + return file_datamodel_proto_rawDescData +} + +var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_datamodel_proto_goTypes = []any{ + (*ObjectMeta)(nil), // 0: openshell.datamodel.v1.ObjectMeta + (*Provider)(nil), // 1: openshell.datamodel.v1.Provider + nil, // 2: openshell.datamodel.v1.ObjectMeta.LabelsEntry + nil, // 3: openshell.datamodel.v1.Provider.CredentialsEntry + nil, // 4: openshell.datamodel.v1.Provider.ConfigEntry + nil, // 5: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry +} +var file_datamodel_proto_depIdxs = []int32{ + 2, // 0: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry + 0, // 1: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 3, // 2: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry + 4, // 3: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry + 5, // 4: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry + 5, // [5:5] is the sub-list for method output_type + 5, // [5:5] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_datamodel_proto_init() } +func file_datamodel_proto_init() { + if File_datamodel_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_datamodel_proto_rawDesc), len(file_datamodel_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_datamodel_proto_goTypes, + DependencyIndexes: file_datamodel_proto_depIdxs, + MessageInfos: file_datamodel_proto_msgTypes, + }.Build() + File_datamodel_proto = out.File + file_datamodel_proto_goTypes = nil + file_datamodel_proto_depIdxs = nil +} diff --git a/sdk/go/proto/openshell.proto b/sdk/go/proto/openshell.proto new file mode 100644 index 0000000000..bf803e864a --- /dev/null +++ b/sdk/go/proto/openshell.proto @@ -0,0 +1,1899 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.v1; + +import "datamodel.proto"; +import "google/protobuf/struct.proto"; +import "sandbox.proto"; + +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +service OpenShell { + // Check the health of the service. + rpc Health(HealthRequest) returns (HealthResponse); + + // Create a new sandbox. + rpc CreateSandbox(CreateSandboxRequest) returns (SandboxResponse); + + // Fetch a sandbox by name. + rpc GetSandbox(GetSandboxRequest) returns (SandboxResponse); + + // List sandboxes. + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + + // List provider records attached to a sandbox. + rpc ListSandboxProviders(ListSandboxProvidersRequest) + returns (ListSandboxProvidersResponse); + + // Attach a provider record to an existing sandbox. + rpc AttachSandboxProvider(AttachSandboxProviderRequest) + returns (AttachSandboxProviderResponse); + + // Detach a provider record from an existing sandbox. + rpc DetachSandboxProvider(DetachSandboxProviderRequest) + returns (DetachSandboxProviderResponse); + + // Delete a sandbox by name. + rpc DeleteSandbox(DeleteSandboxRequest) returns (DeleteSandboxResponse); + + // Create a short-lived SSH session for a sandbox. + rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + + // Create or update a sandbox HTTP service endpoint for local routing. + rpc ExposeService(ExposeServiceRequest) returns (ServiceEndpointResponse); + + // Fetch one sandbox HTTP service endpoint. + rpc GetService(GetServiceRequest) returns (ServiceEndpointResponse); + + // List sandbox HTTP service endpoints. + rpc ListServices(ListServicesRequest) returns (ListServicesResponse); + + // Delete one sandbox HTTP service endpoint. + rpc DeleteService(DeleteServiceRequest) returns (DeleteServiceResponse); + + // Revoke a previously issued SSH session. + rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); + + // Execute a command in a ready sandbox and stream output. + rpc ExecSandbox(ExecSandboxRequest) returns (stream ExecSandboxEvent); + + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + rpc ForwardTcp(stream TcpForwardFrame) returns (stream TcpForwardFrame); + + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + rpc ExecSandboxInteractive(stream ExecSandboxInput) returns (stream ExecSandboxEvent); + + // Create a provider. + rpc CreateProvider(CreateProviderRequest) returns (ProviderResponse); + + // Fetch a provider by name. + rpc GetProvider(GetProviderRequest) returns (ProviderResponse); + + // List providers. + rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + + // List available provider type profiles. + rpc ListProviderProfiles(ListProviderProfilesRequest) + returns (ListProviderProfilesResponse); + + // Fetch one provider type profile by id. + rpc GetProviderProfile(GetProviderProfileRequest) + returns (ProviderProfileResponse); + + // Import custom provider type profiles. + rpc ImportProviderProfiles(ImportProviderProfilesRequest) + returns (ImportProviderProfilesResponse); + + // Update an existing custom provider type profile. + rpc UpdateProviderProfiles(UpdateProviderProfilesRequest) + returns (UpdateProviderProfilesResponse); + + // Validate provider type profiles without registering them. + rpc LintProviderProfiles(LintProviderProfilesRequest) + returns (LintProviderProfilesResponse); + + // Update an existing provider by name. + rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); + + // Fetch refresh status for one provider or provider credential. + rpc GetProviderRefreshStatus(GetProviderRefreshStatusRequest) + returns (GetProviderRefreshStatusResponse); + + // Configure gateway-owned refresh material for one provider credential. + rpc ConfigureProviderRefresh(ConfigureProviderRefreshRequest) + returns (ConfigureProviderRefreshResponse); + + // Record a gateway-owned refresh request for one provider credential. + rpc RotateProviderCredential(RotateProviderCredentialRequest) + returns (RotateProviderCredentialResponse); + + // Delete gateway-owned refresh configuration for one provider credential. + rpc DeleteProviderRefresh(DeleteProviderRefreshRequest) + returns (DeleteProviderRefreshResponse); + + // Delete a provider by name. + rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); + + // Delete a custom provider type profile by id. + rpc DeleteProviderProfile(DeleteProviderProfileRequest) + returns (DeleteProviderProfileResponse); + + // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + rpc GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) + returns (openshell.sandbox.v1.GetSandboxConfigResponse); + + // Get gateway-global settings. + rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) + returns (openshell.sandbox.v1.GetGatewayConfigResponse); + + // Update settings or policy at sandbox or global scope. + rpc UpdateConfig(UpdateConfigRequest) + returns (UpdateConfigResponse); + + // Get the load status of a specific policy version. + rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) + returns (GetSandboxPolicyStatusResponse); + + // List policy history for a sandbox. + rpc ListSandboxPolicies(ListSandboxPoliciesRequest) + returns (ListSandboxPoliciesResponse); + + // Report policy load result (called by sandbox after reload attempt). + rpc ReportPolicyStatus(ReportPolicyStatusRequest) + returns (ReportPolicyStatusResponse); + + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) + returns (GetSandboxProviderEnvironmentResponse); + + // Fetch recent sandbox logs (one-shot). + rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); + + // Push sandbox supervisor logs to the server (client-streaming). + rpc PushSandboxLogs(stream PushSandboxLogsRequest) returns (PushSandboxLogsResponse); + + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + rpc ConnectSupervisor(stream SupervisorMessage) returns (stream GatewayMessage); + + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + rpc RelayStream(stream RelayFrame) returns (stream RelayFrame); + + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + rpc WatchSandbox(WatchSandboxRequest) returns (stream SandboxStreamEvent); + + // --------------------------------------------------------------------------- + // Draft policy recommendation RPCs + // --------------------------------------------------------------------------- + + // Submit denial analysis results from sandbox (summaries + proposed chunks). + rpc SubmitPolicyAnalysis(SubmitPolicyAnalysisRequest) + returns (SubmitPolicyAnalysisResponse); + + // Get draft policy recommendations for a sandbox. + rpc GetDraftPolicy(GetDraftPolicyRequest) returns (GetDraftPolicyResponse); + + // Approve a single draft policy chunk (merges into active policy). + rpc ApproveDraftChunk(ApproveDraftChunkRequest) + returns (ApproveDraftChunkResponse); + + // Reject a single draft policy chunk. + rpc RejectDraftChunk(RejectDraftChunkRequest) + returns (RejectDraftChunkResponse); + + // Approve all pending draft chunks (skips security-flagged unless forced). + rpc ApproveAllDraftChunks(ApproveAllDraftChunksRequest) + returns (ApproveAllDraftChunksResponse); + + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + rpc EditDraftChunk(EditDraftChunkRequest) returns (EditDraftChunkResponse); + + // Reverse an approval (remove merged rule from active policy). + rpc UndoDraftChunk(UndoDraftChunkRequest) returns (UndoDraftChunkResponse); + + // Clear all pending draft chunks for a sandbox. + rpc ClearDraftChunks(ClearDraftChunksRequest) + returns (ClearDraftChunksResponse); + + // Get decision history for a sandbox's draft policy. + rpc GetDraftHistory(GetDraftHistoryRequest) returns (GetDraftHistoryResponse); + + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + rpc IssueSandboxToken(IssueSandboxTokenRequest) returns (IssueSandboxTokenResponse); + + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + rpc RefreshSandboxToken(RefreshSandboxTokenRequest) + returns (RefreshSandboxTokenResponse); +} + +// 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). +message IssueSandboxTokenRequest {} + +// IssueSandboxToken response. The supervisor caches the returned token in +// memory and presents it as `Authorization: Bearer` on every subsequent +// gateway RPC. +message IssueSandboxTokenResponse { + // Gateway-minted JWT bound to the calling sandbox's UUID. + string token = 1; + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 expires_at_ms = 2; +} + +// RefreshSandboxToken request. Empty body; the calling principal must +// already be a sandbox principal (i.e. the request carries a still-valid +// gateway-minted JWT in its Authorization header). +message RefreshSandboxTokenRequest {} + +// RefreshSandboxToken response. The new token replaces the supervisor's +// in-memory bearer credential. +message RefreshSandboxTokenResponse { + // Fresh gateway-minted JWT bound to the same sandbox UUID. + string token = 1; + // Absolute expiry of the new token, milliseconds since the epoch. 0 means + // the token is non-expiring. + int64 expires_at_ms = 2; +} + +// Health check request. +message HealthRequest {} + +// Health check response. +message HealthResponse { + // Service status. + ServiceStatus status = 1; + + // Service version. + string version = 2; +} + +// Public sandbox resource exposed by the OpenShell API. +// +// This is the canonical gateway-owned view of a sandbox. It merges user intent +// (`spec`) with gateway-managed metadata and status derived from internal +// compute-driver observations. +// +// Note: The `namespace` field has been removed from the public API. It remains +// in the internal `DriverSandbox` message as a compute-driver implementation detail. +message Sandbox { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Desired sandbox configuration submitted through the API. + SandboxSpec spec = 2; + // Latest user-facing observed status derived by the gateway. + SandboxStatus status = 3; + + reserved 4, 5; + reserved "phase", "current_policy_version"; +} + +// Desired sandbox configuration provided through the public API. +message SandboxSpec { + // Log level exposed to processes running inside the sandbox. + string log_level = 1; + // Environment variables injected into the sandbox runtime. + map environment = 5; + // Container or VM template used to provision the sandbox. + SandboxTemplate template = 6; + // Required sandbox policy configuration. + openshell.sandbox.v1.SandboxPolicy policy = 7; + // Provider names to attach to this sandbox. + repeated string providers = 8; + // Portable resource requirements used by the gateway for driver selection + // and by drivers for provisioning. + ResourceRequirements resource_requirements = 9; + reserved 10; + reserved "gpu_device"; + // Field 11 was `proposal_approval_mode`. The approval mode is now a + // runtime setting (gateway or sandbox scope) read via UpdateConfig / + // GetSandboxConfig, so it can be flipped on a running sandbox and + // managed fleet-wide. + reserved 11; + reserved "proposal_approval_mode"; +} + +message ResourceRequirements { + // GPU requirements for the sandbox. Presence indicates a GPU request. + GpuResourceRequirements gpu = 1; +} + +// Public GPU resource requirements. +message GpuResourceRequirements { + // Optional number of GPUs requested. When omitted, the request is for one + // GPU using the selected driver's default assignment behavior. + optional uint32 count = 1; +} + +// Public sandbox template mapped onto compute-driver template inputs. +message SandboxTemplate { + // Fully-qualified OCI image reference used to boot the sandbox. + string image = 1; + // Optional runtime class name requested from the compute platform. + string runtime_class_name = 2; + // Optional agent socket path exposed to the workload. + string agent_socket = 3; + // Labels applied to compute-platform resources for this sandbox. + map labels = 4; + // Annotations applied to compute-platform resources for this sandbox. + map annotations = 5; + // Additional environment variables injected by the template. + map environment = 6; + // Platform-specific compute resource requirements and limits. + google.protobuf.Struct resources = 7; + // Optional platform-specific volume claim templates. + google.protobuf.Struct volume_claim_templates = 9; + // Enable Kubernetes user namespace isolation (hostUsers: false). + // When true, container UID 0 maps to a non-root host UID and capabilities + // become namespaced. Requires Kubernetes 1.33+ with user namespace support + // available (beta through 1.35, GA in 1.36+) and a supporting runtime. + // When unset, the cluster-wide default is used. + optional bool user_namespaces = 10; + // Driver-keyed opaque config envelope supplied by the caller. + // The gateway selects the block matching the active compute driver and + // forwards only that inner Struct to DriverSandboxTemplate.driver_config. + // The selected driver owns nested schema validation. + google.protobuf.Struct driver_config = 11; +} + +// User-facing sandbox status derived by the gateway from compute-driver observations. +// +// Public status does not embed driver-only flags such as `deleting`. +message SandboxStatus { + // Compute-platform sandbox object name. + string sandbox_name = 1; + // Name of the agent pod or equivalent runtime instance. + string agent_pod = 2; + // File descriptor or endpoint for reaching the agent service, when available. + string agent_fd = 3; + // File descriptor or endpoint for reaching the sandbox service, when available. + string sandbox_fd = 4; + // Latest user-facing readiness and lifecycle conditions. + repeated SandboxCondition conditions = 5; + // Gateway-derived lifecycle summary. + SandboxPhase phase = 6; + // Currently active policy version (updated when sandbox reports loaded). + uint32 current_policy_version = 7; +} + +// User-facing sandbox condition derived from driver-native conditions. +message SandboxCondition { + // Condition class, typically mirroring the underlying platform condition type. + string type = 1; + // Condition status value such as `True`, `False`, or `Unknown`. + string status = 2; + // Short machine-readable reason associated with the condition. + string reason = 3; + // Human-readable condition message. + string message = 4; + // Timestamp reported by the underlying platform for the last transition. + string last_transition_time = 5; +} + +// High-level sandbox lifecycle phase derived by the gateway. +// +// Clients should rely on this normalized lifecycle summary for readiness and +// deletion decisions instead of interpreting raw conditions. +enum SandboxPhase { + SANDBOX_PHASE_UNSPECIFIED = 0; + SANDBOX_PHASE_PROVISIONING = 1; + SANDBOX_PHASE_READY = 2; + SANDBOX_PHASE_ERROR = 3; + SANDBOX_PHASE_DELETING = 4; + SANDBOX_PHASE_UNKNOWN = 5; +} + +// Public platform event exposed on the sandbox watch stream. +message PlatformEvent { + // Event timestamp in milliseconds since epoch. + int64 timestamp_ms = 1; + // Event source (e.g. "kubernetes", "docker", "process"). + string source = 2; + // Event type/severity (e.g. "Normal", "Warning"). + string type = 3; + // Short reason code (e.g. "Started", "Pulled", "Failed"). + string reason = 4; + // Human-readable event message. + string message = 5; + // Optional metadata as key-value pairs. + map metadata = 6; +} + +// Create sandbox request. +message CreateSandboxRequest { + SandboxSpec spec = 1; + // Optional user-supplied sandbox name. When empty the server generates one. + string name = 2; + // Optional labels for the sandbox (key-value metadata). + map labels = 3; +} + +// Get sandbox request. +message GetSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// List sandboxes request. +message ListSandboxesRequest { + uint32 limit = 1; + uint32 offset = 2; + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + string label_selector = 3; +} + +// List providers attached to a sandbox request. +message ListSandboxProvidersRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; +} + +// Attach provider to sandbox request. +message AttachSandboxProviderRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; + // Provider name to attach. + string provider_name = 2; + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + uint64 expected_resource_version = 3; +} + +// Detach provider from sandbox request. +message DetachSandboxProviderRequest { + // Sandbox name (canonical lookup key). + string sandbox_name = 1; + // Provider name to detach. + string provider_name = 2; + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + uint64 expected_resource_version = 3; +} + +// Delete sandbox request. +message DeleteSandboxRequest { + // Sandbox name (canonical lookup key). + string name = 1; +} + +// Sandbox response. +message SandboxResponse { + Sandbox sandbox = 1; +} + +// List sandboxes response. +message ListSandboxesResponse { + repeated Sandbox sandboxes = 1; +} + +// List providers attached to a sandbox response. +message ListSandboxProvidersResponse { + repeated openshell.datamodel.v1.Provider providers = 1; +} + +// Attach provider to sandbox response. +message AttachSandboxProviderResponse { + Sandbox sandbox = 1; + // True when the provider was newly attached. False means it was already attached. + bool attached = 2; +} + +// Detach provider from sandbox response. +message DetachSandboxProviderResponse { + Sandbox sandbox = 1; + // True when the provider was removed. False means it was not attached. + bool detached = 2; +} + +// Delete sandbox response. +message DeleteSandboxResponse { + bool deleted = 1; +} + +// Create SSH session request. +message CreateSshSessionRequest { + // Sandbox id. + string sandbox_id = 1; +} + +// Create SSH session response. +// +// Fields are interpolated into an SSH `ProxyCommand` string that OpenSSH +// executes through `/bin/sh -c` on the caller's workstation. Servers MUST +// uphold the charset contract below; clients MUST reject responses that +// violate it. The client's own escaping provides defense-in-depth, but +// narrow charsets close injection vectors at the trust boundary. +message CreateSshSessionResponse { + // Sandbox id. [A-Za-z0-9._-]{1,128}. + string sandbox_id = 1; + + // Session token for the gateway tunnel. URL-safe ASCII + // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or + // whitespace. + string token = 2; + + // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 + // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus + // `.-:[]` only, up to 253 bytes. + string gateway_host = 3; + + // Gateway port for SSH proxy connection. Must be in range 1..=65535. + uint32 gateway_port = 4; + + // Gateway scheme. Must be exactly "http" or "https". + string gateway_scheme = 5; + + // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. + string host_key_fingerprint = 7; + + // Expiry timestamp in milliseconds since epoch. 0 means no expiry. + int64 expires_at_ms = 8; +} + +// Request to expose an HTTP service running inside a sandbox. +message ExposeServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. + string service = 2; + // Loopback TCP port inside the sandbox. + uint32 target_port = 3; + // Whether to print/use the browser-facing service URL. + bool domain = 4; +} + +// Request to fetch an exposed sandbox service endpoint. +message GetServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 2; +} + +// Request to list exposed sandbox service endpoints. +message ListServicesRequest { + // Optional sandbox name. Empty lists endpoints for all sandboxes. + string sandbox = 1; + // Page size. Zero uses the server default. + uint32 limit = 2; + // Page offset. + uint32 offset = 3; +} + +// Response containing exposed sandbox service endpoints. +message ListServicesResponse { + repeated ServiceEndpointResponse services = 1; +} + +// Request to delete an exposed sandbox service endpoint. +message DeleteServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. Empty selects the unnamed endpoint. + string service = 2; +} + +// Response for deleting an exposed sandbox service endpoint. +message DeleteServiceResponse { + // True when an endpoint existed and was deleted. + bool deleted = 1; +} + +// Persisted sandbox service endpoint. +message ServiceEndpoint { + // Kubernetes-style metadata. + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Sandbox object ID. + string sandbox_id = 2; + // Sandbox name. + string sandbox_name = 3; + // Service name within the sandbox. + string service_name = 4; + // Loopback TCP port inside the sandbox. + uint32 target_port = 5; + // Whether browser-facing service routing is enabled for this endpoint. + bool domain = 6; +} + +// Response containing a service endpoint and, when available, its local URL. +message ServiceEndpointResponse { + ServiceEndpoint endpoint = 1; + string url = 2; +} + +// Revoke SSH session request. +message RevokeSshSessionRequest { + // Session token to revoke. + string token = 1; +} + +// Revoke SSH session response. +message RevokeSshSessionResponse { + // True when a session was revoked. + bool revoked = 1; +} + +// Execute command request. +message ExecSandboxRequest { + // Sandbox id. + string sandbox_id = 1; + + // Command and arguments. + repeated string command = 2; + + // Optional working directory. + string workdir = 3; + + // Optional environment overrides. + map environment = 4; + + // Optional timeout in seconds. 0 means no timeout. + uint32 timeout_seconds = 5; + + // Optional stdin payload passed to the command. + bytes stdin = 6; + + // Request a pseudo-terminal for the remote command. + bool tty = 7; + + // Initial terminal columns (used when tty=true, 0 = use default). + uint32 cols = 8; + + // Initial terminal rows (used when tty=true, 0 = use default). + uint32 rows = 9; +} + +// One stdout chunk from a sandbox exec. +message ExecSandboxStdout { + bytes data = 1; +} + +// One stderr chunk from a sandbox exec. +message ExecSandboxStderr { + bytes data = 1; +} + +// Final exit status for a sandbox exec. +message ExecSandboxExit { + int32 exit_code = 1; +} + +// One event in a sandbox exec stream. +message ExecSandboxEvent { + oneof payload { + ExecSandboxStdout stdout = 1; + ExecSandboxStderr stderr = 2; + ExecSandboxExit exit = 3; + } +} + +// Initial frame for one TCP forward stream. +message TcpForwardInit { + // Sandbox id. + string sandbox_id = 1; + // Optional service identifier for audit/correlation. + string service_id = 4; + // Target the gateway should request from the supervisor. + oneof target { + SshRelayTarget ssh = 5; + TcpRelayTarget tcp = 6; + } + // Optional target-specific authorization token. SSH targets use this as the + // short-lived SSH session token issued by CreateSshSession. + string authorization_token = 7; +} + +// A single frame on the CLI-to-gateway TCP forward stream. +message TcpForwardFrame { + oneof payload { + TcpForwardInit init = 1; + bytes data = 2; + } +} + +// Client-to-server message for interactive exec. +message ExecSandboxInput { + oneof payload { + // First message: exec request metadata. + ExecSandboxRequest start = 1; + // Subsequent messages: raw stdin bytes. + bytes stdin = 2; + // Terminal window size change. + ExecSandboxWindowResize resize = 3; + } +} + +// Terminal window resize event for interactive exec. +message ExecSandboxWindowResize { + uint32 cols = 1; + uint32 rows = 2; +} + + +// SSH session record stored in persistence. +message SshSession { + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + openshell.datamodel.v1.ObjectMeta metadata = 1; + + // Sandbox id. + string sandbox_id = 2; + + // Session token. + string token = 3; + + // Expiry timestamp in milliseconds since epoch. 0 means no expiry + // (backward-compatible default for sessions created before this field existed). + int64 expires_at_ms = 4; + + // Revoked flag. + bool revoked = 5; +} + +// Watch sandbox request. +message WatchSandboxRequest { + // Sandbox id. + string id = 1; + + // Stream sandbox status snapshots. + bool follow_status = 2; + + // Stream openshell-server process logs correlated to this sandbox. + bool follow_logs = 3; + + // Stream platform events correlated to this sandbox. + bool follow_events = 4; + + // Replay the last N log lines (best-effort) before following. + uint32 log_tail_lines = 5; + + // 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). + bool stop_on_terminal = 7; + + // Only include log lines with timestamp >= this value (milliseconds since epoch). + // 0 means no time filter. Applies to both tail replay and live streaming. + int64 log_since_ms = 8; + + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string log_sources = 9; + + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string log_min_level = 10; +} + +// One event in a sandbox watch stream. +message SandboxStreamEvent { + oneof payload { + // Latest sandbox snapshot. + Sandbox sandbox = 1; + // One server log line/event. + SandboxLogLine log = 2; + // One platform event. + PlatformEvent event = 3; + // Warning from the server (e.g. missed messages due to lag). + SandboxStreamWarning warning = 4; + // Draft policy update notification. + DraftPolicyUpdate draft_policy_update = 5; + } +} + +// Log line correlated to a sandbox. +message SandboxLogLine { + string sandbox_id = 1; + int64 timestamp_ms = 2; + string level = 3; + string target = 4; + string message = 5; + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + string source = 6; + // Structured key-value fields from the tracing event (e.g. dst_host, action). + map fields = 7; +} + +message SandboxStreamWarning { + string message = 1; +} + +// Create provider request. +message CreateProviderRequest { + openshell.datamodel.v1.Provider provider = 1; +} + +// Get provider request. +message GetProviderRequest { + string name = 1; +} + +// List providers request. +message ListProvidersRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Update provider request. +message UpdateProviderRequest { + openshell.datamodel.v1.Provider provider = 1; + // Optional per-credential expiry timestamps to merge into the provider. + // A zero value removes the expiry for that credential. + map credential_expires_at_ms = 2; +} + +// Delete provider request. +message DeleteProviderRequest { + string name = 1; +} + +// Provider response. +message ProviderResponse { + openshell.datamodel.v1.Provider provider = 1; +} + +// List providers response. +message ListProvidersResponse { + repeated openshell.datamodel.v1.Provider providers = 1; +} + +// List provider type profiles request. +message ListProviderProfilesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Fetch provider type profile request. +message GetProviderProfileRequest { + string id = 1; +} + +// Provider profile payload with optional source metadata for diagnostics. +message ProviderProfileImportItem { + ProviderProfile profile = 1; + string source = 2; +} + +// Provider profile validation diagnostic. +message ProviderProfileDiagnostic { + string source = 1; + string profile_id = 2; + string field = 3; + string message = 4; + string severity = 5; +} + +// Endpoint selector for token grant audience overrides. +message ProviderCredentialTokenGrantAudienceOverride { + // Optional: endpoint host selector. If omitted, inherits the profile endpoint host. + string host = 1; + + // Optional: endpoint port selector. If omitted, matches the expanded profile endpoint port. + uint32 port = 2; + + // Optional: endpoint path selector. If omitted, inherits the profile endpoint path. + string path = 3; + + // Resource audience to request for matching endpoints. + string audience = 4; + + // Optional: OAuth2 scopes to request. If omitted, inherits the token grant scopes. + repeated string scopes = 5; +} + +// Provider credential token grant configuration. +// When present, the credential is obtained dynamically via OAuth2 grant when needed. +message ProviderCredentialTokenGrant { + // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) + string token_endpoint = 1; + + // Optional: default resource audience to request from the token service + string audience = 2; + + // Optional: audience to request when fetching the JWT-SVID from SPIRE. + // If omitted, the sandbox derives this from token_endpoint. + string jwt_svid_audience = 6; + + // Optional: OAuth2 scopes to request + repeated string scopes = 3; + + // Optional: override token cache TTL (seconds) + // If 0 or omitted, use expires_in from token response + int64 cache_ttl_seconds = 4; + + // Optional: endpoint-specific resource audience overrides. + repeated ProviderCredentialTokenGrantAudienceOverride audience_overrides = 5; + + // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses + // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. + string client_assertion_type = 7; +} + +// Provider credential declaration. +message ProviderProfileCredential { + string name = 1; + string description = 2; + repeated string env_vars = 3; + bool required = 4; + string auth_style = 5; + string header_name = 6; + string query_param = 7; + ProviderCredentialRefresh refresh = 8; + string path_template = 9; + ProviderCredentialTokenGrant token_grant = 10; +} + +enum ProviderCredentialRefreshStrategy { + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED = 0; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC = 1; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL = 2; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN = 3; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; +} + +message ProviderCredentialRefreshMaterial { + string name = 1; + string description = 2; + bool required = 3; + bool secret = 4; +} + +message ProviderCredentialRefresh { + ProviderCredentialRefreshStrategy strategy = 1; + string token_url = 2; + repeated string scopes = 3; + int64 refresh_before_seconds = 4; + int64 max_lifetime_seconds = 5; + repeated ProviderCredentialRefreshMaterial material = 6; +} + +message ProviderCredentialRefreshStatus { + string provider_name = 1; + string provider_id = 2; + string credential_key = 3; + ProviderCredentialRefreshStrategy strategy = 4; + string status = 5; + int64 expires_at_ms = 6; + int64 next_refresh_at_ms = 7; + int64 last_refresh_at_ms = 8; + string last_error = 9; +} + +// Provider profile local discovery declaration. +message ProviderProfileDiscovery { + // Credential names from ProviderProfile.credentials eligible for local discovery. + repeated string credentials = 1; +} + +message StoredProviderCredentialRefreshState { + openshell.datamodel.v1.ObjectMeta metadata = 1; + string provider_id = 2; + string provider_name = 3; + string credential_key = 4; + ProviderCredentialRefreshStrategy strategy = 5; + map material = 6; + repeated string secret_material_keys = 7; + int64 expires_at_ms = 8; + int64 next_refresh_at_ms = 9; + int64 last_refresh_at_ms = 10; + string status = 11; + string last_error = 12; + string token_url = 13; + repeated string scopes = 14; + int64 refresh_before_seconds = 15; + int64 max_lifetime_seconds = 16; +} + +message GetProviderRefreshStatusRequest { + string provider = 1; + string credential_key = 2; +} + +message GetProviderRefreshStatusResponse { + repeated ProviderCredentialRefreshStatus credentials = 1; +} + +message ConfigureProviderRefreshRequest { + string provider = 1; + string credential_key = 2; + ProviderCredentialRefreshStrategy strategy = 3; + map material = 4; + repeated string secret_material_keys = 5; + optional int64 expires_at_ms = 6; +} + +message ConfigureProviderRefreshResponse { + ProviderCredentialRefreshStatus status = 1; +} + +message RotateProviderCredentialRequest { + string provider = 1; + string credential_key = 2; +} + +message RotateProviderCredentialResponse { + ProviderCredentialRefreshStatus status = 1; +} + +message DeleteProviderRefreshRequest { + string provider = 1; + string credential_key = 2; +} + +message DeleteProviderRefreshResponse { + bool deleted = 1; +} + +// Stable provider profile categories used by clients for grouping and filtering. +enum ProviderProfileCategory { + PROVIDER_PROFILE_CATEGORY_UNSPECIFIED = 0; + PROVIDER_PROFILE_CATEGORY_OTHER = 1; + PROVIDER_PROFILE_CATEGORY_INFERENCE = 2; + PROVIDER_PROFILE_CATEGORY_AGENT = 3; + PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL = 4; + PROVIDER_PROFILE_CATEGORY_MESSAGING = 5; + PROVIDER_PROFILE_CATEGORY_DATA = 6; + PROVIDER_PROFILE_CATEGORY_KNOWLEDGE = 7; +} + +// Provider type profile metadata exposed to clients. +message ProviderProfile { + string id = 1; + string display_name = 2; + string description = 3; + ProviderProfileCategory category = 4; + repeated ProviderProfileCredential credentials = 5; + repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; + repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + bool inference_capable = 8; + ProviderProfileDiscovery discovery = 9; + // Storage resource version for custom profiles. Built-in profiles and new + // profile files use 0. Gateway responses set this for stored custom profiles. + // Update calls use this for optimistic concurrency. + uint64 resource_version = 10; +} + +// Stored custom provider profile object. +message StoredProviderProfile { + openshell.datamodel.v1.ObjectMeta metadata = 1; + ProviderProfile profile = 2; +} + +// Provider profile response. +message ProviderProfileResponse { + ProviderProfile profile = 1; +} + +// List provider profiles response. +message ListProviderProfilesResponse { + repeated ProviderProfile profiles = 1; +} + +// Import custom provider profiles request. +message ImportProviderProfilesRequest { + repeated ProviderProfileImportItem profiles = 1; +} + +// Import custom provider profiles response. +message ImportProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + repeated ProviderProfile profiles = 2; + bool imported = 3; +} + +// Update one custom provider profile request. +message UpdateProviderProfilesRequest { + ProviderProfileImportItem profile = 1; + // Expected storage resource version for optimistic concurrency control. + // If 0, the server uses the resource_version embedded in profile.profile. + // Updates without a non-zero version are rejected to prevent stale files from + // silently overwriting newer profile definitions. + uint64 expected_resource_version = 2; + // Existing custom provider profile ID to update. The payload ID must match. + string id = 3; +} + +// Update one custom provider profile response. +message UpdateProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + ProviderProfile profile = 2; + bool updated = 3; +} + +// Lint provider profiles request. +message LintProviderProfilesRequest { + repeated ProviderProfileImportItem profiles = 1; +} + +// Lint provider profiles response. +message LintProviderProfilesResponse { + repeated ProviderProfileDiagnostic diagnostics = 1; + bool valid = 2; +} + +// Delete provider response. +message DeleteProviderResponse { + bool deleted = 1; +} + +// Delete custom provider profile request. +message DeleteProviderProfileRequest { + string id = 1; +} + +// Delete custom provider profile response. +message DeleteProviderProfileResponse { + bool deleted = 1; +} + +// Get sandbox provider environment request. +message GetSandboxProviderEnvironmentRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Get sandbox provider environment response. +message GetSandboxProviderEnvironmentResponse { + // Provider credential environment variables. + map environment = 1; + // Fingerprint for the provider credential inputs that produced environment. + uint64 provider_env_revision = 2; + // Expiration timestamps for returned environment variables. + map credential_expires_at_ms = 3; + // Dynamic credentials that require token grants or other runtime injection. + // Maps endpoint-bound provider metadata to credential metadata. + // Supervisor uses this to inject Authorization headers for token grant credentials. + map dynamic_credentials = 4; +} + +// --------------------------------------------------------------------------- +// Policy update messages +// --------------------------------------------------------------------------- + +// Update sandbox policy request. +message UpdateConfigRequest { + // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. + // Not required when `global=true`. + string name = 1; + // The new policy to apply. + // + // Sandbox scope (`global=false`): + // - only network_policies and inference fields may differ from create-time + // policy; static fields must match version 1. + // + // Global scope (`global=true`): + // - applies to all sandboxes in full (no merge). + openshell.sandbox.v1.SandboxPolicy policy = 2; + // Optional single setting key to mutate. + string setting_key = 3; + // Setting value for upsert operations. + openshell.sandbox.v1.SettingValue setting_value = 4; + // Delete the setting key from scope. + // Sandbox-scoped deletes are rejected; only global delete is supported. + bool delete_setting = 5; + // Apply mutation at gateway-global scope. + bool global = 6; + // Batched incremental policy merge operations. Sandbox-scoped only. + repeated PolicyMergeOperation merge_operations = 7; + // Expected resource version for optimistic concurrency control (sandbox-scoped only). + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + // Ignored for global-scoped updates. + uint64 expected_resource_version = 8; +} + +message PolicyMergeOperation { + oneof operation { + AddNetworkRule add_rule = 1; + RemoveNetworkEndpoint remove_endpoint = 2; + RemoveNetworkRule remove_rule = 3; + AddDenyRules add_deny_rules = 4; + AddAllowRules add_allow_rules = 5; + RemoveNetworkBinary remove_binary = 6; + } +} + +message AddNetworkRule { + string rule_name = 1; + openshell.sandbox.v1.NetworkPolicyRule rule = 2; +} + +message RemoveNetworkEndpoint { + string rule_name = 1; + string host = 2; + uint32 port = 3; +} + +message RemoveNetworkRule { + string rule_name = 1; +} + +message AddDenyRules { + string host = 1; + uint32 port = 2; + repeated openshell.sandbox.v1.L7DenyRule deny_rules = 3; +} + +message AddAllowRules { + string host = 1; + uint32 port = 2; + repeated openshell.sandbox.v1.L7Rule rules = 3; +} + +message RemoveNetworkBinary { + string rule_name = 1; + string binary_path = 2; +} + +// Update sandbox policy response. +message UpdateConfigResponse { + // Assigned policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Settings revision for the scope that was modified. + uint64 settings_revision = 3; + // True when a setting delete operation removed an existing key. + bool deleted = 4; +} + +// Get sandbox policy status request. +message GetSandboxPolicyStatusRequest { + // Sandbox name (canonical lookup key). Ignored when global is true. + string name = 1; + // The specific policy version to query. 0 means latest. + uint32 version = 2; + // Query global policy revisions instead of a sandbox-scoped one. + bool global = 3; +} + +// Get sandbox policy status response. +message GetSandboxPolicyStatusResponse { + // The queried policy revision. + SandboxPolicyRevision revision = 1; + // The currently active (loaded) policy version for this sandbox. + uint32 active_version = 2; +} + +// List sandbox policies request. +message ListSandboxPoliciesRequest { + // Sandbox name (canonical lookup key). Ignored when global is true. + string name = 1; + uint32 limit = 2; + uint32 offset = 3; + // List global policy revisions instead of sandbox-scoped ones. + bool global = 4; +} + +// List sandbox policies response. +message ListSandboxPoliciesResponse { + repeated SandboxPolicyRevision revisions = 1; +} + +// Report policy load status (called by sandbox runtime after reload attempt). +message ReportPolicyStatusRequest { + // Sandbox id. + string sandbox_id = 1; + // The policy version that was attempted. + uint32 version = 2; + // Load result status. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; +} + +// Report policy status response. +message ReportPolicyStatusResponse {} + +// A versioned policy revision with metadata. +message SandboxPolicyRevision { + // Policy version (monotonically increasing per sandbox). + uint32 version = 1; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 2; + // Load status of this revision. + PolicyStatus status = 3; + // Error message if status is FAILED. + string load_error = 4; + // Milliseconds since epoch when this revision was created. + int64 created_at_ms = 5; + // Milliseconds since epoch when this revision was loaded by the sandbox. + int64 loaded_at_ms = 6; + // The full policy (only populated when explicitly requested). + openshell.sandbox.v1.SandboxPolicy policy = 7; +} + +// Policy load status. +enum PolicyStatus { + POLICY_STATUS_UNSPECIFIED = 0; + // Server received the update; sandbox has not yet loaded it. + POLICY_STATUS_PENDING = 1; + // Sandbox successfully applied this policy version. + POLICY_STATUS_LOADED = 2; + // Sandbox attempted to apply but failed; LKG policy remains active. + POLICY_STATUS_FAILED = 3; + // A newer version was persisted before the sandbox loaded this one. + POLICY_STATUS_SUPERSEDED = 4; +} + +// --------------------------------------------------------------------------- +// Sandbox logs messages +// --------------------------------------------------------------------------- + +// Get sandbox logs request (one-shot fetch). +message GetSandboxLogsRequest { + // Sandbox id. + string sandbox_id = 1; + // Maximum number of log lines to return. 0 means use default (2000). + uint32 lines = 2; + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + int64 since_ms = 3; + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + repeated string sources = 4; + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + string min_level = 5; +} + +// Batch of log lines pushed from sandbox to server. +message PushSandboxLogsRequest { + // The sandbox ID. + string sandbox_id = 1; + // Log lines to ingest. + repeated SandboxLogLine logs = 2; +} + +// Push sandbox logs response. +message PushSandboxLogsResponse {} + +// Get sandbox logs response. +message GetSandboxLogsResponse { + // Log lines in chronological order. + repeated SandboxLogLine logs = 1; + // Total number of lines in the server's buffer for this sandbox. + uint32 buffer_total = 2; +} + +// --------------------------------------------------------------------------- +// Supervisor session messages +// --------------------------------------------------------------------------- + +// Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream. +message SupervisorMessage { + oneof payload { + SupervisorHello hello = 1; + SupervisorHeartbeat heartbeat = 2; + RelayOpenResult relay_open_result = 3; + RelayClose relay_close = 4; + } +} + +// Envelope for gateway-to-supervisor messages on the ConnectSupervisor stream. +message GatewayMessage { + oneof payload { + SessionAccepted session_accepted = 1; + SessionRejected session_rejected = 2; + GatewayHeartbeat heartbeat = 3; + RelayOpen relay_open = 4; + RelayClose relay_close = 5; + } +} + +// Supervisor identifies itself and the sandbox it manages. +message SupervisorHello { + // Sandbox ID this supervisor manages. + string sandbox_id = 1; + // Supervisor instance ID (e.g. boot id or process epoch). + string instance_id = 2; +} + +// Gateway accepts the supervisor session. +message SessionAccepted { + // Gateway-assigned session ID for this connection. + string session_id = 1; + // Recommended heartbeat interval in seconds. + uint32 heartbeat_interval_secs = 2; +} + +// Gateway rejects the supervisor session. +message SessionRejected { + // Human-readable rejection reason. + string reason = 1; +} + +// Supervisor heartbeat. +message SupervisorHeartbeat {} + +// Gateway heartbeat. +message GatewayHeartbeat {} + +// Gateway requests the supervisor to open a relay channel. +// +// On receiving this, the supervisor should initiate a RelayStream RPC to +// the gateway, sending a RelayInit in the first RelayFrame to associate +// the new HTTP/2 stream with the pending relay slot. The supervisor +// bridges that stream to the requested local target. +message RelayOpen { + // Gateway-allocated channel identifier (UUID). + string channel_id = 1; + // Target the supervisor should dial inside the sandbox. + // If absent, supervisors treat the relay as SSH for compatibility. + oneof target { + SshRelayTarget ssh = 2; + TcpRelayTarget tcp = 3; + } + // Optional service identifier for audit/correlation. + string service_id = 5; +} + +// Built-in SSH relay target. +message SshRelayTarget {} + +// TCP target dialed by the supervisor from inside the sandbox. +message TcpRelayTarget { + // Phase 1 accepts loopback only: 127.0.0.1, ::1, or localhost. + string host = 1; + // Target port. Must fit in u16 and be non-zero. + uint32 port = 2; +} + +// Initial RelayStream frame sent by the supervisor to claim a pending relay. +message RelayInit { + // Gateway-allocated channel identifier (UUID). + string channel_id = 1; +} + +// A single frame on the RelayStream RPC. +// +// The supervisor MUST send `init` as the first frame. All subsequent frames +// in either direction carry raw bytes in `data`. +message RelayFrame { + oneof payload { + RelayInit init = 1; + bytes data = 2; + } +} + +// Supervisor reports the result of a relay open request. +message RelayOpenResult { + // Channel identifier from the RelayOpen request. + string channel_id = 1; + // True if the relay was successfully established. + bool success = 2; + // Error message if success is false. + string error = 3; +} + +// Either side requests closure of a relay channel. +message RelayClose { + // Channel identifier to close. + string channel_id = 1; + // Optional reason for closure. + string reason = 2; +} + +// --------------------------------------------------------------------------- +// Service status +// --------------------------------------------------------------------------- + +// Service status enum. +enum ServiceStatus { + SERVICE_STATUS_UNSPECIFIED = 0; + SERVICE_STATUS_HEALTHY = 1; + SERVICE_STATUS_DEGRADED = 2; + SERVICE_STATUS_UNHEALTHY = 3; +} + +// --------------------------------------------------------------------------- +// Draft policy recommendation messages +// --------------------------------------------------------------------------- + +// Observed HTTP method+path pattern from L7 inspection. +message L7RequestSample { + // HTTP method: GET, POST, PUT, DELETE, etc. + string method = 1; + // HTTP path: /v1/models, /repos/myorg/issues + string path = 2; + // L7 decision: "audit" or "deny" (allowed requests not collected). + string decision = 3; + // Number of times this (method, path) was observed. + uint32 count = 4; +} + +// Structured denial summary from sandbox aggregator. +message DenialSummary { + // Sandbox ID that produced this summary. + string sandbox_id = 1; + // Denied destination host. + string host = 2; + // Denied destination port. + uint32 port = 3; + // Binary that attempted the connection. + string binary = 4; + // Process ancestor chain. + repeated string ancestors = 5; + // Denial reason from OPA evaluation. + string deny_reason = 6; + // First denial timestamp (ms since epoch). + int64 first_seen_ms = 7; + // Most recent denial timestamp (ms since epoch). + int64 last_seen_ms = 8; + // Number of denials in the current window. + uint32 count = 9; + // Events dropped during aggregator cooldown. + uint32 suppressed_count = 10; + // Cumulative lifetime count (never resets). + uint32 total_count = 11; + // Distinct cmdline strings observed (sanitized of credentials). + repeated string sample_cmdlines = 12; + // SHA-256 of the binary for audit trail. + string binary_sha256 = 13; + // True if emitted by stale-flush rather than threshold. + bool persistent = 14; + // Denial category: "l4_deny", "l7_deny", "l7_audit", "ssrf". + string denial_stage = 15; + // Observed HTTP request patterns (from L7 inspection). + repeated L7RequestSample l7_request_samples = 16; + // True if L7 inspection was active during observation window. + bool l7_inspection_active = 17; +} + +// Count of denied actions grouped only by sanitized telemetry category. +message DenialGroupCount { + // Sanitized denial category, e.g. "connect_policy", "l7_policy", "ssrf". + string deny_group = 1; + // Number of denied actions in this category. + uint32 denied_count = 2; +} + +// Anonymous sandbox network activity counters. This intentionally excludes +// hosts, paths, binaries, raw deny reasons, sandbox IDs, and user content. +message NetworkActivitySummary { + // Total observed network activities in the current window. + uint32 network_activity_count = 1; + // Total denied actions in the current window. + uint32 denied_action_count = 2; + // Denied action counts grouped by sanitized category. + repeated DenialGroupCount denials_by_group = 3; +} + +// A proposed policy rule with rationale and approval status. +message PolicyChunk { + // Unique chunk identifier. + string id = 1; + // Approval status: "pending", "approved", "rejected". + string status = 2; + // Proposed network_policies map key. + string rule_name = 3; + // The proposed network policy rule. + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 4; + // Human-readable explanation of why this rule is proposed. + string rationale = 5; + // Security concerns flagged by analysis (empty if none). + string security_notes = 6; + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + float confidence = 7; + // IDs of denial summaries that led to this chunk. + repeated string denial_summary_ids = 8; + // Creation timestamp (ms since epoch). + int64 created_at_ms = 9; + // When the user approved/rejected (ms since epoch). 0 if undecided. + int64 decided_at_ms = 10; + // Recommendation stage: "initial" or "refined" (progressive L7 visibility). + string stage = 11; + // For stage="refined": the initial chunk this replaces. + string supersedes_chunk_id = 12; + // How many times this endpoint has been seen across denial flush cycles. + int32 hit_count = 13; + // First time this endpoint was proposed (ms since epoch). + int64 first_seen_ms = 14; + // Most recent time this endpoint was re-proposed (ms since epoch). + int64 last_seen_ms = 15; + // Binary path that triggered the denial (denormalized for display convenience). + string binary = 16; + // Validation verdict from gateway-side static checks (prover output). + // Free-form summary string for human consumption in the inbox card. + // Empty until the prover has run for this chunk. + string validation_result = 17; + // Operator-supplied free-form text accompanying a rejection. Populated + // when the reviewer rejects via `RejectDraftChunkRequest.reason`; surfaced + // back to the in-sandbox agent so it can revise the proposal. + // Empty for non-rejected chunks. + string rejection_reason = 18; +} + +// Notification that the draft policy was updated. +message DraftPolicyUpdate { + // Current draft version. + uint64 draft_version = 1; + // Number of new chunks added in this update. + uint32 new_chunks = 2; + // Total pending chunks awaiting approval. + uint32 total_pending = 3; + // Brief description of what changed. + string summary = 4; +} + +// Submit analysis results from sandbox to gateway. +message SubmitPolicyAnalysisRequest { + // Aggregated denial summaries. + repeated DenialSummary summaries = 1; + // Proposed policy chunks (validated by sandbox OPA engine). + repeated PolicyChunk proposed_chunks = 2; + // Analysis mode. `mechanistic` is the observation-driven path from the + // denial aggregator — chunks targeting the same host|port|binary fold + // into one row with hit_count incremented. `agent_authored` is an + // intentional proposal from an in-sandbox agent — each submission lands + // as its own chunk so the redraft-after-rejection loop has a stable id + // to watch. Other values are treated as agent-style (no dedup) so a new + // mode does not silently collapse proposals. + string analysis_mode = 3; + // Sandbox name. + string name = 4; + // Anonymous network activity counters. + repeated NetworkActivitySummary network_activity_summaries = 5; +} + +message SubmitPolicyAnalysisResponse { + // Number of chunks accepted by the gateway. + uint32 accepted_chunks = 1; + // Number of chunks rejected by gateway validation. + uint32 rejected_chunks = 2; + // Reasons for each rejected chunk. + repeated string rejection_reasons = 3; + // Server-assigned chunk IDs for the accepted chunks, in submission order. + // Agents use these to watch proposal state via policy.local's + // GET /v1/proposals/{id} and /wait endpoints. + repeated string accepted_chunk_ids = 4; +} + +// Get draft policy for a sandbox. +message GetDraftPolicyRequest { + // Sandbox name. + string name = 1; + // Optional status filter: "pending", "approved", "rejected", or "" for all. + string status_filter = 2; +} + +message GetDraftPolicyResponse { + // Draft policy chunks. + repeated PolicyChunk chunks = 1; + // LLM-generated summary of all analysis (empty in mechanistic mode). + string rolling_summary = 2; + // Current draft version. + uint64 draft_version = 3; + // When the last analysis completed (ms since epoch). + int64 last_analyzed_at_ms = 4; +} + +// Approve a single draft chunk. +message ApproveDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to approve. + string chunk_id = 2; +} + +message ApproveDraftChunkResponse { + // New policy version after merge. + uint32 policy_version = 1; + // SHA-256 hash of the new policy. + string policy_hash = 2; +} + +// Reject a single draft chunk. +message RejectDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to reject. + string chunk_id = 2; + // Optional reason for rejection (fed to LLM context in future analysis). + string reason = 3; +} + +message RejectDraftChunkResponse {} + +// Approve all pending chunks. +message ApproveAllDraftChunksRequest { + // Sandbox name. + string name = 1; + // Include chunks with security_notes (default false: skips them). + bool include_security_flagged = 2; +} + +message ApproveAllDraftChunksResponse { + // New policy version after merge. + uint32 policy_version = 1; + // SHA-256 hash of the new policy. + string policy_hash = 2; + // Number of chunks approved. + uint32 chunks_approved = 3; + // Number of chunks skipped (security-flagged). + uint32 chunks_skipped = 4; +} + +// Edit a pending chunk in-place. +message EditDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to edit. + string chunk_id = 2; + // The modified rule (replaces existing proposed_rule). + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; +} + +message EditDraftChunkResponse {} + +// Reverse an approval (remove merged rule from active policy). +message UndoDraftChunkRequest { + // Sandbox name. + string name = 1; + // Chunk ID to undo. + string chunk_id = 2; +} + +message UndoDraftChunkResponse { + // New policy version after removal. + uint32 policy_version = 1; + // SHA-256 hash of the updated policy. + string policy_hash = 2; +} + +// Clear all pending draft chunks for a sandbox. +message ClearDraftChunksRequest { + // Sandbox name. + string name = 1; +} + +message ClearDraftChunksResponse { + // Number of chunks cleared. + uint32 chunks_cleared = 1; +} + +// Get decision history for a sandbox's draft policy. +message GetDraftHistoryRequest { + // Sandbox name. + string name = 1; +} + +message DraftHistoryEntry { + // Event timestamp (ms since epoch). + int64 timestamp_ms = 1; + // Event type: "denial_detected", "analysis_cycle", "approved", + // "rejected", "edited", "undone", "cleared". + string event_type = 2; + // Human-readable description. + string description = 3; + // Associated chunk ID (if applicable). + string chunk_id = 4; +} + +message GetDraftHistoryResponse { + // Chronological decision history. + repeated DraftHistoryEntry entries = 1; +} + +// Stored payload for a policy revision row in the generic objects table. +message PolicyRevisionPayload { + // Serialized policy contents. + openshell.sandbox.v1.SandboxPolicy policy = 1; + // Deterministic hash of the policy payload. + string hash = 2; + // Load error reported by the sandbox, if any. + string load_error = 3; + // When the policy version was reported as loaded (ms since epoch). 0 if unset. + int64 loaded_at_ms = 4; +} + +// Stored payload for a draft policy chunk row in the generic objects table. +message DraftChunkPayload { + // Proposed network_policies map key. + string rule_name = 1; + // Proposed network policy rule. + openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 2; + // Human-readable explanation of why this rule is proposed. + string rationale = 3; + // Security concerns flagged by analysis (empty if none). + string security_notes = 4; + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + float confidence = 5; + // When the user approved/rejected (ms since epoch). 0 if undecided. + int64 decided_at_ms = 6; + // Denormalized endpoint host for dedup and display. + string host = 7; + // Denormalized endpoint port for dedup and display. + int32 port = 8; + // Binary path that triggered the denial. + string binary = 9; + // Current draft version for the owning sandbox. + int64 draft_version = 10; + // Gateway prover verdict for this chunk; empty until prover runs. + // Mirrors PolicyChunk.validation_result. + string validation_result = 11; + // Operator-supplied free-form rejection text; empty for non-rejected + // chunks. Mirrors PolicyChunk.rejection_reason. + string rejection_reason = 12; +} + +// Internal stored policy revision row materialized from the generic objects table. +message StoredPolicyRevision { + string id = 1; + string sandbox_id = 2; + int64 version = 3; + bytes policy_payload = 4; + string policy_hash = 5; + string status = 6; + optional string load_error = 7; + int64 created_at_ms = 8; + optional int64 loaded_at_ms = 9; +} + +// Internal stored draft chunk row materialized from the generic objects table. +message StoredDraftChunk { + string id = 1; + string sandbox_id = 2; + int64 draft_version = 3; + string status = 4; + string rule_name = 5; + bytes proposed_rule = 6; + string rationale = 7; + string security_notes = 8; + double confidence = 9; + int64 created_at_ms = 10; + optional int64 decided_at_ms = 11; + string host = 12; + int32 port = 13; + string binary = 14; + int32 hit_count = 15; + int64 first_seen_ms = 16; + int64 last_seen_ms = 17; + // Gateway prover verdict; empty until the prover runs. See PolicyChunk. + string validation_result = 18; + // Operator-supplied free-form rejection text. See PolicyChunk. + string rejection_reason = 19; +} diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go new file mode 100644 index 0000000000..46eb5d0d17 --- /dev/null +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -0,0 +1,12469 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.6 +// source: openshell.proto + +package openshellv1 + +import ( + datamodelv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + sandboxv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// High-level sandbox lifecycle phase derived by the gateway. +// +// Clients should rely on this normalized lifecycle summary for readiness and +// deletion decisions instead of interpreting raw conditions. +type SandboxPhase int32 + +const ( + SandboxPhase_SANDBOX_PHASE_UNSPECIFIED SandboxPhase = 0 + SandboxPhase_SANDBOX_PHASE_PROVISIONING SandboxPhase = 1 + SandboxPhase_SANDBOX_PHASE_READY SandboxPhase = 2 + SandboxPhase_SANDBOX_PHASE_ERROR SandboxPhase = 3 + SandboxPhase_SANDBOX_PHASE_DELETING SandboxPhase = 4 + SandboxPhase_SANDBOX_PHASE_UNKNOWN SandboxPhase = 5 +) + +// Enum value maps for SandboxPhase. +var ( + SandboxPhase_name = map[int32]string{ + 0: "SANDBOX_PHASE_UNSPECIFIED", + 1: "SANDBOX_PHASE_PROVISIONING", + 2: "SANDBOX_PHASE_READY", + 3: "SANDBOX_PHASE_ERROR", + 4: "SANDBOX_PHASE_DELETING", + 5: "SANDBOX_PHASE_UNKNOWN", + } + SandboxPhase_value = map[string]int32{ + "SANDBOX_PHASE_UNSPECIFIED": 0, + "SANDBOX_PHASE_PROVISIONING": 1, + "SANDBOX_PHASE_READY": 2, + "SANDBOX_PHASE_ERROR": 3, + "SANDBOX_PHASE_DELETING": 4, + "SANDBOX_PHASE_UNKNOWN": 5, + } +) + +func (x SandboxPhase) Enum() *SandboxPhase { + p := new(SandboxPhase) + *p = x + return p +} + +func (x SandboxPhase) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SandboxPhase) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[0].Descriptor() +} + +func (SandboxPhase) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[0] +} + +func (x SandboxPhase) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SandboxPhase.Descriptor instead. +func (SandboxPhase) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{0} +} + +type ProviderCredentialRefreshStrategy int32 + +const ( + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED ProviderCredentialRefreshStrategy = 0 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC ProviderCredentialRefreshStrategy = 1 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL ProviderCredentialRefreshStrategy = 2 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN ProviderCredentialRefreshStrategy = 3 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS ProviderCredentialRefreshStrategy = 4 + ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT ProviderCredentialRefreshStrategy = 5 +) + +// Enum value maps for ProviderCredentialRefreshStrategy. +var ( + ProviderCredentialRefreshStrategy_name = map[int32]string{ + 0: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED", + 1: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC", + 2: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL", + 3: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN", + 4: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS", + 5: "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT", + } + ProviderCredentialRefreshStrategy_value = map[string]int32{ + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED": 0, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC": 1, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL": 2, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN": 3, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS": 4, + "PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT": 5, + } +) + +func (x ProviderCredentialRefreshStrategy) Enum() *ProviderCredentialRefreshStrategy { + p := new(ProviderCredentialRefreshStrategy) + *p = x + return p +} + +func (x ProviderCredentialRefreshStrategy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderCredentialRefreshStrategy) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[1].Descriptor() +} + +func (ProviderCredentialRefreshStrategy) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[1] +} + +func (x ProviderCredentialRefreshStrategy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderCredentialRefreshStrategy.Descriptor instead. +func (ProviderCredentialRefreshStrategy) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{1} +} + +// Stable provider profile categories used by clients for grouping and filtering. +type ProviderProfileCategory int32 + +const ( + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED ProviderProfileCategory = 0 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER ProviderProfileCategory = 1 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE ProviderProfileCategory = 2 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT ProviderProfileCategory = 3 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL ProviderProfileCategory = 4 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING ProviderProfileCategory = 5 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA ProviderProfileCategory = 6 + ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE ProviderProfileCategory = 7 +) + +// Enum value maps for ProviderProfileCategory. +var ( + ProviderProfileCategory_name = map[int32]string{ + 0: "PROVIDER_PROFILE_CATEGORY_UNSPECIFIED", + 1: "PROVIDER_PROFILE_CATEGORY_OTHER", + 2: "PROVIDER_PROFILE_CATEGORY_INFERENCE", + 3: "PROVIDER_PROFILE_CATEGORY_AGENT", + 4: "PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL", + 5: "PROVIDER_PROFILE_CATEGORY_MESSAGING", + 6: "PROVIDER_PROFILE_CATEGORY_DATA", + 7: "PROVIDER_PROFILE_CATEGORY_KNOWLEDGE", + } + ProviderProfileCategory_value = map[string]int32{ + "PROVIDER_PROFILE_CATEGORY_UNSPECIFIED": 0, + "PROVIDER_PROFILE_CATEGORY_OTHER": 1, + "PROVIDER_PROFILE_CATEGORY_INFERENCE": 2, + "PROVIDER_PROFILE_CATEGORY_AGENT": 3, + "PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL": 4, + "PROVIDER_PROFILE_CATEGORY_MESSAGING": 5, + "PROVIDER_PROFILE_CATEGORY_DATA": 6, + "PROVIDER_PROFILE_CATEGORY_KNOWLEDGE": 7, + } +) + +func (x ProviderProfileCategory) Enum() *ProviderProfileCategory { + p := new(ProviderProfileCategory) + *p = x + return p +} + +func (x ProviderProfileCategory) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProviderProfileCategory) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[2].Descriptor() +} + +func (ProviderProfileCategory) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[2] +} + +func (x ProviderProfileCategory) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProviderProfileCategory.Descriptor instead. +func (ProviderProfileCategory) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{2} +} + +// Policy load status. +type PolicyStatus int32 + +const ( + PolicyStatus_POLICY_STATUS_UNSPECIFIED PolicyStatus = 0 + // Server received the update; sandbox has not yet loaded it. + PolicyStatus_POLICY_STATUS_PENDING PolicyStatus = 1 + // Sandbox successfully applied this policy version. + PolicyStatus_POLICY_STATUS_LOADED PolicyStatus = 2 + // Sandbox attempted to apply but failed; LKG policy remains active. + PolicyStatus_POLICY_STATUS_FAILED PolicyStatus = 3 + // A newer version was persisted before the sandbox loaded this one. + PolicyStatus_POLICY_STATUS_SUPERSEDED PolicyStatus = 4 +) + +// Enum value maps for PolicyStatus. +var ( + PolicyStatus_name = map[int32]string{ + 0: "POLICY_STATUS_UNSPECIFIED", + 1: "POLICY_STATUS_PENDING", + 2: "POLICY_STATUS_LOADED", + 3: "POLICY_STATUS_FAILED", + 4: "POLICY_STATUS_SUPERSEDED", + } + PolicyStatus_value = map[string]int32{ + "POLICY_STATUS_UNSPECIFIED": 0, + "POLICY_STATUS_PENDING": 1, + "POLICY_STATUS_LOADED": 2, + "POLICY_STATUS_FAILED": 3, + "POLICY_STATUS_SUPERSEDED": 4, + } +) + +func (x PolicyStatus) Enum() *PolicyStatus { + p := new(PolicyStatus) + *p = x + return p +} + +func (x PolicyStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PolicyStatus) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[3].Descriptor() +} + +func (PolicyStatus) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[3] +} + +func (x PolicyStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PolicyStatus.Descriptor instead. +func (PolicyStatus) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{3} +} + +// Service status enum. +type ServiceStatus int32 + +const ( + ServiceStatus_SERVICE_STATUS_UNSPECIFIED ServiceStatus = 0 + ServiceStatus_SERVICE_STATUS_HEALTHY ServiceStatus = 1 + ServiceStatus_SERVICE_STATUS_DEGRADED ServiceStatus = 2 + ServiceStatus_SERVICE_STATUS_UNHEALTHY ServiceStatus = 3 +) + +// Enum value maps for ServiceStatus. +var ( + ServiceStatus_name = map[int32]string{ + 0: "SERVICE_STATUS_UNSPECIFIED", + 1: "SERVICE_STATUS_HEALTHY", + 2: "SERVICE_STATUS_DEGRADED", + 3: "SERVICE_STATUS_UNHEALTHY", + } + ServiceStatus_value = map[string]int32{ + "SERVICE_STATUS_UNSPECIFIED": 0, + "SERVICE_STATUS_HEALTHY": 1, + "SERVICE_STATUS_DEGRADED": 2, + "SERVICE_STATUS_UNHEALTHY": 3, + } +) + +func (x ServiceStatus) Enum() *ServiceStatus { + p := new(ServiceStatus) + *p = x + return p +} + +func (x ServiceStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ServiceStatus) Descriptor() protoreflect.EnumDescriptor { + return file_openshell_proto_enumTypes[4].Descriptor() +} + +func (ServiceStatus) Type() protoreflect.EnumType { + return &file_openshell_proto_enumTypes[4] +} + +func (x ServiceStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ServiceStatus.Descriptor instead. +func (ServiceStatus) EnumDescriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{4} +} + +// 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). +type IssueSandboxTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueSandboxTokenRequest) Reset() { + *x = IssueSandboxTokenRequest{} + mi := &file_openshell_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueSandboxTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueSandboxTokenRequest) ProtoMessage() {} + +func (x *IssueSandboxTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[0] + 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 IssueSandboxTokenRequest.ProtoReflect.Descriptor instead. +func (*IssueSandboxTokenRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{0} +} + +// IssueSandboxToken response. The supervisor caches the returned token in +// memory and presents it as `Authorization: Bearer` on every subsequent +// gateway RPC. +type IssueSandboxTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-minted JWT bound to the calling sandbox's UUID. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // Absolute expiry of the issued token, milliseconds since the epoch. 0 means + // the token is non-expiring. + ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IssueSandboxTokenResponse) Reset() { + *x = IssueSandboxTokenResponse{} + mi := &file_openshell_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IssueSandboxTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IssueSandboxTokenResponse) ProtoMessage() {} + +func (x *IssueSandboxTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[1] + 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 IssueSandboxTokenResponse.ProtoReflect.Descriptor instead. +func (*IssueSandboxTokenResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{1} +} + +func (x *IssueSandboxTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *IssueSandboxTokenResponse) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +// RefreshSandboxToken request. Empty body; the calling principal must +// already be a sandbox principal (i.e. the request carries a still-valid +// gateway-minted JWT in its Authorization header). +type RefreshSandboxTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RefreshSandboxTokenRequest) Reset() { + *x = RefreshSandboxTokenRequest{} + mi := &file_openshell_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RefreshSandboxTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RefreshSandboxTokenRequest) ProtoMessage() {} + +func (x *RefreshSandboxTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[2] + 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 RefreshSandboxTokenRequest.ProtoReflect.Descriptor instead. +func (*RefreshSandboxTokenRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{2} +} + +// RefreshSandboxToken response. The new token replaces the supervisor's +// in-memory bearer credential. +type RefreshSandboxTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Fresh gateway-minted JWT bound to the same sandbox UUID. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // Absolute expiry of the new token, milliseconds since the epoch. 0 means + // the token is non-expiring. + ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RefreshSandboxTokenResponse) Reset() { + *x = RefreshSandboxTokenResponse{} + mi := &file_openshell_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RefreshSandboxTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RefreshSandboxTokenResponse) ProtoMessage() {} + +func (x *RefreshSandboxTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[3] + 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 RefreshSandboxTokenResponse.ProtoReflect.Descriptor instead. +func (*RefreshSandboxTokenResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{3} +} + +func (x *RefreshSandboxTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *RefreshSandboxTokenResponse) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +// Health check request. +type HealthRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthRequest) Reset() { + *x = HealthRequest{} + mi := &file_openshell_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthRequest) ProtoMessage() {} + +func (x *HealthRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[4] + 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 HealthRequest.ProtoReflect.Descriptor instead. +func (*HealthRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{4} +} + +// Health check response. +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Service status. + Status ServiceStatus `protobuf:"varint,1,opt,name=status,proto3,enum=openshell.v1.ServiceStatus" json:"status,omitempty"` + // Service version. + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_openshell_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[5] + 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 HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{5} +} + +func (x *HealthResponse) GetStatus() ServiceStatus { + if x != nil { + return x.Status + } + return ServiceStatus_SERVICE_STATUS_UNSPECIFIED +} + +func (x *HealthResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +// Public sandbox resource exposed by the OpenShell API. +// +// This is the canonical gateway-owned view of a sandbox. It merges user intent +// (`spec`) with gateway-managed metadata and status derived from internal +// compute-driver observations. +// +// Note: The `namespace` field has been removed from the public API. It remains +// in the internal `DriverSandbox` message as a compute-driver implementation detail. +type Sandbox struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // 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 +} + +func (x *Sandbox) Reset() { + *x = Sandbox{} + mi := &file_openshell_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Sandbox) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sandbox) ProtoMessage() {} + +func (x *Sandbox) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[6] + 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 Sandbox.ProtoReflect.Descriptor instead. +func (*Sandbox) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{6} +} + +func (x *Sandbox) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Sandbox) GetSpec() *SandboxSpec { + if x != nil { + return x.Spec + } + return nil +} + +func (x *Sandbox) GetStatus() *SandboxStatus { + if x != nil { + return x.Status + } + return nil +} + +// Desired sandbox configuration provided through the public API. +type SandboxSpec struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Log level exposed to processes running inside the sandbox. + LogLevel string `protobuf:"bytes,1,opt,name=log_level,json=logLevel,proto3" json:"log_level,omitempty"` + // Environment variables injected into the sandbox runtime. + Environment map[string]string `protobuf:"bytes,5,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Container or VM template used to provision the sandbox. + Template *SandboxTemplate `protobuf:"bytes,6,opt,name=template,proto3" json:"template,omitempty"` + // Required sandbox policy configuration. + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` + // Provider names to attach to this sandbox. + Providers []string `protobuf:"bytes,8,rep,name=providers,proto3" json:"providers,omitempty"` + // Portable resource requirements used by the gateway for driver selection + // and by drivers for provisioning. + ResourceRequirements *ResourceRequirements `protobuf:"bytes,9,opt,name=resource_requirements,json=resourceRequirements,proto3" json:"resource_requirements,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxSpec) Reset() { + *x = SandboxSpec{} + mi := &file_openshell_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxSpec) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxSpec) ProtoMessage() {} + +func (x *SandboxSpec) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[7] + 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 SandboxSpec.ProtoReflect.Descriptor instead. +func (*SandboxSpec) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{7} +} + +func (x *SandboxSpec) GetLogLevel() string { + if x != nil { + return x.LogLevel + } + return "" +} + +func (x *SandboxSpec) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *SandboxSpec) GetTemplate() *SandboxTemplate { + if x != nil { + return x.Template + } + return nil +} + +func (x *SandboxSpec) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *SandboxSpec) GetProviders() []string { + if x != nil { + return x.Providers + } + return nil +} + +func (x *SandboxSpec) GetResourceRequirements() *ResourceRequirements { + if x != nil { + return x.ResourceRequirements + } + return nil +} + +type ResourceRequirements struct { + state protoimpl.MessageState `protogen:"open.v1"` + // GPU requirements for the sandbox. Presence indicates a GPU request. + Gpu *GpuResourceRequirements `protobuf:"bytes,1,opt,name=gpu,proto3" json:"gpu,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceRequirements) Reset() { + *x = ResourceRequirements{} + mi := &file_openshell_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceRequirements) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceRequirements) ProtoMessage() {} + +func (x *ResourceRequirements) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[8] + 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 ResourceRequirements.ProtoReflect.Descriptor instead. +func (*ResourceRequirements) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{8} +} + +func (x *ResourceRequirements) GetGpu() *GpuResourceRequirements { + if x != nil { + return x.Gpu + } + return nil +} + +// Public GPU resource requirements. +type GpuResourceRequirements struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional number of GPUs requested. When omitted, the request is for one + // GPU using the selected driver's default assignment behavior. + Count *uint32 `protobuf:"varint,1,opt,name=count,proto3,oneof" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GpuResourceRequirements) Reset() { + *x = GpuResourceRequirements{} + mi := &file_openshell_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GpuResourceRequirements) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GpuResourceRequirements) ProtoMessage() {} + +func (x *GpuResourceRequirements) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[9] + 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 GpuResourceRequirements.ProtoReflect.Descriptor instead. +func (*GpuResourceRequirements) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{9} +} + +func (x *GpuResourceRequirements) GetCount() uint32 { + if x != nil && x.Count != nil { + return *x.Count + } + return 0 +} + +// Public sandbox template mapped onto compute-driver template inputs. +type SandboxTemplate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Fully-qualified OCI image reference used to boot the sandbox. + Image string `protobuf:"bytes,1,opt,name=image,proto3" json:"image,omitempty"` + // Optional runtime class name requested from the compute platform. + RuntimeClassName string `protobuf:"bytes,2,opt,name=runtime_class_name,json=runtimeClassName,proto3" json:"runtime_class_name,omitempty"` + // Optional agent socket path exposed to the workload. + AgentSocket string `protobuf:"bytes,3,opt,name=agent_socket,json=agentSocket,proto3" json:"agent_socket,omitempty"` + // Labels applied to compute-platform resources for this sandbox. + Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Annotations applied to compute-platform resources for this sandbox. + Annotations map[string]string `protobuf:"bytes,5,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Additional environment variables injected by the template. + Environment map[string]string `protobuf:"bytes,6,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Platform-specific compute resource requirements and limits. + Resources *structpb.Struct `protobuf:"bytes,7,opt,name=resources,proto3" json:"resources,omitempty"` + // Optional platform-specific volume claim templates. + VolumeClaimTemplates *structpb.Struct `protobuf:"bytes,9,opt,name=volume_claim_templates,json=volumeClaimTemplates,proto3" json:"volume_claim_templates,omitempty"` + // Enable Kubernetes user namespace isolation (hostUsers: false). + // When true, container UID 0 maps to a non-root host UID and capabilities + // become namespaced. Requires Kubernetes 1.33+ with user namespace support + // available (beta through 1.35, GA in 1.36+) and a supporting runtime. + // When unset, the cluster-wide default is used. + UserNamespaces *bool `protobuf:"varint,10,opt,name=user_namespaces,json=userNamespaces,proto3,oneof" json:"user_namespaces,omitempty"` + // Driver-keyed opaque config envelope supplied by the caller. + // The gateway selects the block matching the active compute driver and + // forwards only that inner Struct to DriverSandboxTemplate.driver_config. + // The selected driver owns nested schema validation. + DriverConfig *structpb.Struct `protobuf:"bytes,11,opt,name=driver_config,json=driverConfig,proto3" json:"driver_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxTemplate) Reset() { + *x = SandboxTemplate{} + mi := &file_openshell_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxTemplate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxTemplate) ProtoMessage() {} + +func (x *SandboxTemplate) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[10] + 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 SandboxTemplate.ProtoReflect.Descriptor instead. +func (*SandboxTemplate) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{10} +} + +func (x *SandboxTemplate) GetImage() string { + if x != nil { + return x.Image + } + return "" +} + +func (x *SandboxTemplate) GetRuntimeClassName() string { + if x != nil { + return x.RuntimeClassName + } + return "" +} + +func (x *SandboxTemplate) GetAgentSocket() string { + if x != nil { + return x.AgentSocket + } + return "" +} + +func (x *SandboxTemplate) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + +func (x *SandboxTemplate) GetAnnotations() map[string]string { + if x != nil { + return x.Annotations + } + return nil +} + +func (x *SandboxTemplate) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *SandboxTemplate) GetResources() *structpb.Struct { + if x != nil { + return x.Resources + } + return nil +} + +func (x *SandboxTemplate) GetVolumeClaimTemplates() *structpb.Struct { + if x != nil { + return x.VolumeClaimTemplates + } + return nil +} + +func (x *SandboxTemplate) GetUserNamespaces() bool { + if x != nil && x.UserNamespaces != nil { + return *x.UserNamespaces + } + return false +} + +func (x *SandboxTemplate) GetDriverConfig() *structpb.Struct { + if x != nil { + return x.DriverConfig + } + return nil +} + +// 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStatus) Reset() { + *x = SandboxStatus{} + mi := &file_openshell_proto_msgTypes[11] + 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[11] + 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{11} +} + +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 +} + +// 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[12] + 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[12] + 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{12} +} + +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[13] + 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[13] + 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{13} +} + +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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSandboxRequest) Reset() { + *x = CreateSandboxRequest{} + mi := &file_openshell_proto_msgTypes[14] + 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[14] + 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{14} +} + +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 +} + +// Get sandbox request. +type GetSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxRequest) Reset() { + *x = GetSandboxRequest{} + mi := &file_openshell_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxRequest) ProtoMessage() {} + +func (x *GetSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[15] + 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 GetSandboxRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{15} +} + +func (x *GetSandboxRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// List sandboxes request. +type ListSandboxesRequest 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"` + // Optional label selector for filtering (format: "key1=value1,key2=value2"). + LabelSelector string `protobuf:"bytes,3,opt,name=label_selector,json=labelSelector,proto3" json:"label_selector,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxesRequest) Reset() { + *x = ListSandboxesRequest{} + mi := &file_openshell_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxesRequest) ProtoMessage() {} + +func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[16] + 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 ListSandboxesRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{16} +} + +func (x *ListSandboxesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSandboxesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *ListSandboxesRequest) GetLabelSelector() string { + if x != nil { + return x.LabelSelector + } + return "" +} + +// List providers attached to a sandbox request. +type ListSandboxProvidersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxProvidersRequest) Reset() { + *x = ListSandboxProvidersRequest{} + mi := &file_openshell_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxProvidersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxProvidersRequest) ProtoMessage() {} + +func (x *ListSandboxProvidersRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[17] + 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 ListSandboxProvidersRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxProvidersRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{17} +} + +func (x *ListSandboxProvidersRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +// Attach provider to sandbox request. +type AttachSandboxProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Provider name to attach. + ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachSandboxProviderRequest) Reset() { + *x = AttachSandboxProviderRequest{} + mi := &file_openshell_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachSandboxProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachSandboxProviderRequest) ProtoMessage() {} + +func (x *AttachSandboxProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[18] + 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 AttachSandboxProviderRequest.ProtoReflect.Descriptor instead. +func (*AttachSandboxProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{18} +} + +func (x *AttachSandboxProviderRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *AttachSandboxProviderRequest) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *AttachSandboxProviderRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +// Detach provider from sandbox request. +type DetachSandboxProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + SandboxName string `protobuf:"bytes,1,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Provider name to detach. + ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + // Expected resource version for optimistic concurrency control. + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachSandboxProviderRequest) Reset() { + *x = DetachSandboxProviderRequest{} + mi := &file_openshell_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachSandboxProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachSandboxProviderRequest) ProtoMessage() {} + +func (x *DetachSandboxProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[19] + 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 DetachSandboxProviderRequest.ProtoReflect.Descriptor instead. +func (*DetachSandboxProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{19} +} + +func (x *DetachSandboxProviderRequest) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *DetachSandboxProviderRequest) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *DetachSandboxProviderRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +// Delete sandbox request. +type DeleteSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSandboxRequest) Reset() { + *x = DeleteSandboxRequest{} + mi := &file_openshell_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSandboxRequest) ProtoMessage() {} + +func (x *DeleteSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[20] + 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 DeleteSandboxRequest.ProtoReflect.Descriptor instead. +func (*DeleteSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{20} +} + +func (x *DeleteSandboxRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// Sandbox response. +type SandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxResponse) Reset() { + *x = SandboxResponse{} + mi := &file_openshell_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxResponse) ProtoMessage() {} + +func (x *SandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[21] + 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 SandboxResponse.ProtoReflect.Descriptor instead. +func (*SandboxResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{21} +} + +func (x *SandboxResponse) GetSandbox() *Sandbox { + if x != nil { + return x.Sandbox + } + return nil +} + +// List sandboxes response. +type ListSandboxesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandboxes []*Sandbox `protobuf:"bytes,1,rep,name=sandboxes,proto3" json:"sandboxes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxesResponse) Reset() { + *x = ListSandboxesResponse{} + mi := &file_openshell_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxesResponse) ProtoMessage() {} + +func (x *ListSandboxesResponse) 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 ListSandboxesResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{22} +} + +func (x *ListSandboxesResponse) GetSandboxes() []*Sandbox { + if x != nil { + return x.Sandboxes + } + return nil +} + +// List providers attached to a sandbox response. +type ListSandboxProvidersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Providers []*datamodelv1.Provider `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxProvidersResponse) Reset() { + *x = ListSandboxProvidersResponse{} + mi := &file_openshell_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxProvidersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxProvidersResponse) ProtoMessage() {} + +func (x *ListSandboxProvidersResponse) 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 ListSandboxProvidersResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxProvidersResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{23} +} + +func (x *ListSandboxProvidersResponse) GetProviders() []*datamodelv1.Provider { + if x != nil { + return x.Providers + } + return nil +} + +// Attach provider to sandbox response. +type AttachSandboxProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // True when the provider was newly attached. False means it was already attached. + Attached bool `protobuf:"varint,2,opt,name=attached,proto3" json:"attached,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AttachSandboxProviderResponse) Reset() { + *x = AttachSandboxProviderResponse{} + mi := &file_openshell_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AttachSandboxProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AttachSandboxProviderResponse) ProtoMessage() {} + +func (x *AttachSandboxProviderResponse) 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 AttachSandboxProviderResponse.ProtoReflect.Descriptor instead. +func (*AttachSandboxProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{24} +} + +func (x *AttachSandboxProviderResponse) GetSandbox() *Sandbox { + if x != nil { + return x.Sandbox + } + return nil +} + +func (x *AttachSandboxProviderResponse) GetAttached() bool { + if x != nil { + return x.Attached + } + return false +} + +// Detach provider from sandbox response. +type DetachSandboxProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // True when the provider was removed. False means it was not attached. + Detached bool `protobuf:"varint,2,opt,name=detached,proto3" json:"detached,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DetachSandboxProviderResponse) Reset() { + *x = DetachSandboxProviderResponse{} + mi := &file_openshell_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DetachSandboxProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DetachSandboxProviderResponse) ProtoMessage() {} + +func (x *DetachSandboxProviderResponse) 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 DetachSandboxProviderResponse.ProtoReflect.Descriptor instead. +func (*DetachSandboxProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{25} +} + +func (x *DetachSandboxProviderResponse) GetSandbox() *Sandbox { + if x != nil { + return x.Sandbox + } + return nil +} + +func (x *DetachSandboxProviderResponse) GetDetached() bool { + if x != nil { + return x.Detached + } + return false +} + +// Delete sandbox response. +type DeleteSandboxResponse 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 *DeleteSandboxResponse) Reset() { + *x = DeleteSandboxResponse{} + mi := &file_openshell_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSandboxResponse) ProtoMessage() {} + +func (x *DeleteSandboxResponse) 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 DeleteSandboxResponse.ProtoReflect.Descriptor instead. +func (*DeleteSandboxResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{26} +} + +func (x *DeleteSandboxResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Create SSH session request. +type CreateSshSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSshSessionRequest) Reset() { + *x = CreateSshSessionRequest{} + mi := &file_openshell_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSshSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSshSessionRequest) ProtoMessage() {} + +func (x *CreateSshSessionRequest) 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 CreateSshSessionRequest.ProtoReflect.Descriptor instead. +func (*CreateSshSessionRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{27} +} + +func (x *CreateSshSessionRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +// Create SSH session response. +// +// Fields are interpolated into an SSH `ProxyCommand` string that OpenSSH +// executes through `/bin/sh -c` on the caller's workstation. Servers MUST +// uphold the charset contract below; clients MUST reject responses that +// violate it. The client's own escaping provides defense-in-depth, but +// narrow charsets close injection vectors at the trust boundary. +type CreateSshSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. [A-Za-z0-9._-]{1,128}. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Session token for the gateway tunnel. URL-safe ASCII + // ([A-Za-z0-9._~+/=-]) up to 4096 bytes. No shell metacharacters or + // whitespace. + Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` + // Gateway host for SSH proxy connection. IPv4 address, bracketed IPv6 + // address, or DNS hostname (Punycode-encoded for IDN). Alphanumeric plus + // `.-:[]` only, up to 253 bytes. + GatewayHost string `protobuf:"bytes,3,opt,name=gateway_host,json=gatewayHost,proto3" json:"gateway_host,omitempty"` + // Gateway port for SSH proxy connection. Must be in range 1..=65535. + GatewayPort uint32 `protobuf:"varint,4,opt,name=gateway_port,json=gatewayPort,proto3" json:"gateway_port,omitempty"` + // Gateway scheme. Must be exactly "http" or "https". + GatewayScheme string `protobuf:"bytes,5,opt,name=gateway_scheme,json=gatewayScheme,proto3" json:"gateway_scheme,omitempty"` + // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. + HostKeyFingerprint string `protobuf:"bytes,7,opt,name=host_key_fingerprint,json=hostKeyFingerprint,proto3" json:"host_key_fingerprint,omitempty"` + // Expiry timestamp in milliseconds since epoch. 0 means no expiry. + ExpiresAtMs int64 `protobuf:"varint,8,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSshSessionResponse) Reset() { + *x = CreateSshSessionResponse{} + mi := &file_openshell_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSshSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSshSessionResponse) ProtoMessage() {} + +func (x *CreateSshSessionResponse) 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 CreateSshSessionResponse.ProtoReflect.Descriptor instead. +func (*CreateSshSessionResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{28} +} + +func (x *CreateSshSessionResponse) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *CreateSshSessionResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *CreateSshSessionResponse) GetGatewayHost() string { + if x != nil { + return x.GatewayHost + } + return "" +} + +func (x *CreateSshSessionResponse) GetGatewayPort() uint32 { + if x != nil { + return x.GatewayPort + } + return 0 +} + +func (x *CreateSshSessionResponse) GetGatewayScheme() string { + if x != nil { + return x.GatewayScheme + } + return "" +} + +func (x *CreateSshSessionResponse) GetHostKeyFingerprint() string { + if x != nil { + return x.HostKeyFingerprint + } + return "" +} + +func (x *CreateSshSessionResponse) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +// Request to expose an HTTP service running inside a sandbox. +type ExposeServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service name within the sandbox. + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + // Loopback TCP port inside the sandbox. + TargetPort uint32 `protobuf:"varint,3,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` + // Whether to print/use the browser-facing service URL. + Domain bool `protobuf:"varint,4,opt,name=domain,proto3" json:"domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceRequest) Reset() { + *x = ExposeServiceRequest{} + mi := &file_openshell_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceRequest) ProtoMessage() {} + +func (x *ExposeServiceRequest) 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 ExposeServiceRequest.ProtoReflect.Descriptor instead. +func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{29} +} + +func (x *ExposeServiceRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *ExposeServiceRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *ExposeServiceRequest) GetTargetPort() uint32 { + if x != nil { + return x.TargetPort + } + return 0 +} + +func (x *ExposeServiceRequest) GetDomain() bool { + if x != nil { + return x.Domain + } + return false +} + +// Request to fetch an exposed sandbox service endpoint. +type GetServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service name within the sandbox. Empty selects the unnamed endpoint. + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceRequest) Reset() { + *x = GetServiceRequest{} + mi := &file_openshell_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetServiceRequest) ProtoMessage() {} + +func (x *GetServiceRequest) 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 GetServiceRequest.ProtoReflect.Descriptor instead. +func (*GetServiceRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{30} +} + +func (x *GetServiceRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *GetServiceRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +// Request to list exposed sandbox service endpoints. +type ListServicesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional sandbox name. Empty lists endpoints for all sandboxes. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Page size. Zero uses the server default. + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + // Page offset. + Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServicesRequest) Reset() { + *x = ListServicesRequest{} + mi := &file_openshell_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServicesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServicesRequest) ProtoMessage() {} + +func (x *ListServicesRequest) 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 ListServicesRequest.ProtoReflect.Descriptor instead. +func (*ListServicesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{31} +} + +func (x *ListServicesRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *ListServicesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListServicesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +// Response containing exposed sandbox service endpoints. +type ListServicesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Services []*ServiceEndpointResponse `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServicesResponse) Reset() { + *x = ListServicesResponse{} + mi := &file_openshell_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServicesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServicesResponse) ProtoMessage() {} + +func (x *ListServicesResponse) 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 ListServicesResponse.ProtoReflect.Descriptor instead. +func (*ListServicesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{32} +} + +func (x *ListServicesResponse) GetServices() []*ServiceEndpointResponse { + if x != nil { + return x.Services + } + return nil +} + +// Request to delete an exposed sandbox service endpoint. +type DeleteServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Sandbox string `protobuf:"bytes,1,opt,name=sandbox,proto3" json:"sandbox,omitempty"` + // Service name within the sandbox. Empty selects the unnamed endpoint. + Service string `protobuf:"bytes,2,opt,name=service,proto3" json:"service,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteServiceRequest) Reset() { + *x = DeleteServiceRequest{} + mi := &file_openshell_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteServiceRequest) ProtoMessage() {} + +func (x *DeleteServiceRequest) 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 DeleteServiceRequest.ProtoReflect.Descriptor instead. +func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{33} +} + +func (x *DeleteServiceRequest) GetSandbox() string { + if x != nil { + return x.Sandbox + } + return "" +} + +func (x *DeleteServiceRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +// Response for deleting an exposed sandbox service endpoint. +type DeleteServiceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True when an endpoint existed and was deleted. + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteServiceResponse) Reset() { + *x = DeleteServiceResponse{} + mi := &file_openshell_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteServiceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteServiceResponse) ProtoMessage() {} + +func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[34] + 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 DeleteServiceResponse.ProtoReflect.Descriptor instead. +func (*DeleteServiceResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{34} +} + +func (x *DeleteServiceResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Persisted sandbox service endpoint. +type ServiceEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata. + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Sandbox object ID. + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Sandbox name. + SandboxName string `protobuf:"bytes,3,opt,name=sandbox_name,json=sandboxName,proto3" json:"sandbox_name,omitempty"` + // Service name within the sandbox. + ServiceName string `protobuf:"bytes,4,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + // Loopback TCP port inside the sandbox. + TargetPort uint32 `protobuf:"varint,5,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` + // Whether browser-facing service routing is enabled for this endpoint. + Domain bool `protobuf:"varint,6,opt,name=domain,proto3" json:"domain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceEndpoint) Reset() { + *x = ServiceEndpoint{} + mi := &file_openshell_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceEndpoint) ProtoMessage() {} + +func (x *ServiceEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[35] + 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 ServiceEndpoint.ProtoReflect.Descriptor instead. +func (*ServiceEndpoint) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{35} +} + +func (x *ServiceEndpoint) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *ServiceEndpoint) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ServiceEndpoint) GetSandboxName() string { + if x != nil { + return x.SandboxName + } + return "" +} + +func (x *ServiceEndpoint) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *ServiceEndpoint) GetTargetPort() uint32 { + if x != nil { + return x.TargetPort + } + return 0 +} + +func (x *ServiceEndpoint) GetDomain() bool { + if x != nil { + return x.Domain + } + return false +} + +// Response containing a service endpoint and, when available, its local URL. +type ServiceEndpointResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoint *ServiceEndpoint `protobuf:"bytes,1,opt,name=endpoint,proto3" json:"endpoint,omitempty"` + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceEndpointResponse) Reset() { + *x = ServiceEndpointResponse{} + mi := &file_openshell_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceEndpointResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceEndpointResponse) ProtoMessage() {} + +func (x *ServiceEndpointResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[36] + 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 ServiceEndpointResponse.ProtoReflect.Descriptor instead. +func (*ServiceEndpointResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{36} +} + +func (x *ServiceEndpointResponse) GetEndpoint() *ServiceEndpoint { + if x != nil { + return x.Endpoint + } + return nil +} + +func (x *ServiceEndpointResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +// Revoke SSH session request. +type RevokeSshSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Session token to revoke. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeSshSessionRequest) Reset() { + *x = RevokeSshSessionRequest{} + mi := &file_openshell_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeSshSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeSshSessionRequest) ProtoMessage() {} + +func (x *RevokeSshSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[37] + 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 RevokeSshSessionRequest.ProtoReflect.Descriptor instead. +func (*RevokeSshSessionRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{37} +} + +func (x *RevokeSshSessionRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +// Revoke SSH session response. +type RevokeSshSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True when a session was revoked. + Revoked bool `protobuf:"varint,1,opt,name=revoked,proto3" json:"revoked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeSshSessionResponse) Reset() { + *x = RevokeSshSessionResponse{} + mi := &file_openshell_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeSshSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeSshSessionResponse) ProtoMessage() {} + +func (x *RevokeSshSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[38] + 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 RevokeSshSessionResponse.ProtoReflect.Descriptor instead. +func (*RevokeSshSessionResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{38} +} + +func (x *RevokeSshSessionResponse) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +// Execute command request. +type ExecSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Command and arguments. + Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"` + // Optional working directory. + Workdir string `protobuf:"bytes,3,opt,name=workdir,proto3" json:"workdir,omitempty"` + // Optional environment overrides. + Environment map[string]string `protobuf:"bytes,4,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional timeout in seconds. 0 means no timeout. + TimeoutSeconds uint32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + // Optional stdin payload passed to the command. + Stdin []byte `protobuf:"bytes,6,opt,name=stdin,proto3" json:"stdin,omitempty"` + // Request a pseudo-terminal for the remote command. + Tty bool `protobuf:"varint,7,opt,name=tty,proto3" json:"tty,omitempty"` + // 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxRequest) Reset() { + *x = ExecSandboxRequest{} + mi := &file_openshell_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxRequest) ProtoMessage() {} + +func (x *ExecSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[39] + 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 ExecSandboxRequest.ProtoReflect.Descriptor instead. +func (*ExecSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{39} +} + +func (x *ExecSandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ExecSandboxRequest) GetCommand() []string { + if x != nil { + return x.Command + } + return nil +} + +func (x *ExecSandboxRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +func (x *ExecSandboxRequest) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *ExecSandboxRequest) GetTimeoutSeconds() uint32 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +func (x *ExecSandboxRequest) GetStdin() []byte { + if x != nil { + return x.Stdin + } + return nil +} + +func (x *ExecSandboxRequest) GetTty() bool { + if x != nil { + return x.Tty + } + return false +} + +func (x *ExecSandboxRequest) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *ExecSandboxRequest) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +// One stdout chunk from a sandbox exec. +type ExecSandboxStdout struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxStdout) Reset() { + *x = ExecSandboxStdout{} + mi := &file_openshell_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxStdout) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxStdout) ProtoMessage() {} + +func (x *ExecSandboxStdout) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[40] + 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 ExecSandboxStdout.ProtoReflect.Descriptor instead. +func (*ExecSandboxStdout) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{40} +} + +func (x *ExecSandboxStdout) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// One stderr chunk from a sandbox exec. +type ExecSandboxStderr struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxStderr) Reset() { + *x = ExecSandboxStderr{} + mi := &file_openshell_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxStderr) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxStderr) ProtoMessage() {} + +func (x *ExecSandboxStderr) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[41] + 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 ExecSandboxStderr.ProtoReflect.Descriptor instead. +func (*ExecSandboxStderr) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{41} +} + +func (x *ExecSandboxStderr) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +// Final exit status for a sandbox exec. +type ExecSandboxExit struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxExit) Reset() { + *x = ExecSandboxExit{} + mi := &file_openshell_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxExit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxExit) ProtoMessage() {} + +func (x *ExecSandboxExit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[42] + 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 ExecSandboxExit.ProtoReflect.Descriptor instead. +func (*ExecSandboxExit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{42} +} + +func (x *ExecSandboxExit) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +// One event in a sandbox exec stream. +type ExecSandboxEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ExecSandboxEvent_Stdout + // *ExecSandboxEvent_Stderr + // *ExecSandboxEvent_Exit + Payload isExecSandboxEvent_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxEvent) Reset() { + *x = ExecSandboxEvent{} + mi := &file_openshell_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxEvent) ProtoMessage() {} + +func (x *ExecSandboxEvent) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[43] + 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 ExecSandboxEvent.ProtoReflect.Descriptor instead. +func (*ExecSandboxEvent) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{43} +} + +func (x *ExecSandboxEvent) GetPayload() isExecSandboxEvent_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ExecSandboxEvent) GetStdout() *ExecSandboxStdout { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ExecSandboxEvent) GetStderr() *ExecSandboxStderr { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ExecSandboxEvent) GetExit() *ExecSandboxExit { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxEvent_Exit); ok { + return x.Exit + } + } + return nil +} + +type isExecSandboxEvent_Payload interface { + isExecSandboxEvent_Payload() +} + +type ExecSandboxEvent_Stdout struct { + Stdout *ExecSandboxStdout `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ExecSandboxEvent_Stderr struct { + Stderr *ExecSandboxStderr `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ExecSandboxEvent_Exit struct { + Exit *ExecSandboxExit `protobuf:"bytes,3,opt,name=exit,proto3,oneof"` +} + +func (*ExecSandboxEvent_Stdout) isExecSandboxEvent_Payload() {} + +func (*ExecSandboxEvent_Stderr) isExecSandboxEvent_Payload() {} + +func (*ExecSandboxEvent_Exit) isExecSandboxEvent_Payload() {} + +// Initial frame for one TCP forward stream. +type TcpForwardInit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Optional service identifier for audit/correlation. + ServiceId string `protobuf:"bytes,4,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` + // Target the gateway should request from the supervisor. + // + // Types that are valid to be assigned to Target: + // + // *TcpForwardInit_Ssh + // *TcpForwardInit_Tcp + Target isTcpForwardInit_Target `protobuf_oneof:"target"` + // Optional target-specific authorization token. SSH targets use this as the + // short-lived SSH session token issued by CreateSshSession. + AuthorizationToken string `protobuf:"bytes,7,opt,name=authorization_token,json=authorizationToken,proto3" json:"authorization_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TcpForwardInit) Reset() { + *x = TcpForwardInit{} + mi := &file_openshell_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TcpForwardInit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TcpForwardInit) ProtoMessage() {} + +func (x *TcpForwardInit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[44] + 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 TcpForwardInit.ProtoReflect.Descriptor instead. +func (*TcpForwardInit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{44} +} + +func (x *TcpForwardInit) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *TcpForwardInit) GetServiceId() string { + if x != nil { + return x.ServiceId + } + return "" +} + +func (x *TcpForwardInit) GetTarget() isTcpForwardInit_Target { + if x != nil { + return x.Target + } + return nil +} + +func (x *TcpForwardInit) GetSsh() *SshRelayTarget { + if x != nil { + if x, ok := x.Target.(*TcpForwardInit_Ssh); ok { + return x.Ssh + } + } + return nil +} + +func (x *TcpForwardInit) GetTcp() *TcpRelayTarget { + if x != nil { + if x, ok := x.Target.(*TcpForwardInit_Tcp); ok { + return x.Tcp + } + } + return nil +} + +func (x *TcpForwardInit) GetAuthorizationToken() string { + if x != nil { + return x.AuthorizationToken + } + return "" +} + +type isTcpForwardInit_Target interface { + isTcpForwardInit_Target() +} + +type TcpForwardInit_Ssh struct { + Ssh *SshRelayTarget `protobuf:"bytes,5,opt,name=ssh,proto3,oneof"` +} + +type TcpForwardInit_Tcp struct { + Tcp *TcpRelayTarget `protobuf:"bytes,6,opt,name=tcp,proto3,oneof"` +} + +func (*TcpForwardInit_Ssh) isTcpForwardInit_Target() {} + +func (*TcpForwardInit_Tcp) isTcpForwardInit_Target() {} + +// A single frame on the CLI-to-gateway TCP forward stream. +type TcpForwardFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *TcpForwardFrame_Init + // *TcpForwardFrame_Data + Payload isTcpForwardFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TcpForwardFrame) Reset() { + *x = TcpForwardFrame{} + mi := &file_openshell_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TcpForwardFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TcpForwardFrame) ProtoMessage() {} + +func (x *TcpForwardFrame) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[45] + 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 TcpForwardFrame.ProtoReflect.Descriptor instead. +func (*TcpForwardFrame) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{45} +} + +func (x *TcpForwardFrame) GetPayload() isTcpForwardFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *TcpForwardFrame) GetInit() *TcpForwardInit { + if x != nil { + if x, ok := x.Payload.(*TcpForwardFrame_Init); ok { + return x.Init + } + } + return nil +} + +func (x *TcpForwardFrame) GetData() []byte { + if x != nil { + if x, ok := x.Payload.(*TcpForwardFrame_Data); ok { + return x.Data + } + } + return nil +} + +type isTcpForwardFrame_Payload interface { + isTcpForwardFrame_Payload() +} + +type TcpForwardFrame_Init struct { + Init *TcpForwardInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"` +} + +type TcpForwardFrame_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +func (*TcpForwardFrame_Init) isTcpForwardFrame_Payload() {} + +func (*TcpForwardFrame_Data) isTcpForwardFrame_Payload() {} + +// Client-to-server message for interactive exec. +type ExecSandboxInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *ExecSandboxInput_Start + // *ExecSandboxInput_Stdin + // *ExecSandboxInput_Resize + Payload isExecSandboxInput_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxInput) Reset() { + *x = ExecSandboxInput{} + mi := &file_openshell_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxInput) ProtoMessage() {} + +func (x *ExecSandboxInput) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[46] + 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 ExecSandboxInput.ProtoReflect.Descriptor instead. +func (*ExecSandboxInput) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{46} +} + +func (x *ExecSandboxInput) GetPayload() isExecSandboxInput_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ExecSandboxInput) GetStart() *ExecSandboxRequest { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxInput_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ExecSandboxInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ExecSandboxInput) GetResize() *ExecSandboxWindowResize { + if x != nil { + if x, ok := x.Payload.(*ExecSandboxInput_Resize); ok { + return x.Resize + } + } + return nil +} + +type isExecSandboxInput_Payload interface { + isExecSandboxInput_Payload() +} + +type ExecSandboxInput_Start struct { + // First message: exec request metadata. + Start *ExecSandboxRequest `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ExecSandboxInput_Stdin struct { + // Subsequent messages: raw stdin bytes. + Stdin []byte `protobuf:"bytes,2,opt,name=stdin,proto3,oneof"` +} + +type ExecSandboxInput_Resize struct { + // Terminal window size change. + Resize *ExecSandboxWindowResize `protobuf:"bytes,3,opt,name=resize,proto3,oneof"` +} + +func (*ExecSandboxInput_Start) isExecSandboxInput_Payload() {} + +func (*ExecSandboxInput_Stdin) isExecSandboxInput_Payload() {} + +func (*ExecSandboxInput_Resize) isExecSandboxInput_Payload() {} + +// Terminal window resize event for interactive exec. +type ExecSandboxWindowResize struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecSandboxWindowResize) Reset() { + *x = ExecSandboxWindowResize{} + mi := &file_openshell_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecSandboxWindowResize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecSandboxWindowResize) ProtoMessage() {} + +func (x *ExecSandboxWindowResize) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[47] + 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 ExecSandboxWindowResize.ProtoReflect.Descriptor instead. +func (*ExecSandboxWindowResize) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{47} +} + +func (x *ExecSandboxWindowResize) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *ExecSandboxWindowResize) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +// SSH session record stored in persistence. +type SshSession struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Kubernetes-style metadata (id, name, labels, timestamps, resource version). + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Sandbox id. + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Session token. + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + // Expiry timestamp in milliseconds since epoch. 0 means no expiry + // (backward-compatible default for sessions created before this field existed). + ExpiresAtMs int64 `protobuf:"varint,4,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // Revoked flag. + Revoked bool `protobuf:"varint,5,opt,name=revoked,proto3" json:"revoked,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SshSession) Reset() { + *x = SshSession{} + mi := &file_openshell_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SshSession) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SshSession) ProtoMessage() {} + +func (x *SshSession) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[48] + 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 SshSession.ProtoReflect.Descriptor instead. +func (*SshSession) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{48} +} + +func (x *SshSession) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *SshSession) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SshSession) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *SshSession) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +func (x *SshSession) GetRevoked() bool { + if x != nil { + return x.Revoked + } + return false +} + +// Watch sandbox request. +type WatchSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Stream sandbox status snapshots. + FollowStatus bool `protobuf:"varint,2,opt,name=follow_status,json=followStatus,proto3" json:"follow_status,omitempty"` + // Stream openshell-server process logs correlated to this sandbox. + FollowLogs bool `protobuf:"varint,3,opt,name=follow_logs,json=followLogs,proto3" json:"follow_logs,omitempty"` + // Stream platform events correlated to this sandbox. + FollowEvents bool `protobuf:"varint,4,opt,name=follow_events,json=followEvents,proto3" json:"follow_events,omitempty"` + // Replay the last N log lines (best-effort) before following. + 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). + 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. + LogSinceMs int64 `protobuf:"varint,8,opt,name=log_since_ms,json=logSinceMs,proto3" json:"log_since_ms,omitempty"` + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + LogSources []string `protobuf:"bytes,9,rep,name=log_sources,json=logSources,proto3" json:"log_sources,omitempty"` + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + LogMinLevel string `protobuf:"bytes,10,opt,name=log_min_level,json=logMinLevel,proto3" json:"log_min_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSandboxRequest) Reset() { + *x = WatchSandboxRequest{} + mi := &file_openshell_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSandboxRequest) ProtoMessage() {} + +func (x *WatchSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[49] + 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 WatchSandboxRequest.ProtoReflect.Descriptor instead. +func (*WatchSandboxRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{49} +} + +func (x *WatchSandboxRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *WatchSandboxRequest) GetFollowStatus() bool { + if x != nil { + return x.FollowStatus + } + return false +} + +func (x *WatchSandboxRequest) GetFollowLogs() bool { + if x != nil { + return x.FollowLogs + } + return false +} + +func (x *WatchSandboxRequest) GetFollowEvents() bool { + if x != nil { + return x.FollowEvents + } + return false +} + +func (x *WatchSandboxRequest) GetLogTailLines() uint32 { + if x != nil { + return x.LogTailLines + } + return 0 +} + +func (x *WatchSandboxRequest) GetEventTail() uint32 { + if x != nil { + return x.EventTail + } + return 0 +} + +func (x *WatchSandboxRequest) GetStopOnTerminal() bool { + if x != nil { + return x.StopOnTerminal + } + return false +} + +func (x *WatchSandboxRequest) GetLogSinceMs() int64 { + if x != nil { + return x.LogSinceMs + } + return 0 +} + +func (x *WatchSandboxRequest) GetLogSources() []string { + if x != nil { + return x.LogSources + } + return nil +} + +func (x *WatchSandboxRequest) GetLogMinLevel() string { + if x != nil { + return x.LogMinLevel + } + return "" +} + +// One event in a sandbox watch stream. +type SandboxStreamEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *SandboxStreamEvent_Sandbox + // *SandboxStreamEvent_Log + // *SandboxStreamEvent_Event + // *SandboxStreamEvent_Warning + // *SandboxStreamEvent_DraftPolicyUpdate + Payload isSandboxStreamEvent_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStreamEvent) Reset() { + *x = SandboxStreamEvent{} + mi := &file_openshell_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStreamEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStreamEvent) ProtoMessage() {} + +func (x *SandboxStreamEvent) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[50] + 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 SandboxStreamEvent.ProtoReflect.Descriptor instead. +func (*SandboxStreamEvent) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{50} +} + +func (x *SandboxStreamEvent) GetPayload() isSandboxStreamEvent_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *SandboxStreamEvent) GetSandbox() *Sandbox { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Sandbox); ok { + return x.Sandbox + } + } + return nil +} + +func (x *SandboxStreamEvent) GetLog() *SandboxLogLine { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Log); ok { + return x.Log + } + } + return nil +} + +func (x *SandboxStreamEvent) GetEvent() *PlatformEvent { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Event); ok { + return x.Event + } + } + return nil +} + +func (x *SandboxStreamEvent) GetWarning() *SandboxStreamWarning { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_Warning); ok { + return x.Warning + } + } + return nil +} + +func (x *SandboxStreamEvent) GetDraftPolicyUpdate() *DraftPolicyUpdate { + if x != nil { + if x, ok := x.Payload.(*SandboxStreamEvent_DraftPolicyUpdate); ok { + return x.DraftPolicyUpdate + } + } + return nil +} + +type isSandboxStreamEvent_Payload interface { + isSandboxStreamEvent_Payload() +} + +type SandboxStreamEvent_Sandbox struct { + // Latest sandbox snapshot. + Sandbox *Sandbox `protobuf:"bytes,1,opt,name=sandbox,proto3,oneof"` +} + +type SandboxStreamEvent_Log struct { + // One server log line/event. + Log *SandboxLogLine `protobuf:"bytes,2,opt,name=log,proto3,oneof"` +} + +type SandboxStreamEvent_Event struct { + // One platform event. + Event *PlatformEvent `protobuf:"bytes,3,opt,name=event,proto3,oneof"` +} + +type SandboxStreamEvent_Warning struct { + // Warning from the server (e.g. missed messages due to lag). + Warning *SandboxStreamWarning `protobuf:"bytes,4,opt,name=warning,proto3,oneof"` +} + +type SandboxStreamEvent_DraftPolicyUpdate struct { + // Draft policy update notification. + DraftPolicyUpdate *DraftPolicyUpdate `protobuf:"bytes,5,opt,name=draft_policy_update,json=draftPolicyUpdate,proto3,oneof"` +} + +func (*SandboxStreamEvent_Sandbox) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_Log) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_Event) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_Warning) isSandboxStreamEvent_Payload() {} + +func (*SandboxStreamEvent_DraftPolicyUpdate) isSandboxStreamEvent_Payload() {} + +// Log line correlated to a sandbox. +type SandboxLogLine struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + TimestampMs int64 `protobuf:"varint,2,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + Level string `protobuf:"bytes,3,opt,name=level,proto3" json:"level,omitempty"` + Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + // Log source: "gateway" (server-side) or "sandbox" (supervisor). + // Empty is treated as "gateway" for backward compatibility. + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` + // Structured key-value fields from the tracing event (e.g. dst_host, action). + Fields map[string]string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxLogLine) Reset() { + *x = SandboxLogLine{} + mi := &file_openshell_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxLogLine) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxLogLine) ProtoMessage() {} + +func (x *SandboxLogLine) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[51] + 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 SandboxLogLine.ProtoReflect.Descriptor instead. +func (*SandboxLogLine) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{51} +} + +func (x *SandboxLogLine) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SandboxLogLine) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *SandboxLogLine) GetLevel() string { + if x != nil { + return x.Level + } + return "" +} + +func (x *SandboxLogLine) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *SandboxLogLine) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *SandboxLogLine) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *SandboxLogLine) GetFields() map[string]string { + if x != nil { + return x.Fields + } + return nil +} + +type SandboxStreamWarning struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxStreamWarning) Reset() { + *x = SandboxStreamWarning{} + mi := &file_openshell_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxStreamWarning) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxStreamWarning) ProtoMessage() {} + +func (x *SandboxStreamWarning) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[52] + 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 SandboxStreamWarning.ProtoReflect.Descriptor instead. +func (*SandboxStreamWarning) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{52} +} + +func (x *SandboxStreamWarning) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// Create provider request. +type CreateProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateProviderRequest) Reset() { + *x = CreateProviderRequest{} + mi := &file_openshell_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateProviderRequest) ProtoMessage() {} + +func (x *CreateProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[53] + 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 CreateProviderRequest.ProtoReflect.Descriptor instead. +func (*CreateProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{53} +} + +func (x *CreateProviderRequest) GetProvider() *datamodelv1.Provider { + if x != nil { + return x.Provider + } + return nil +} + +// Get provider request. +type GetProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderRequest) Reset() { + *x = GetProviderRequest{} + mi := &file_openshell_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderRequest) ProtoMessage() {} + +func (x *GetProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[54] + 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 GetProviderRequest.ProtoReflect.Descriptor instead. +func (*GetProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{54} +} + +func (x *GetProviderRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// List providers request. +type ListProvidersRequest 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProvidersRequest) Reset() { + *x = ListProvidersRequest{} + mi := &file_openshell_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProvidersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProvidersRequest) ProtoMessage() {} + +func (x *ListProvidersRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[55] + 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 ListProvidersRequest.ProtoReflect.Descriptor instead. +func (*ListProvidersRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{55} +} + +func (x *ListProvidersRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListProvidersRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +// Update provider request. +type UpdateProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + // Optional per-credential expiry timestamps to merge into the provider. + // A zero value removes the expiry for that credential. + CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,2,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateProviderRequest) Reset() { + *x = UpdateProviderRequest{} + mi := &file_openshell_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateProviderRequest) ProtoMessage() {} + +func (x *UpdateProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[56] + 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 UpdateProviderRequest.ProtoReflect.Descriptor instead. +func (*UpdateProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{56} +} + +func (x *UpdateProviderRequest) GetProvider() *datamodelv1.Provider { + if x != nil { + return x.Provider + } + return nil +} + +func (x *UpdateProviderRequest) GetCredentialExpiresAtMs() map[string]int64 { + if x != nil { + return x.CredentialExpiresAtMs + } + return nil +} + +// Delete provider request. +type DeleteProviderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderRequest) Reset() { + *x = DeleteProviderRequest{} + mi := &file_openshell_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderRequest) ProtoMessage() {} + +func (x *DeleteProviderRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[57] + 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 DeleteProviderRequest.ProtoReflect.Descriptor instead. +func (*DeleteProviderRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{57} +} + +func (x *DeleteProviderRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// Provider response. +type ProviderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderResponse) Reset() { + *x = ProviderResponse{} + mi := &file_openshell_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderResponse) ProtoMessage() {} + +func (x *ProviderResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[58] + 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 ProviderResponse.ProtoReflect.Descriptor instead. +func (*ProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{58} +} + +func (x *ProviderResponse) GetProvider() *datamodelv1.Provider { + if x != nil { + return x.Provider + } + return nil +} + +// List providers response. +type ListProvidersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Providers []*datamodelv1.Provider `protobuf:"bytes,1,rep,name=providers,proto3" json:"providers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProvidersResponse) Reset() { + *x = ListProvidersResponse{} + mi := &file_openshell_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProvidersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProvidersResponse) ProtoMessage() {} + +func (x *ListProvidersResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[59] + 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 ListProvidersResponse.ProtoReflect.Descriptor instead. +func (*ListProvidersResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{59} +} + +func (x *ListProvidersResponse) GetProviders() []*datamodelv1.Provider { + if x != nil { + return x.Providers + } + return nil +} + +// List provider type profiles request. +type ListProviderProfilesRequest 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProviderProfilesRequest) Reset() { + *x = ListProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProviderProfilesRequest) ProtoMessage() {} + +func (x *ListProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[60] + 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 ListProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*ListProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{60} +} + +func (x *ListProviderProfilesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListProviderProfilesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +// Fetch provider type profile request. +type GetProviderProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderProfileRequest) Reset() { + *x = GetProviderProfileRequest{} + mi := &file_openshell_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderProfileRequest) ProtoMessage() {} + +func (x *GetProviderProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[61] + 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 GetProviderProfileRequest.ProtoReflect.Descriptor instead. +func (*GetProviderProfileRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{61} +} + +func (x *GetProviderProfileRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Provider profile payload with optional source metadata for diagnostics. +type ProviderProfileImportItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *ProviderProfile `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileImportItem) Reset() { + *x = ProviderProfileImportItem{} + mi := &file_openshell_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileImportItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileImportItem) ProtoMessage() {} + +func (x *ProviderProfileImportItem) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[62] + 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 ProviderProfileImportItem.ProtoReflect.Descriptor instead. +func (*ProviderProfileImportItem) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{62} +} + +func (x *ProviderProfileImportItem) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +func (x *ProviderProfileImportItem) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +// Provider profile validation diagnostic. +type ProviderProfileDiagnostic struct { + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + ProfileId string `protobuf:"bytes,2,opt,name=profile_id,json=profileId,proto3" json:"profile_id,omitempty"` + Field string `protobuf:"bytes,3,opt,name=field,proto3" json:"field,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + Severity string `protobuf:"bytes,5,opt,name=severity,proto3" json:"severity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileDiagnostic) Reset() { + *x = ProviderProfileDiagnostic{} + mi := &file_openshell_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileDiagnostic) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileDiagnostic) ProtoMessage() {} + +func (x *ProviderProfileDiagnostic) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[63] + 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 ProviderProfileDiagnostic.ProtoReflect.Descriptor instead. +func (*ProviderProfileDiagnostic) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{63} +} + +func (x *ProviderProfileDiagnostic) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetProfileId() string { + if x != nil { + return x.ProfileId + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetField() string { + if x != nil { + return x.Field + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ProviderProfileDiagnostic) GetSeverity() string { + if x != nil { + return x.Severity + } + return "" +} + +// Endpoint selector for token grant audience overrides. +type ProviderCredentialTokenGrantAudienceOverride struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional: endpoint host selector. If omitted, inherits the profile endpoint host. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // Optional: endpoint port selector. If omitted, matches the expanded profile endpoint port. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + // Optional: endpoint path selector. If omitted, inherits the profile endpoint path. + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + // Resource audience to request for matching endpoints. + Audience string `protobuf:"bytes,4,opt,name=audience,proto3" json:"audience,omitempty"` + // Optional: OAuth2 scopes to request. If omitted, inherits the token grant scopes. + Scopes []string `protobuf:"bytes,5,rep,name=scopes,proto3" json:"scopes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) Reset() { + *x = ProviderCredentialTokenGrantAudienceOverride{} + mi := &file_openshell_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialTokenGrantAudienceOverride) ProtoMessage() {} + +func (x *ProviderCredentialTokenGrantAudienceOverride) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[64] + 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 ProviderCredentialTokenGrantAudienceOverride.ProtoReflect.Descriptor instead. +func (*ProviderCredentialTokenGrantAudienceOverride) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{64} +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +func (x *ProviderCredentialTokenGrantAudienceOverride) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +// Provider credential token grant configuration. +// When present, the credential is obtained dynamically via OAuth2 grant when needed. +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) + TokenEndpoint string `protobuf:"bytes,1,opt,name=token_endpoint,json=tokenEndpoint,proto3" json:"token_endpoint,omitempty"` + // Optional: default resource audience to request from the token service + Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"` + // Optional: audience to request when fetching the JWT-SVID from SPIRE. + // If omitted, the sandbox derives this from token_endpoint. + JwtSvidAudience string `protobuf:"bytes,6,opt,name=jwt_svid_audience,json=jwtSvidAudience,proto3" json:"jwt_svid_audience,omitempty"` + // Optional: OAuth2 scopes to request + Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` + // Optional: override token cache TTL (seconds) + // If 0 or omitted, use expires_in from token response + CacheTtlSeconds int64 `protobuf:"varint,4,opt,name=cache_ttl_seconds,json=cacheTtlSeconds,proto3" json:"cache_ttl_seconds,omitempty"` + // Optional: endpoint-specific resource audience overrides. + AudienceOverrides []*ProviderCredentialTokenGrantAudienceOverride `protobuf:"bytes,5,rep,name=audience_overrides,json=audienceOverrides,proto3" json:"audience_overrides,omitempty"` + // 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 +} + +func (x *ProviderCredentialTokenGrant) Reset() { + *x = ProviderCredentialTokenGrant{} + mi := &file_openshell_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialTokenGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialTokenGrant) ProtoMessage() {} + +func (x *ProviderCredentialTokenGrant) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[65] + 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 ProviderCredentialTokenGrant.ProtoReflect.Descriptor instead. +func (*ProviderCredentialTokenGrant) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{65} +} + +func (x *ProviderCredentialTokenGrant) GetTokenEndpoint() string { + if x != nil { + return x.TokenEndpoint + } + return "" +} + +func (x *ProviderCredentialTokenGrant) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +func (x *ProviderCredentialTokenGrant) GetJwtSvidAudience() string { + if x != nil { + return x.JwtSvidAudience + } + return "" +} + +func (x *ProviderCredentialTokenGrant) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *ProviderCredentialTokenGrant) GetCacheTtlSeconds() int64 { + if x != nil { + return x.CacheTtlSeconds + } + return 0 +} + +func (x *ProviderCredentialTokenGrant) GetAudienceOverrides() []*ProviderCredentialTokenGrantAudienceOverride { + if x != nil { + return x.AudienceOverrides + } + return nil +} + +func (x *ProviderCredentialTokenGrant) GetClientAssertionType() string { + if x != nil { + return x.ClientAssertionType + } + return "" +} + +// Provider credential declaration. +type ProviderProfileCredential struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + EnvVars []string `protobuf:"bytes,3,rep,name=env_vars,json=envVars,proto3" json:"env_vars,omitempty"` + Required bool `protobuf:"varint,4,opt,name=required,proto3" json:"required,omitempty"` + AuthStyle string `protobuf:"bytes,5,opt,name=auth_style,json=authStyle,proto3" json:"auth_style,omitempty"` + HeaderName string `protobuf:"bytes,6,opt,name=header_name,json=headerName,proto3" json:"header_name,omitempty"` + QueryParam string `protobuf:"bytes,7,opt,name=query_param,json=queryParam,proto3" json:"query_param,omitempty"` + Refresh *ProviderCredentialRefresh `protobuf:"bytes,8,opt,name=refresh,proto3" json:"refresh,omitempty"` + PathTemplate string `protobuf:"bytes,9,opt,name=path_template,json=pathTemplate,proto3" json:"path_template,omitempty"` + TokenGrant *ProviderCredentialTokenGrant `protobuf:"bytes,10,opt,name=token_grant,json=tokenGrant,proto3" json:"token_grant,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileCredential) Reset() { + *x = ProviderProfileCredential{} + mi := &file_openshell_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileCredential) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileCredential) ProtoMessage() {} + +func (x *ProviderProfileCredential) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[66] + 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 ProviderProfileCredential.ProtoReflect.Descriptor instead. +func (*ProviderProfileCredential) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{66} +} + +func (x *ProviderProfileCredential) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProviderProfileCredential) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProviderProfileCredential) GetEnvVars() []string { + if x != nil { + return x.EnvVars + } + return nil +} + +func (x *ProviderProfileCredential) GetRequired() bool { + if x != nil { + return x.Required + } + return false +} + +func (x *ProviderProfileCredential) GetAuthStyle() string { + if x != nil { + return x.AuthStyle + } + return "" +} + +func (x *ProviderProfileCredential) GetHeaderName() string { + if x != nil { + return x.HeaderName + } + return "" +} + +func (x *ProviderProfileCredential) GetQueryParam() string { + if x != nil { + return x.QueryParam + } + return "" +} + +func (x *ProviderProfileCredential) GetRefresh() *ProviderCredentialRefresh { + if x != nil { + return x.Refresh + } + return nil +} + +func (x *ProviderProfileCredential) GetPathTemplate() string { + if x != nil { + return x.PathTemplate + } + return "" +} + +func (x *ProviderProfileCredential) GetTokenGrant() *ProviderCredentialTokenGrant { + if x != nil { + return x.TokenGrant + } + return nil +} + +type ProviderCredentialRefreshMaterial struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Required bool `protobuf:"varint,3,opt,name=required,proto3" json:"required,omitempty"` + Secret bool `protobuf:"varint,4,opt,name=secret,proto3" json:"secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialRefreshMaterial) Reset() { + *x = ProviderCredentialRefreshMaterial{} + mi := &file_openshell_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialRefreshMaterial) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialRefreshMaterial) ProtoMessage() {} + +func (x *ProviderCredentialRefreshMaterial) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[67] + 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 ProviderCredentialRefreshMaterial.ProtoReflect.Descriptor instead. +func (*ProviderCredentialRefreshMaterial) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{67} +} + +func (x *ProviderCredentialRefreshMaterial) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProviderCredentialRefreshMaterial) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProviderCredentialRefreshMaterial) GetRequired() bool { + if x != nil { + return x.Required + } + return false +} + +func (x *ProviderCredentialRefreshMaterial) GetSecret() bool { + if x != nil { + return x.Secret + } + return false +} + +type ProviderCredentialRefresh struct { + state protoimpl.MessageState `protogen:"open.v1"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,1,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + TokenUrl string `protobuf:"bytes,2,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` + RefreshBeforeSeconds int64 `protobuf:"varint,4,opt,name=refresh_before_seconds,json=refreshBeforeSeconds,proto3" json:"refresh_before_seconds,omitempty"` + MaxLifetimeSeconds int64 `protobuf:"varint,5,opt,name=max_lifetime_seconds,json=maxLifetimeSeconds,proto3" json:"max_lifetime_seconds,omitempty"` + Material []*ProviderCredentialRefreshMaterial `protobuf:"bytes,6,rep,name=material,proto3" json:"material,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderCredentialRefresh) Reset() { + *x = ProviderCredentialRefresh{} + mi := &file_openshell_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialRefresh) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialRefresh) ProtoMessage() {} + +func (x *ProviderCredentialRefresh) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[68] + 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 ProviderCredentialRefresh.ProtoReflect.Descriptor instead. +func (*ProviderCredentialRefresh) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{68} +} + +func (x *ProviderCredentialRefresh) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *ProviderCredentialRefresh) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *ProviderCredentialRefresh) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *ProviderCredentialRefresh) GetRefreshBeforeSeconds() int64 { + if x != nil { + return x.RefreshBeforeSeconds + } + return 0 +} + +func (x *ProviderCredentialRefresh) GetMaxLifetimeSeconds() int64 { + if x != nil { + return x.MaxLifetimeSeconds + } + return 0 +} + +func (x *ProviderCredentialRefresh) GetMaterial() []*ProviderCredentialRefreshMaterial { + if x != nil { + return x.Material + } + return nil +} + +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 +} + +func (x *ProviderCredentialRefreshStatus) Reset() { + *x = ProviderCredentialRefreshStatus{} + mi := &file_openshell_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderCredentialRefreshStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderCredentialRefreshStatus) ProtoMessage() {} + +func (x *ProviderCredentialRefreshStatus) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[69] + 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 ProviderCredentialRefreshStatus.ProtoReflect.Descriptor instead. +func (*ProviderCredentialRefreshStatus) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{69} +} + +func (x *ProviderCredentialRefreshStatus) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *ProviderCredentialRefreshStatus) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProviderCredentialRefreshStatus) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +func (x *ProviderCredentialRefreshStatus) GetNextRefreshAtMs() int64 { + if x != nil { + return x.NextRefreshAtMs + } + return 0 +} + +func (x *ProviderCredentialRefreshStatus) GetLastRefreshAtMs() int64 { + if x != nil { + return x.LastRefreshAtMs + } + return 0 +} + +func (x *ProviderCredentialRefreshStatus) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +// Provider profile local discovery declaration. +type ProviderProfileDiscovery struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Credential names from ProviderProfile.credentials eligible for local discovery. + Credentials []string `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileDiscovery) Reset() { + *x = ProviderProfileDiscovery{} + mi := &file_openshell_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileDiscovery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileDiscovery) ProtoMessage() {} + +func (x *ProviderProfileDiscovery) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[70] + 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 ProviderProfileDiscovery.ProtoReflect.Descriptor instead. +func (*ProviderProfileDiscovery) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{70} +} + +func (x *ProviderProfileDiscovery) GetCredentials() []string { + if x != nil { + return x.Credentials + } + return nil +} + +type StoredProviderCredentialRefreshState struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + ProviderName string `protobuf:"bytes,3,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + CredentialKey string `protobuf:"bytes,4,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,5,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Material map[string]string `protobuf:"bytes,6,rep,name=material,proto3" json:"material,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + 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"` + 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"` + LastError string `protobuf:"bytes,12,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + TokenUrl string `protobuf:"bytes,13,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + Scopes []string `protobuf:"bytes,14,rep,name=scopes,proto3" json:"scopes,omitempty"` + RefreshBeforeSeconds int64 `protobuf:"varint,15,opt,name=refresh_before_seconds,json=refreshBeforeSeconds,proto3" json:"refresh_before_seconds,omitempty"` + MaxLifetimeSeconds int64 `protobuf:"varint,16,opt,name=max_lifetime_seconds,json=maxLifetimeSeconds,proto3" json:"max_lifetime_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredProviderCredentialRefreshState) Reset() { + *x = StoredProviderCredentialRefreshState{} + mi := &file_openshell_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredProviderCredentialRefreshState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredProviderCredentialRefreshState) ProtoMessage() {} + +func (x *StoredProviderCredentialRefreshState) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[71] + 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 StoredProviderCredentialRefreshState.ProtoReflect.Descriptor instead. +func (*StoredProviderCredentialRefreshState) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{71} +} + +func (x *StoredProviderCredentialRefreshState) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *StoredProviderCredentialRefreshState) GetMaterial() map[string]string { + if x != nil { + return x.Material + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetSecretMaterialKeys() []string { + if x != nil { + return x.SecretMaterialKeys + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetExpiresAtMs() int64 { + if x != nil { + return x.ExpiresAtMs + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetNextRefreshAtMs() int64 { + if x != nil { + return x.NextRefreshAtMs + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetLastRefreshAtMs() int64 { + if x != nil { + return x.LastRefreshAtMs + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *StoredProviderCredentialRefreshState) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *StoredProviderCredentialRefreshState) GetRefreshBeforeSeconds() int64 { + if x != nil { + return x.RefreshBeforeSeconds + } + return 0 +} + +func (x *StoredProviderCredentialRefreshState) GetMaxLifetimeSeconds() int64 { + if x != nil { + return x.MaxLifetimeSeconds + } + return 0 +} + +type GetProviderRefreshStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderRefreshStatusRequest) Reset() { + *x = GetProviderRefreshStatusRequest{} + mi := &file_openshell_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderRefreshStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderRefreshStatusRequest) ProtoMessage() {} + +func (x *GetProviderRefreshStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[72] + 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 GetProviderRefreshStatusRequest.ProtoReflect.Descriptor instead. +func (*GetProviderRefreshStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{72} +} + +func (x *GetProviderRefreshStatusRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *GetProviderRefreshStatusRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type GetProviderRefreshStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Credentials []*ProviderCredentialRefreshStatus `protobuf:"bytes,1,rep,name=credentials,proto3" json:"credentials,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetProviderRefreshStatusResponse) Reset() { + *x = GetProviderRefreshStatusResponse{} + mi := &file_openshell_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetProviderRefreshStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetProviderRefreshStatusResponse) ProtoMessage() {} + +func (x *GetProviderRefreshStatusResponse) 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 GetProviderRefreshStatusResponse.ProtoReflect.Descriptor instead. +func (*GetProviderRefreshStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{73} +} + +func (x *GetProviderRefreshStatusResponse) GetCredentials() []*ProviderCredentialRefreshStatus { + if x != nil { + return x.Credentials + } + return nil +} + +type ConfigureProviderRefreshRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,3,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Material map[string]string `protobuf:"bytes,4,rep,name=material,proto3" json:"material,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + SecretMaterialKeys []string `protobuf:"bytes,5,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` + ExpiresAtMs *int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3,oneof" json:"expires_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureProviderRefreshRequest) Reset() { + *x = ConfigureProviderRefreshRequest{} + mi := &file_openshell_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureProviderRefreshRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureProviderRefreshRequest) ProtoMessage() {} + +func (x *ConfigureProviderRefreshRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[74] + 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 ConfigureProviderRefreshRequest.ProtoReflect.Descriptor instead. +func (*ConfigureProviderRefreshRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{74} +} + +func (x *ConfigureProviderRefreshRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ConfigureProviderRefreshRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +func (x *ConfigureProviderRefreshRequest) GetStrategy() ProviderCredentialRefreshStrategy { + if x != nil { + return x.Strategy + } + return ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED +} + +func (x *ConfigureProviderRefreshRequest) GetMaterial() map[string]string { + if x != nil { + return x.Material + } + return nil +} + +func (x *ConfigureProviderRefreshRequest) GetSecretMaterialKeys() []string { + if x != nil { + return x.SecretMaterialKeys + } + return nil +} + +func (x *ConfigureProviderRefreshRequest) GetExpiresAtMs() int64 { + if x != nil && x.ExpiresAtMs != nil { + return *x.ExpiresAtMs + } + return 0 +} + +type ConfigureProviderRefreshResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigureProviderRefreshResponse) Reset() { + *x = ConfigureProviderRefreshResponse{} + mi := &file_openshell_proto_msgTypes[75] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigureProviderRefreshResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigureProviderRefreshResponse) ProtoMessage() {} + +func (x *ConfigureProviderRefreshResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[75] + 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 ConfigureProviderRefreshResponse.ProtoReflect.Descriptor instead. +func (*ConfigureProviderRefreshResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{75} +} + +func (x *ConfigureProviderRefreshResponse) GetStatus() *ProviderCredentialRefreshStatus { + if x != nil { + return x.Status + } + return nil +} + +type RotateProviderCredentialRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateProviderCredentialRequest) Reset() { + *x = RotateProviderCredentialRequest{} + mi := &file_openshell_proto_msgTypes[76] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateProviderCredentialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateProviderCredentialRequest) ProtoMessage() {} + +func (x *RotateProviderCredentialRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[76] + 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 RotateProviderCredentialRequest.ProtoReflect.Descriptor instead. +func (*RotateProviderCredentialRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{76} +} + +func (x *RotateProviderCredentialRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *RotateProviderCredentialRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type RotateProviderCredentialResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateProviderCredentialResponse) Reset() { + *x = RotateProviderCredentialResponse{} + mi := &file_openshell_proto_msgTypes[77] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateProviderCredentialResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateProviderCredentialResponse) ProtoMessage() {} + +func (x *RotateProviderCredentialResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[77] + 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 RotateProviderCredentialResponse.ProtoReflect.Descriptor instead. +func (*RotateProviderCredentialResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{77} +} + +func (x *RotateProviderCredentialResponse) GetStatus() *ProviderCredentialRefreshStatus { + if x != nil { + return x.Status + } + return nil +} + +type DeleteProviderRefreshRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderRefreshRequest) Reset() { + *x = DeleteProviderRefreshRequest{} + mi := &file_openshell_proto_msgTypes[78] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderRefreshRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderRefreshRequest) ProtoMessage() {} + +func (x *DeleteProviderRefreshRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[78] + 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 DeleteProviderRefreshRequest.ProtoReflect.Descriptor instead. +func (*DeleteProviderRefreshRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{78} +} + +func (x *DeleteProviderRefreshRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *DeleteProviderRefreshRequest) GetCredentialKey() string { + if x != nil { + return x.CredentialKey + } + return "" +} + +type DeleteProviderRefreshResponse 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 *DeleteProviderRefreshResponse) Reset() { + *x = DeleteProviderRefreshResponse{} + mi := &file_openshell_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderRefreshResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderRefreshResponse) ProtoMessage() {} + +func (x *DeleteProviderRefreshResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[79] + 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 DeleteProviderRefreshResponse.ProtoReflect.Descriptor instead. +func (*DeleteProviderRefreshResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{79} +} + +func (x *DeleteProviderRefreshResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Provider type profile metadata exposed to clients. +type ProviderProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Category ProviderProfileCategory `protobuf:"varint,4,opt,name=category,proto3,enum=openshell.v1.ProviderProfileCategory" json:"category,omitempty"` + Credentials []*ProviderProfileCredential `protobuf:"bytes,5,rep,name=credentials,proto3" json:"credentials,omitempty"` + Endpoints []*sandboxv1.NetworkEndpoint `protobuf:"bytes,6,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + Binaries []*sandboxv1.NetworkBinary `protobuf:"bytes,7,rep,name=binaries,proto3" json:"binaries,omitempty"` + InferenceCapable bool `protobuf:"varint,8,opt,name=inference_capable,json=inferenceCapable,proto3" json:"inference_capable,omitempty"` + Discovery *ProviderProfileDiscovery `protobuf:"bytes,9,opt,name=discovery,proto3" json:"discovery,omitempty"` + // Storage resource version for custom profiles. Built-in profiles and new + // profile files use 0. Gateway responses set this for stored custom profiles. + // Update calls use this for optimistic concurrency. + ResourceVersion uint64 `protobuf:"varint,10,opt,name=resource_version,json=resourceVersion,proto3" json:"resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfile) Reset() { + *x = ProviderProfile{} + mi := &file_openshell_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfile) ProtoMessage() {} + +func (x *ProviderProfile) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[80] + 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 ProviderProfile.ProtoReflect.Descriptor instead. +func (*ProviderProfile) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{80} +} + +func (x *ProviderProfile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ProviderProfile) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *ProviderProfile) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ProviderProfile) GetCategory() ProviderProfileCategory { + if x != nil { + return x.Category + } + return ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED +} + +func (x *ProviderProfile) GetCredentials() []*ProviderProfileCredential { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *ProviderProfile) GetEndpoints() []*sandboxv1.NetworkEndpoint { + if x != nil { + return x.Endpoints + } + return nil +} + +func (x *ProviderProfile) GetBinaries() []*sandboxv1.NetworkBinary { + if x != nil { + return x.Binaries + } + return nil +} + +func (x *ProviderProfile) GetInferenceCapable() bool { + if x != nil { + return x.InferenceCapable + } + return false +} + +func (x *ProviderProfile) GetDiscovery() *ProviderProfileDiscovery { + if x != nil { + return x.Discovery + } + return nil +} + +func (x *ProviderProfile) GetResourceVersion() uint64 { + if x != nil { + return x.ResourceVersion + } + return 0 +} + +// Stored custom provider profile object. +type StoredProviderProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Profile *ProviderProfile `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredProviderProfile) Reset() { + *x = StoredProviderProfile{} + mi := &file_openshell_proto_msgTypes[81] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredProviderProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredProviderProfile) ProtoMessage() {} + +func (x *StoredProviderProfile) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[81] + 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 StoredProviderProfile.ProtoReflect.Descriptor instead. +func (*StoredProviderProfile) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{81} +} + +func (x *StoredProviderProfile) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *StoredProviderProfile) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +// Provider profile response. +type ProviderProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *ProviderProfile `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProviderProfileResponse) Reset() { + *x = ProviderProfileResponse{} + mi := &file_openshell_proto_msgTypes[82] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProviderProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProviderProfileResponse) ProtoMessage() {} + +func (x *ProviderProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[82] + 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 ProviderProfileResponse.ProtoReflect.Descriptor instead. +func (*ProviderProfileResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{82} +} + +func (x *ProviderProfileResponse) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +// List provider profiles response. +type ListProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*ProviderProfile `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProviderProfilesResponse) Reset() { + *x = ListProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[83] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProviderProfilesResponse) ProtoMessage() {} + +func (x *ListProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[83] + 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 ListProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*ListProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{83} +} + +func (x *ListProviderProfilesResponse) GetProfiles() []*ProviderProfile { + if x != nil { + return x.Profiles + } + return nil +} + +// Import custom provider profiles request. +type ImportProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*ProviderProfileImportItem `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImportProviderProfilesRequest) Reset() { + *x = ImportProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[84] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImportProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportProviderProfilesRequest) ProtoMessage() {} + +func (x *ImportProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[84] + 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 ImportProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*ImportProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{84} +} + +func (x *ImportProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { + if x != nil { + return x.Profiles + } + return nil +} + +// Import custom provider profiles response. +type ImportProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Profiles []*ProviderProfile `protobuf:"bytes,2,rep,name=profiles,proto3" json:"profiles,omitempty"` + Imported bool `protobuf:"varint,3,opt,name=imported,proto3" json:"imported,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImportProviderProfilesResponse) Reset() { + *x = ImportProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[85] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImportProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportProviderProfilesResponse) ProtoMessage() {} + +func (x *ImportProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[85] + 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 ImportProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*ImportProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{85} +} + +func (x *ImportProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { + if x != nil { + return x.Diagnostics + } + return nil +} + +func (x *ImportProviderProfilesResponse) GetProfiles() []*ProviderProfile { + if x != nil { + return x.Profiles + } + return nil +} + +func (x *ImportProviderProfilesResponse) GetImported() bool { + if x != nil { + return x.Imported + } + return false +} + +// Update one custom provider profile request. +type UpdateProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profile *ProviderProfileImportItem `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // Expected storage resource version for optimistic concurrency control. + // If 0, the server uses the resource_version embedded in profile.profile. + // Updates without a non-zero version are rejected to prevent stale files from + // silently overwriting newer profile definitions. + ExpectedResourceVersion uint64 `protobuf:"varint,2,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + // Existing custom provider profile ID to update. The payload ID must match. + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateProviderProfilesRequest) Reset() { + *x = UpdateProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[86] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateProviderProfilesRequest) ProtoMessage() {} + +func (x *UpdateProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[86] + 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 UpdateProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*UpdateProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{86} +} + +func (x *UpdateProviderProfilesRequest) GetProfile() *ProviderProfileImportItem { + if x != nil { + return x.Profile + } + return nil +} + +func (x *UpdateProviderProfilesRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +func (x *UpdateProviderProfilesRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Update one custom provider profile response. +type UpdateProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Profile *ProviderProfile `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"` + Updated bool `protobuf:"varint,3,opt,name=updated,proto3" json:"updated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateProviderProfilesResponse) Reset() { + *x = UpdateProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateProviderProfilesResponse) ProtoMessage() {} + +func (x *UpdateProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[87] + 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 UpdateProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*UpdateProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{87} +} + +func (x *UpdateProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { + if x != nil { + return x.Diagnostics + } + return nil +} + +func (x *UpdateProviderProfilesResponse) GetProfile() *ProviderProfile { + if x != nil { + return x.Profile + } + return nil +} + +func (x *UpdateProviderProfilesResponse) GetUpdated() bool { + if x != nil { + return x.Updated + } + return false +} + +// Lint provider profiles request. +type LintProviderProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*ProviderProfileImportItem `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LintProviderProfilesRequest) Reset() { + *x = LintProviderProfilesRequest{} + mi := &file_openshell_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LintProviderProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LintProviderProfilesRequest) ProtoMessage() {} + +func (x *LintProviderProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[88] + 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 LintProviderProfilesRequest.ProtoReflect.Descriptor instead. +func (*LintProviderProfilesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{88} +} + +func (x *LintProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem { + if x != nil { + return x.Profiles + } + return nil +} + +// Lint provider profiles response. +type LintProviderProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Valid bool `protobuf:"varint,2,opt,name=valid,proto3" json:"valid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LintProviderProfilesResponse) Reset() { + *x = LintProviderProfilesResponse{} + mi := &file_openshell_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LintProviderProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LintProviderProfilesResponse) ProtoMessage() {} + +func (x *LintProviderProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[89] + 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 LintProviderProfilesResponse.ProtoReflect.Descriptor instead. +func (*LintProviderProfilesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{89} +} + +func (x *LintProviderProfilesResponse) GetDiagnostics() []*ProviderProfileDiagnostic { + if x != nil { + return x.Diagnostics + } + return nil +} + +func (x *LintProviderProfilesResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +// Delete provider response. +type DeleteProviderResponse 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 *DeleteProviderResponse) Reset() { + *x = DeleteProviderResponse{} + mi := &file_openshell_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderResponse) ProtoMessage() {} + +func (x *DeleteProviderResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[90] + 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 DeleteProviderResponse.ProtoReflect.Descriptor instead. +func (*DeleteProviderResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{90} +} + +func (x *DeleteProviderResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Delete custom provider profile request. +type DeleteProviderProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteProviderProfileRequest) Reset() { + *x = DeleteProviderProfileRequest{} + mi := &file_openshell_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderProfileRequest) ProtoMessage() {} + +func (x *DeleteProviderProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[91] + 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 DeleteProviderProfileRequest.ProtoReflect.Descriptor instead. +func (*DeleteProviderProfileRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{91} +} + +func (x *DeleteProviderProfileRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Delete custom provider profile response. +type DeleteProviderProfileResponse 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 *DeleteProviderProfileResponse) Reset() { + *x = DeleteProviderProfileResponse{} + mi := &file_openshell_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteProviderProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteProviderProfileResponse) ProtoMessage() {} + +func (x *DeleteProviderProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[92] + 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 DeleteProviderProfileResponse.ProtoReflect.Descriptor instead. +func (*DeleteProviderProfileResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{92} +} + +func (x *DeleteProviderProfileResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Get sandbox provider environment request. +type GetSandboxProviderEnvironmentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxProviderEnvironmentRequest) Reset() { + *x = GetSandboxProviderEnvironmentRequest{} + mi := &file_openshell_proto_msgTypes[93] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxProviderEnvironmentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxProviderEnvironmentRequest) ProtoMessage() {} + +func (x *GetSandboxProviderEnvironmentRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[93] + 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 GetSandboxProviderEnvironmentRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxProviderEnvironmentRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{93} +} + +func (x *GetSandboxProviderEnvironmentRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +// Get sandbox provider environment response. +type GetSandboxProviderEnvironmentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Provider credential environment variables. + Environment map[string]string `protobuf:"bytes,1,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Fingerprint for the provider credential inputs that produced environment. + ProviderEnvRevision uint64 `protobuf:"varint,2,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + // Expiration timestamps for returned environment variables. + CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,3,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + // Dynamic credentials that require token grants or other runtime injection. + // Maps endpoint-bound provider metadata to credential metadata. + // Supervisor uses this to inject Authorization headers for token grant credentials. + DynamicCredentials map[string]*ProviderProfileCredential `protobuf:"bytes,4,rep,name=dynamic_credentials,json=dynamicCredentials,proto3" json:"dynamic_credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxProviderEnvironmentResponse) Reset() { + *x = GetSandboxProviderEnvironmentResponse{} + mi := &file_openshell_proto_msgTypes[94] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxProviderEnvironmentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxProviderEnvironmentResponse) ProtoMessage() {} + +func (x *GetSandboxProviderEnvironmentResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[94] + 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 GetSandboxProviderEnvironmentResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxProviderEnvironmentResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{94} +} + +func (x *GetSandboxProviderEnvironmentResponse) GetEnvironment() map[string]string { + if x != nil { + return x.Environment + } + return nil +} + +func (x *GetSandboxProviderEnvironmentResponse) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +func (x *GetSandboxProviderEnvironmentResponse) GetCredentialExpiresAtMs() map[string]int64 { + if x != nil { + return x.CredentialExpiresAtMs + } + return nil +} + +func (x *GetSandboxProviderEnvironmentResponse) GetDynamicCredentials() map[string]*ProviderProfileCredential { + if x != nil { + return x.DynamicCredentials + } + return nil +} + +// Update sandbox policy request. +type UpdateConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. + // Not required when `global=true`. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The new policy to apply. + // + // Sandbox scope (`global=false`): + // - only network_policies and inference fields may differ from create-time + // policy; static fields must match version 1. + // + // Global scope (`global=true`): + // - applies to all sandboxes in full (no merge). + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,2,opt,name=policy,proto3" json:"policy,omitempty"` + // Optional single setting key to mutate. + SettingKey string `protobuf:"bytes,3,opt,name=setting_key,json=settingKey,proto3" json:"setting_key,omitempty"` + // Setting value for upsert operations. + SettingValue *sandboxv1.SettingValue `protobuf:"bytes,4,opt,name=setting_value,json=settingValue,proto3" json:"setting_value,omitempty"` + // Delete the setting key from scope. + // Sandbox-scoped deletes are rejected; only global delete is supported. + DeleteSetting bool `protobuf:"varint,5,opt,name=delete_setting,json=deleteSetting,proto3" json:"delete_setting,omitempty"` + // Apply mutation at gateway-global scope. + Global bool `protobuf:"varint,6,opt,name=global,proto3" json:"global,omitempty"` + // Batched incremental policy merge operations. Sandbox-scoped only. + MergeOperations []*PolicyMergeOperation `protobuf:"bytes,7,rep,name=merge_operations,json=mergeOperations,proto3" json:"merge_operations,omitempty"` + // Expected resource version for optimistic concurrency control (sandbox-scoped only). + // If 0, the server uses the current version (backward compatibility). + // If non-zero, the server validates that the sandbox's current resource_version + // matches this value before applying the mutation, returning ABORTED on mismatch. + // Ignored for global-scoped updates. + ExpectedResourceVersion uint64 `protobuf:"varint,8,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateConfigRequest) Reset() { + *x = UpdateConfigRequest{} + mi := &file_openshell_proto_msgTypes[95] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConfigRequest) ProtoMessage() {} + +func (x *UpdateConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[95] + 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 UpdateConfigRequest.ProtoReflect.Descriptor instead. +func (*UpdateConfigRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{95} +} + +func (x *UpdateConfigRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UpdateConfigRequest) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *UpdateConfigRequest) GetSettingKey() string { + if x != nil { + return x.SettingKey + } + return "" +} + +func (x *UpdateConfigRequest) GetSettingValue() *sandboxv1.SettingValue { + if x != nil { + return x.SettingValue + } + return nil +} + +func (x *UpdateConfigRequest) GetDeleteSetting() bool { + if x != nil { + return x.DeleteSetting + } + return false +} + +func (x *UpdateConfigRequest) GetGlobal() bool { + if x != nil { + return x.Global + } + return false +} + +func (x *UpdateConfigRequest) GetMergeOperations() []*PolicyMergeOperation { + if x != nil { + return x.MergeOperations + } + return nil +} + +func (x *UpdateConfigRequest) GetExpectedResourceVersion() uint64 { + if x != nil { + return x.ExpectedResourceVersion + } + return 0 +} + +type PolicyMergeOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Operation: + // + // *PolicyMergeOperation_AddRule + // *PolicyMergeOperation_RemoveEndpoint + // *PolicyMergeOperation_RemoveRule + // *PolicyMergeOperation_AddDenyRules + // *PolicyMergeOperation_AddAllowRules + // *PolicyMergeOperation_RemoveBinary + Operation isPolicyMergeOperation_Operation `protobuf_oneof:"operation"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyMergeOperation) Reset() { + *x = PolicyMergeOperation{} + mi := &file_openshell_proto_msgTypes[96] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyMergeOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyMergeOperation) ProtoMessage() {} + +func (x *PolicyMergeOperation) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[96] + 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 PolicyMergeOperation.ProtoReflect.Descriptor instead. +func (*PolicyMergeOperation) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{96} +} + +func (x *PolicyMergeOperation) GetOperation() isPolicyMergeOperation_Operation { + if x != nil { + return x.Operation + } + return nil +} + +func (x *PolicyMergeOperation) GetAddRule() *AddNetworkRule { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_AddRule); ok { + return x.AddRule + } + } + return nil +} + +func (x *PolicyMergeOperation) GetRemoveEndpoint() *RemoveNetworkEndpoint { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_RemoveEndpoint); ok { + return x.RemoveEndpoint + } + } + return nil +} + +func (x *PolicyMergeOperation) GetRemoveRule() *RemoveNetworkRule { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_RemoveRule); ok { + return x.RemoveRule + } + } + return nil +} + +func (x *PolicyMergeOperation) GetAddDenyRules() *AddDenyRules { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_AddDenyRules); ok { + return x.AddDenyRules + } + } + return nil +} + +func (x *PolicyMergeOperation) GetAddAllowRules() *AddAllowRules { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_AddAllowRules); ok { + return x.AddAllowRules + } + } + return nil +} + +func (x *PolicyMergeOperation) GetRemoveBinary() *RemoveNetworkBinary { + if x != nil { + if x, ok := x.Operation.(*PolicyMergeOperation_RemoveBinary); ok { + return x.RemoveBinary + } + } + return nil +} + +type isPolicyMergeOperation_Operation interface { + isPolicyMergeOperation_Operation() +} + +type PolicyMergeOperation_AddRule struct { + AddRule *AddNetworkRule `protobuf:"bytes,1,opt,name=add_rule,json=addRule,proto3,oneof"` +} + +type PolicyMergeOperation_RemoveEndpoint struct { + RemoveEndpoint *RemoveNetworkEndpoint `protobuf:"bytes,2,opt,name=remove_endpoint,json=removeEndpoint,proto3,oneof"` +} + +type PolicyMergeOperation_RemoveRule struct { + RemoveRule *RemoveNetworkRule `protobuf:"bytes,3,opt,name=remove_rule,json=removeRule,proto3,oneof"` +} + +type PolicyMergeOperation_AddDenyRules struct { + AddDenyRules *AddDenyRules `protobuf:"bytes,4,opt,name=add_deny_rules,json=addDenyRules,proto3,oneof"` +} + +type PolicyMergeOperation_AddAllowRules struct { + AddAllowRules *AddAllowRules `protobuf:"bytes,5,opt,name=add_allow_rules,json=addAllowRules,proto3,oneof"` +} + +type PolicyMergeOperation_RemoveBinary struct { + RemoveBinary *RemoveNetworkBinary `protobuf:"bytes,6,opt,name=remove_binary,json=removeBinary,proto3,oneof"` +} + +func (*PolicyMergeOperation_AddRule) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_RemoveEndpoint) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_RemoveRule) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_AddDenyRules) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_AddAllowRules) isPolicyMergeOperation_Operation() {} + +func (*PolicyMergeOperation_RemoveBinary) isPolicyMergeOperation_Operation() {} + +type AddNetworkRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + Rule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,2,opt,name=rule,proto3" json:"rule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddNetworkRule) Reset() { + *x = AddNetworkRule{} + mi := &file_openshell_proto_msgTypes[97] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddNetworkRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddNetworkRule) ProtoMessage() {} + +func (x *AddNetworkRule) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[97] + 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 AddNetworkRule.ProtoReflect.Descriptor instead. +func (*AddNetworkRule) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{97} +} + +func (x *AddNetworkRule) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *AddNetworkRule) GetRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.Rule + } + return nil +} + +type RemoveNetworkEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` + Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveNetworkEndpoint) Reset() { + *x = RemoveNetworkEndpoint{} + mi := &file_openshell_proto_msgTypes[98] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveNetworkEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveNetworkEndpoint) ProtoMessage() {} + +func (x *RemoveNetworkEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[98] + 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 RemoveNetworkEndpoint.ProtoReflect.Descriptor instead. +func (*RemoveNetworkEndpoint) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{98} +} + +func (x *RemoveNetworkEndpoint) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *RemoveNetworkEndpoint) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *RemoveNetworkEndpoint) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +type RemoveNetworkRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveNetworkRule) Reset() { + *x = RemoveNetworkRule{} + mi := &file_openshell_proto_msgTypes[99] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveNetworkRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveNetworkRule) ProtoMessage() {} + +func (x *RemoveNetworkRule) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[99] + 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 RemoveNetworkRule.ProtoReflect.Descriptor instead. +func (*RemoveNetworkRule) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{99} +} + +func (x *RemoveNetworkRule) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +type AddDenyRules struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + DenyRules []*sandboxv1.L7DenyRule `protobuf:"bytes,3,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddDenyRules) Reset() { + *x = AddDenyRules{} + mi := &file_openshell_proto_msgTypes[100] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddDenyRules) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddDenyRules) ProtoMessage() {} + +func (x *AddDenyRules) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[100] + 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 AddDenyRules.ProtoReflect.Descriptor instead. +func (*AddDenyRules) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{100} +} + +func (x *AddDenyRules) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *AddDenyRules) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *AddDenyRules) GetDenyRules() []*sandboxv1.L7DenyRule { + if x != nil { + return x.DenyRules + } + return nil +} + +type AddAllowRules struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + Rules []*sandboxv1.L7Rule `protobuf:"bytes,3,rep,name=rules,proto3" json:"rules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddAllowRules) Reset() { + *x = AddAllowRules{} + mi := &file_openshell_proto_msgTypes[101] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddAllowRules) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddAllowRules) ProtoMessage() {} + +func (x *AddAllowRules) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[101] + 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 AddAllowRules.ProtoReflect.Descriptor instead. +func (*AddAllowRules) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{101} +} + +func (x *AddAllowRules) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *AddAllowRules) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *AddAllowRules) GetRules() []*sandboxv1.L7Rule { + if x != nil { + return x.Rules + } + return nil +} + +type RemoveNetworkBinary struct { + state protoimpl.MessageState `protogen:"open.v1"` + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + BinaryPath string `protobuf:"bytes,2,opt,name=binary_path,json=binaryPath,proto3" json:"binary_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveNetworkBinary) Reset() { + *x = RemoveNetworkBinary{} + mi := &file_openshell_proto_msgTypes[102] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveNetworkBinary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveNetworkBinary) ProtoMessage() {} + +func (x *RemoveNetworkBinary) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[102] + 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 RemoveNetworkBinary.ProtoReflect.Descriptor instead. +func (*RemoveNetworkBinary) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{102} +} + +func (x *RemoveNetworkBinary) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *RemoveNetworkBinary) GetBinaryPath() string { + if x != nil { + return x.BinaryPath + } + return "" +} + +// Update sandbox policy response. +type UpdateConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Assigned policy version (monotonically increasing per sandbox). + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + // SHA-256 hash of the serialized policy payload. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Settings revision for the scope that was modified. + SettingsRevision uint64 `protobuf:"varint,3,opt,name=settings_revision,json=settingsRevision,proto3" json:"settings_revision,omitempty"` + // True when a setting delete operation removed an existing key. + Deleted bool `protobuf:"varint,4,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateConfigResponse) Reset() { + *x = UpdateConfigResponse{} + mi := &file_openshell_proto_msgTypes[103] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateConfigResponse) ProtoMessage() {} + +func (x *UpdateConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[103] + 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 UpdateConfigResponse.ProtoReflect.Descriptor instead. +func (*UpdateConfigResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{103} +} + +func (x *UpdateConfigResponse) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *UpdateConfigResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *UpdateConfigResponse) GetSettingsRevision() uint64 { + if x != nil { + return x.SettingsRevision + } + return 0 +} + +func (x *UpdateConfigResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Get sandbox policy status request. +type GetSandboxPolicyStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). Ignored when global is true. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The specific policy version to query. 0 means latest. + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + // Query global policy revisions instead of a sandbox-scoped one. + Global bool `protobuf:"varint,3,opt,name=global,proto3" json:"global,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxPolicyStatusRequest) Reset() { + *x = GetSandboxPolicyStatusRequest{} + mi := &file_openshell_proto_msgTypes[104] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxPolicyStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxPolicyStatusRequest) ProtoMessage() {} + +func (x *GetSandboxPolicyStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[104] + 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 GetSandboxPolicyStatusRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxPolicyStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{104} +} + +func (x *GetSandboxPolicyStatusRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetSandboxPolicyStatusRequest) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *GetSandboxPolicyStatusRequest) GetGlobal() bool { + if x != nil { + return x.Global + } + return false +} + +// Get sandbox policy status response. +type GetSandboxPolicyStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The queried policy revision. + Revision *SandboxPolicyRevision `protobuf:"bytes,1,opt,name=revision,proto3" json:"revision,omitempty"` + // The currently active (loaded) policy version for this sandbox. + ActiveVersion uint32 `protobuf:"varint,2,opt,name=active_version,json=activeVersion,proto3" json:"active_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxPolicyStatusResponse) Reset() { + *x = GetSandboxPolicyStatusResponse{} + mi := &file_openshell_proto_msgTypes[105] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxPolicyStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxPolicyStatusResponse) ProtoMessage() {} + +func (x *GetSandboxPolicyStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[105] + 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 GetSandboxPolicyStatusResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxPolicyStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{105} +} + +func (x *GetSandboxPolicyStatusResponse) GetRevision() *SandboxPolicyRevision { + if x != nil { + return x.Revision + } + return nil +} + +func (x *GetSandboxPolicyStatusResponse) GetActiveVersion() uint32 { + if x != nil { + return x.ActiveVersion + } + return 0 +} + +// List sandbox policies request. +type ListSandboxPoliciesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name (canonical lookup key). Ignored when global is true. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Limit uint32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + Offset uint32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + // List global policy revisions instead of sandbox-scoped ones. + Global bool `protobuf:"varint,4,opt,name=global,proto3" json:"global,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxPoliciesRequest) Reset() { + *x = ListSandboxPoliciesRequest{} + mi := &file_openshell_proto_msgTypes[106] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxPoliciesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxPoliciesRequest) ProtoMessage() {} + +func (x *ListSandboxPoliciesRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[106] + 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 ListSandboxPoliciesRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxPoliciesRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{106} +} + +func (x *ListSandboxPoliciesRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ListSandboxPoliciesRequest) GetLimit() uint32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSandboxPoliciesRequest) GetOffset() uint32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *ListSandboxPoliciesRequest) GetGlobal() bool { + if x != nil { + return x.Global + } + return false +} + +// List sandbox policies response. +type ListSandboxPoliciesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Revisions []*SandboxPolicyRevision `protobuf:"bytes,1,rep,name=revisions,proto3" json:"revisions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxPoliciesResponse) Reset() { + *x = ListSandboxPoliciesResponse{} + mi := &file_openshell_proto_msgTypes[107] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxPoliciesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxPoliciesResponse) ProtoMessage() {} + +func (x *ListSandboxPoliciesResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[107] + 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 ListSandboxPoliciesResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxPoliciesResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{107} +} + +func (x *ListSandboxPoliciesResponse) GetRevisions() []*SandboxPolicyRevision { + if x != nil { + return x.Revisions + } + return nil +} + +// Report policy load status (called by sandbox runtime after reload attempt). +type ReportPolicyStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // The policy version that was attempted. + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + // Load result status. + Status PolicyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=openshell.v1.PolicyStatus" json:"status,omitempty"` + // Error message if status is FAILED. + LoadError string `protobuf:"bytes,4,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportPolicyStatusRequest) Reset() { + *x = ReportPolicyStatusRequest{} + mi := &file_openshell_proto_msgTypes[108] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportPolicyStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportPolicyStatusRequest) ProtoMessage() {} + +func (x *ReportPolicyStatusRequest) 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 ReportPolicyStatusRequest.ProtoReflect.Descriptor instead. +func (*ReportPolicyStatusRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{108} +} + +func (x *ReportPolicyStatusRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ReportPolicyStatusRequest) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *ReportPolicyStatusRequest) GetStatus() PolicyStatus { + if x != nil { + return x.Status + } + return PolicyStatus_POLICY_STATUS_UNSPECIFIED +} + +func (x *ReportPolicyStatusRequest) GetLoadError() string { + if x != nil { + return x.LoadError + } + return "" +} + +// Report policy status response. +type ReportPolicyStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportPolicyStatusResponse) Reset() { + *x = ReportPolicyStatusResponse{} + mi := &file_openshell_proto_msgTypes[109] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportPolicyStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportPolicyStatusResponse) ProtoMessage() {} + +func (x *ReportPolicyStatusResponse) 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 ReportPolicyStatusResponse.ProtoReflect.Descriptor instead. +func (*ReportPolicyStatusResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{109} +} + +// A versioned policy revision with metadata. +type SandboxPolicyRevision struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Policy version (monotonically increasing per sandbox). + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + // SHA-256 hash of the serialized policy payload. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Load status of this revision. + Status PolicyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=openshell.v1.PolicyStatus" json:"status,omitempty"` + // Error message if status is FAILED. + LoadError string `protobuf:"bytes,4,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` + // Milliseconds since epoch when this revision was created. + CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // Milliseconds since epoch when this revision was loaded by the sandbox. + LoadedAtMs int64 `protobuf:"varint,6,opt,name=loaded_at_ms,json=loadedAtMs,proto3" json:"loaded_at_ms,omitempty"` + // The full policy (only populated when explicitly requested). + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxPolicyRevision) Reset() { + *x = SandboxPolicyRevision{} + mi := &file_openshell_proto_msgTypes[110] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxPolicyRevision) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxPolicyRevision) ProtoMessage() {} + +func (x *SandboxPolicyRevision) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[110] + 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 SandboxPolicyRevision.ProtoReflect.Descriptor instead. +func (*SandboxPolicyRevision) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{110} +} + +func (x *SandboxPolicyRevision) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *SandboxPolicyRevision) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *SandboxPolicyRevision) GetStatus() PolicyStatus { + if x != nil { + return x.Status + } + return PolicyStatus_POLICY_STATUS_UNSPECIFIED +} + +func (x *SandboxPolicyRevision) GetLoadError() string { + if x != nil { + return x.LoadError + } + return "" +} + +func (x *SandboxPolicyRevision) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *SandboxPolicyRevision) GetLoadedAtMs() int64 { + if x != nil { + return x.LoadedAtMs + } + return 0 +} + +func (x *SandboxPolicyRevision) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +// Get sandbox logs request (one-shot fetch). +type GetSandboxLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox id. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Maximum number of log lines to return. 0 means use default (2000). + Lines uint32 `protobuf:"varint,2,opt,name=lines,proto3" json:"lines,omitempty"` + // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. + SinceMs int64 `protobuf:"varint,3,opt,name=since_ms,json=sinceMs,proto3" json:"since_ms,omitempty"` + // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. + Sources []string `protobuf:"bytes,4,rep,name=sources,proto3" json:"sources,omitempty"` + // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. + MinLevel string `protobuf:"bytes,5,opt,name=min_level,json=minLevel,proto3" json:"min_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxLogsRequest) Reset() { + *x = GetSandboxLogsRequest{} + mi := &file_openshell_proto_msgTypes[111] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxLogsRequest) ProtoMessage() {} + +func (x *GetSandboxLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[111] + 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 GetSandboxLogsRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxLogsRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{111} +} + +func (x *GetSandboxLogsRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *GetSandboxLogsRequest) GetLines() uint32 { + if x != nil { + return x.Lines + } + return 0 +} + +func (x *GetSandboxLogsRequest) GetSinceMs() int64 { + if x != nil { + return x.SinceMs + } + return 0 +} + +func (x *GetSandboxLogsRequest) GetSources() []string { + if x != nil { + return x.Sources + } + return nil +} + +func (x *GetSandboxLogsRequest) GetMinLevel() string { + if x != nil { + return x.MinLevel + } + return "" +} + +// Batch of log lines pushed from sandbox to server. +type PushSandboxLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Log lines to ingest. + Logs []*SandboxLogLine `protobuf:"bytes,2,rep,name=logs,proto3" json:"logs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushSandboxLogsRequest) Reset() { + *x = PushSandboxLogsRequest{} + mi := &file_openshell_proto_msgTypes[112] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushSandboxLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushSandboxLogsRequest) ProtoMessage() {} + +func (x *PushSandboxLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[112] + 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 PushSandboxLogsRequest.ProtoReflect.Descriptor instead. +func (*PushSandboxLogsRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{112} +} + +func (x *PushSandboxLogsRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *PushSandboxLogsRequest) GetLogs() []*SandboxLogLine { + if x != nil { + return x.Logs + } + return nil +} + +// Push sandbox logs response. +type PushSandboxLogsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PushSandboxLogsResponse) Reset() { + *x = PushSandboxLogsResponse{} + mi := &file_openshell_proto_msgTypes[113] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PushSandboxLogsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PushSandboxLogsResponse) ProtoMessage() {} + +func (x *PushSandboxLogsResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[113] + 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 PushSandboxLogsResponse.ProtoReflect.Descriptor instead. +func (*PushSandboxLogsResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{113} +} + +// Get sandbox logs response. +type GetSandboxLogsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Log lines in chronological order. + Logs []*SandboxLogLine `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` + // Total number of lines in the server's buffer for this sandbox. + BufferTotal uint32 `protobuf:"varint,2,opt,name=buffer_total,json=bufferTotal,proto3" json:"buffer_total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxLogsResponse) Reset() { + *x = GetSandboxLogsResponse{} + mi := &file_openshell_proto_msgTypes[114] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxLogsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxLogsResponse) ProtoMessage() {} + +func (x *GetSandboxLogsResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[114] + 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 GetSandboxLogsResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxLogsResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{114} +} + +func (x *GetSandboxLogsResponse) GetLogs() []*SandboxLogLine { + if x != nil { + return x.Logs + } + return nil +} + +func (x *GetSandboxLogsResponse) GetBufferTotal() uint32 { + if x != nil { + return x.BufferTotal + } + return 0 +} + +// Envelope for supervisor-to-gateway messages on the ConnectSupervisor stream. +type SupervisorMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *SupervisorMessage_Hello + // *SupervisorMessage_Heartbeat + // *SupervisorMessage_RelayOpenResult + // *SupervisorMessage_RelayClose + Payload isSupervisorMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorMessage) Reset() { + *x = SupervisorMessage{} + mi := &file_openshell_proto_msgTypes[115] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorMessage) ProtoMessage() {} + +func (x *SupervisorMessage) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[115] + 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 SupervisorMessage.ProtoReflect.Descriptor instead. +func (*SupervisorMessage) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{115} +} + +func (x *SupervisorMessage) GetPayload() isSupervisorMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *SupervisorMessage) GetHello() *SupervisorHello { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *SupervisorMessage) GetHeartbeat() *SupervisorHeartbeat { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *SupervisorMessage) GetRelayOpenResult() *RelayOpenResult { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_RelayOpenResult); ok { + return x.RelayOpenResult + } + } + return nil +} + +func (x *SupervisorMessage) GetRelayClose() *RelayClose { + if x != nil { + if x, ok := x.Payload.(*SupervisorMessage_RelayClose); ok { + return x.RelayClose + } + } + return nil +} + +type isSupervisorMessage_Payload interface { + isSupervisorMessage_Payload() +} + +type SupervisorMessage_Hello struct { + Hello *SupervisorHello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type SupervisorMessage_Heartbeat struct { + Heartbeat *SupervisorHeartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"` +} + +type SupervisorMessage_RelayOpenResult struct { + RelayOpenResult *RelayOpenResult `protobuf:"bytes,3,opt,name=relay_open_result,json=relayOpenResult,proto3,oneof"` +} + +type SupervisorMessage_RelayClose struct { + RelayClose *RelayClose `protobuf:"bytes,4,opt,name=relay_close,json=relayClose,proto3,oneof"` +} + +func (*SupervisorMessage_Hello) isSupervisorMessage_Payload() {} + +func (*SupervisorMessage_Heartbeat) isSupervisorMessage_Payload() {} + +func (*SupervisorMessage_RelayOpenResult) isSupervisorMessage_Payload() {} + +func (*SupervisorMessage_RelayClose) isSupervisorMessage_Payload() {} + +// Envelope for gateway-to-supervisor messages on the ConnectSupervisor stream. +type GatewayMessage struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *GatewayMessage_SessionAccepted + // *GatewayMessage_SessionRejected + // *GatewayMessage_Heartbeat + // *GatewayMessage_RelayOpen + // *GatewayMessage_RelayClose + Payload isGatewayMessage_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GatewayMessage) Reset() { + *x = GatewayMessage{} + mi := &file_openshell_proto_msgTypes[116] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GatewayMessage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GatewayMessage) ProtoMessage() {} + +func (x *GatewayMessage) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[116] + 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 GatewayMessage.ProtoReflect.Descriptor instead. +func (*GatewayMessage) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{116} +} + +func (x *GatewayMessage) GetPayload() isGatewayMessage_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *GatewayMessage) GetSessionAccepted() *SessionAccepted { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_SessionAccepted); ok { + return x.SessionAccepted + } + } + return nil +} + +func (x *GatewayMessage) GetSessionRejected() *SessionRejected { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_SessionRejected); ok { + return x.SessionRejected + } + } + return nil +} + +func (x *GatewayMessage) GetHeartbeat() *GatewayHeartbeat { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *GatewayMessage) GetRelayOpen() *RelayOpen { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_RelayOpen); ok { + return x.RelayOpen + } + } + return nil +} + +func (x *GatewayMessage) GetRelayClose() *RelayClose { + if x != nil { + if x, ok := x.Payload.(*GatewayMessage_RelayClose); ok { + return x.RelayClose + } + } + return nil +} + +type isGatewayMessage_Payload interface { + isGatewayMessage_Payload() +} + +type GatewayMessage_SessionAccepted struct { + SessionAccepted *SessionAccepted `protobuf:"bytes,1,opt,name=session_accepted,json=sessionAccepted,proto3,oneof"` +} + +type GatewayMessage_SessionRejected struct { + SessionRejected *SessionRejected `protobuf:"bytes,2,opt,name=session_rejected,json=sessionRejected,proto3,oneof"` +} + +type GatewayMessage_Heartbeat struct { + Heartbeat *GatewayHeartbeat `protobuf:"bytes,3,opt,name=heartbeat,proto3,oneof"` +} + +type GatewayMessage_RelayOpen struct { + RelayOpen *RelayOpen `protobuf:"bytes,4,opt,name=relay_open,json=relayOpen,proto3,oneof"` +} + +type GatewayMessage_RelayClose struct { + RelayClose *RelayClose `protobuf:"bytes,5,opt,name=relay_close,json=relayClose,proto3,oneof"` +} + +func (*GatewayMessage_SessionAccepted) isGatewayMessage_Payload() {} + +func (*GatewayMessage_SessionRejected) isGatewayMessage_Payload() {} + +func (*GatewayMessage_Heartbeat) isGatewayMessage_Payload() {} + +func (*GatewayMessage_RelayOpen) isGatewayMessage_Payload() {} + +func (*GatewayMessage_RelayClose) isGatewayMessage_Payload() {} + +// Supervisor identifies itself and the sandbox it manages. +type SupervisorHello struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox ID this supervisor manages. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Supervisor instance ID (e.g. boot id or process epoch). + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorHello) Reset() { + *x = SupervisorHello{} + mi := &file_openshell_proto_msgTypes[117] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorHello) ProtoMessage() {} + +func (x *SupervisorHello) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[117] + 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 SupervisorHello.ProtoReflect.Descriptor instead. +func (*SupervisorHello) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{117} +} + +func (x *SupervisorHello) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SupervisorHello) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +// Gateway accepts the supervisor session. +type SessionAccepted struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-assigned session ID for this connection. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Recommended heartbeat interval in seconds. + HeartbeatIntervalSecs uint32 `protobuf:"varint,2,opt,name=heartbeat_interval_secs,json=heartbeatIntervalSecs,proto3" json:"heartbeat_interval_secs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionAccepted) Reset() { + *x = SessionAccepted{} + mi := &file_openshell_proto_msgTypes[118] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionAccepted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionAccepted) ProtoMessage() {} + +func (x *SessionAccepted) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[118] + 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 SessionAccepted.ProtoReflect.Descriptor instead. +func (*SessionAccepted) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{118} +} + +func (x *SessionAccepted) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionAccepted) GetHeartbeatIntervalSecs() uint32 { + if x != nil { + return x.HeartbeatIntervalSecs + } + return 0 +} + +// Gateway rejects the supervisor session. +type SessionRejected struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable rejection reason. + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRejected) Reset() { + *x = SessionRejected{} + mi := &file_openshell_proto_msgTypes[119] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRejected) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRejected) ProtoMessage() {} + +func (x *SessionRejected) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[119] + 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 SessionRejected.ProtoReflect.Descriptor instead. +func (*SessionRejected) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{119} +} + +func (x *SessionRejected) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Supervisor heartbeat. +type SupervisorHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SupervisorHeartbeat) Reset() { + *x = SupervisorHeartbeat{} + mi := &file_openshell_proto_msgTypes[120] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SupervisorHeartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SupervisorHeartbeat) ProtoMessage() {} + +func (x *SupervisorHeartbeat) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[120] + 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 SupervisorHeartbeat.ProtoReflect.Descriptor instead. +func (*SupervisorHeartbeat) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{120} +} + +// Gateway heartbeat. +type GatewayHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GatewayHeartbeat) Reset() { + *x = GatewayHeartbeat{} + mi := &file_openshell_proto_msgTypes[121] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GatewayHeartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GatewayHeartbeat) ProtoMessage() {} + +func (x *GatewayHeartbeat) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[121] + 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 GatewayHeartbeat.ProtoReflect.Descriptor instead. +func (*GatewayHeartbeat) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{121} +} + +// Gateway requests the supervisor to open a relay channel. +// +// On receiving this, the supervisor should initiate a RelayStream RPC to +// the gateway, sending a RelayInit in the first RelayFrame to associate +// the new HTTP/2 stream with the pending relay slot. The supervisor +// bridges that stream to the requested local target. +type RelayOpen struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-allocated channel identifier (UUID). + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // Target the supervisor should dial inside the sandbox. + // If absent, supervisors treat the relay as SSH for compatibility. + // + // Types that are valid to be assigned to Target: + // + // *RelayOpen_Ssh + // *RelayOpen_Tcp + Target isRelayOpen_Target `protobuf_oneof:"target"` + // Optional service identifier for audit/correlation. + ServiceId string `protobuf:"bytes,5,opt,name=service_id,json=serviceId,proto3" json:"service_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayOpen) Reset() { + *x = RelayOpen{} + mi := &file_openshell_proto_msgTypes[122] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayOpen) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayOpen) ProtoMessage() {} + +func (x *RelayOpen) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[122] + 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 RelayOpen.ProtoReflect.Descriptor instead. +func (*RelayOpen) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{122} +} + +func (x *RelayOpen) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +func (x *RelayOpen) GetTarget() isRelayOpen_Target { + if x != nil { + return x.Target + } + return nil +} + +func (x *RelayOpen) GetSsh() *SshRelayTarget { + if x != nil { + if x, ok := x.Target.(*RelayOpen_Ssh); ok { + return x.Ssh + } + } + return nil +} + +func (x *RelayOpen) GetTcp() *TcpRelayTarget { + if x != nil { + if x, ok := x.Target.(*RelayOpen_Tcp); ok { + return x.Tcp + } + } + return nil +} + +func (x *RelayOpen) GetServiceId() string { + if x != nil { + return x.ServiceId + } + return "" +} + +type isRelayOpen_Target interface { + isRelayOpen_Target() +} + +type RelayOpen_Ssh struct { + Ssh *SshRelayTarget `protobuf:"bytes,2,opt,name=ssh,proto3,oneof"` +} + +type RelayOpen_Tcp struct { + Tcp *TcpRelayTarget `protobuf:"bytes,3,opt,name=tcp,proto3,oneof"` +} + +func (*RelayOpen_Ssh) isRelayOpen_Target() {} + +func (*RelayOpen_Tcp) isRelayOpen_Target() {} + +// Built-in SSH relay target. +type SshRelayTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SshRelayTarget) Reset() { + *x = SshRelayTarget{} + mi := &file_openshell_proto_msgTypes[123] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SshRelayTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SshRelayTarget) ProtoMessage() {} + +func (x *SshRelayTarget) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[123] + 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 SshRelayTarget.ProtoReflect.Descriptor instead. +func (*SshRelayTarget) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{123} +} + +// TCP target dialed by the supervisor from inside the sandbox. +type TcpRelayTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Phase 1 accepts loopback only: 127.0.0.1, ::1, or localhost. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // Target port. Must fit in u16 and be non-zero. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TcpRelayTarget) Reset() { + *x = TcpRelayTarget{} + mi := &file_openshell_proto_msgTypes[124] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TcpRelayTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TcpRelayTarget) ProtoMessage() {} + +func (x *TcpRelayTarget) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[124] + 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 TcpRelayTarget.ProtoReflect.Descriptor instead. +func (*TcpRelayTarget) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{124} +} + +func (x *TcpRelayTarget) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *TcpRelayTarget) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +// Initial RelayStream frame sent by the supervisor to claim a pending relay. +type RelayInit struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-allocated channel identifier (UUID). + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayInit) Reset() { + *x = RelayInit{} + mi := &file_openshell_proto_msgTypes[125] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayInit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayInit) ProtoMessage() {} + +func (x *RelayInit) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[125] + 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 RelayInit.ProtoReflect.Descriptor instead. +func (*RelayInit) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{125} +} + +func (x *RelayInit) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +// A single frame on the RelayStream RPC. +// +// The supervisor MUST send `init` as the first frame. All subsequent frames +// in either direction carry raw bytes in `data`. +type RelayFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *RelayFrame_Init + // *RelayFrame_Data + Payload isRelayFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayFrame) Reset() { + *x = RelayFrame{} + mi := &file_openshell_proto_msgTypes[126] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayFrame) ProtoMessage() {} + +func (x *RelayFrame) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[126] + 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 RelayFrame.ProtoReflect.Descriptor instead. +func (*RelayFrame) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{126} +} + +func (x *RelayFrame) GetPayload() isRelayFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *RelayFrame) GetInit() *RelayInit { + if x != nil { + if x, ok := x.Payload.(*RelayFrame_Init); ok { + return x.Init + } + } + return nil +} + +func (x *RelayFrame) GetData() []byte { + if x != nil { + if x, ok := x.Payload.(*RelayFrame_Data); ok { + return x.Data + } + } + return nil +} + +type isRelayFrame_Payload interface { + isRelayFrame_Payload() +} + +type RelayFrame_Init struct { + Init *RelayInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"` +} + +type RelayFrame_Data struct { + Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +func (*RelayFrame_Init) isRelayFrame_Payload() {} + +func (*RelayFrame_Data) isRelayFrame_Payload() {} + +// Supervisor reports the result of a relay open request. +type RelayOpenResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Channel identifier from the RelayOpen request. + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // True if the relay was successfully established. + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + // Error message if success is false. + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayOpenResult) Reset() { + *x = RelayOpenResult{} + mi := &file_openshell_proto_msgTypes[127] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayOpenResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayOpenResult) ProtoMessage() {} + +func (x *RelayOpenResult) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[127] + 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 RelayOpenResult.ProtoReflect.Descriptor instead. +func (*RelayOpenResult) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{127} +} + +func (x *RelayOpenResult) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +func (x *RelayOpenResult) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *RelayOpenResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// Either side requests closure of a relay channel. +type RelayClose struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Channel identifier to close. + ChannelId string `protobuf:"bytes,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // Optional reason for closure. + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelayClose) Reset() { + *x = RelayClose{} + mi := &file_openshell_proto_msgTypes[128] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelayClose) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelayClose) ProtoMessage() {} + +func (x *RelayClose) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[128] + 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 RelayClose.ProtoReflect.Descriptor instead. +func (*RelayClose) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{128} +} + +func (x *RelayClose) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +func (x *RelayClose) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Observed HTTP method+path pattern from L7 inspection. +type L7RequestSample struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HTTP method: GET, POST, PUT, DELETE, etc. + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // HTTP path: /v1/models, /repos/myorg/issues + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + // L7 decision: "audit" or "deny" (allowed requests not collected). + Decision string `protobuf:"bytes,3,opt,name=decision,proto3" json:"decision,omitempty"` + // Number of times this (method, path) was observed. + Count uint32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7RequestSample) Reset() { + *x = L7RequestSample{} + mi := &file_openshell_proto_msgTypes[129] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7RequestSample) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7RequestSample) ProtoMessage() {} + +func (x *L7RequestSample) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[129] + 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 L7RequestSample.ProtoReflect.Descriptor instead. +func (*L7RequestSample) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{129} +} + +func (x *L7RequestSample) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7RequestSample) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7RequestSample) GetDecision() string { + if x != nil { + return x.Decision + } + return "" +} + +func (x *L7RequestSample) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +// Structured denial summary from sandbox aggregator. +type DenialSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox ID that produced this summary. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Denied destination host. + Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"` + // Denied destination port. + Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` + // Binary that attempted the connection. + Binary string `protobuf:"bytes,4,opt,name=binary,proto3" json:"binary,omitempty"` + // Process ancestor chain. + Ancestors []string `protobuf:"bytes,5,rep,name=ancestors,proto3" json:"ancestors,omitempty"` + // Denial reason from OPA evaluation. + DenyReason string `protobuf:"bytes,6,opt,name=deny_reason,json=denyReason,proto3" json:"deny_reason,omitempty"` + // First denial timestamp (ms since epoch). + FirstSeenMs int64 `protobuf:"varint,7,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` + // Most recent denial timestamp (ms since epoch). + LastSeenMs int64 `protobuf:"varint,8,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Number of denials in the current window. + Count uint32 `protobuf:"varint,9,opt,name=count,proto3" json:"count,omitempty"` + // Events dropped during aggregator cooldown. + SuppressedCount uint32 `protobuf:"varint,10,opt,name=suppressed_count,json=suppressedCount,proto3" json:"suppressed_count,omitempty"` + // Cumulative lifetime count (never resets). + TotalCount uint32 `protobuf:"varint,11,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` + // Distinct cmdline strings observed (sanitized of credentials). + SampleCmdlines []string `protobuf:"bytes,12,rep,name=sample_cmdlines,json=sampleCmdlines,proto3" json:"sample_cmdlines,omitempty"` + // SHA-256 of the binary for audit trail. + BinarySha256 string `protobuf:"bytes,13,opt,name=binary_sha256,json=binarySha256,proto3" json:"binary_sha256,omitempty"` + // True if emitted by stale-flush rather than threshold. + Persistent bool `protobuf:"varint,14,opt,name=persistent,proto3" json:"persistent,omitempty"` + // Denial category: "l4_deny", "l7_deny", "l7_audit", "ssrf". + DenialStage string `protobuf:"bytes,15,opt,name=denial_stage,json=denialStage,proto3" json:"denial_stage,omitempty"` + // Observed HTTP request patterns (from L7 inspection). + L7RequestSamples []*L7RequestSample `protobuf:"bytes,16,rep,name=l7_request_samples,json=l7RequestSamples,proto3" json:"l7_request_samples,omitempty"` + // True if L7 inspection was active during observation window. + L7InspectionActive bool `protobuf:"varint,17,opt,name=l7_inspection_active,json=l7InspectionActive,proto3" json:"l7_inspection_active,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DenialSummary) Reset() { + *x = DenialSummary{} + mi := &file_openshell_proto_msgTypes[130] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DenialSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DenialSummary) ProtoMessage() {} + +func (x *DenialSummary) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[130] + 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 DenialSummary.ProtoReflect.Descriptor instead. +func (*DenialSummary) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{130} +} + +func (x *DenialSummary) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *DenialSummary) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *DenialSummary) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *DenialSummary) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *DenialSummary) GetAncestors() []string { + if x != nil { + return x.Ancestors + } + return nil +} + +func (x *DenialSummary) GetDenyReason() string { + if x != nil { + return x.DenyReason + } + return "" +} + +func (x *DenialSummary) GetFirstSeenMs() int64 { + if x != nil { + return x.FirstSeenMs + } + return 0 +} + +func (x *DenialSummary) GetLastSeenMs() int64 { + if x != nil { + return x.LastSeenMs + } + return 0 +} + +func (x *DenialSummary) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *DenialSummary) GetSuppressedCount() uint32 { + if x != nil { + return x.SuppressedCount + } + return 0 +} + +func (x *DenialSummary) GetTotalCount() uint32 { + if x != nil { + return x.TotalCount + } + return 0 +} + +func (x *DenialSummary) GetSampleCmdlines() []string { + if x != nil { + return x.SampleCmdlines + } + return nil +} + +func (x *DenialSummary) GetBinarySha256() string { + if x != nil { + return x.BinarySha256 + } + return "" +} + +func (x *DenialSummary) GetPersistent() bool { + if x != nil { + return x.Persistent + } + return false +} + +func (x *DenialSummary) GetDenialStage() string { + if x != nil { + return x.DenialStage + } + return "" +} + +func (x *DenialSummary) GetL7RequestSamples() []*L7RequestSample { + if x != nil { + return x.L7RequestSamples + } + return nil +} + +func (x *DenialSummary) GetL7InspectionActive() bool { + if x != nil { + return x.L7InspectionActive + } + return false +} + +// Count of denied actions grouped only by sanitized telemetry category. +type DenialGroupCount struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sanitized denial category, e.g. "connect_policy", "l7_policy", "ssrf". + DenyGroup string `protobuf:"bytes,1,opt,name=deny_group,json=denyGroup,proto3" json:"deny_group,omitempty"` + // Number of denied actions in this category. + DeniedCount uint32 `protobuf:"varint,2,opt,name=denied_count,json=deniedCount,proto3" json:"denied_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DenialGroupCount) Reset() { + *x = DenialGroupCount{} + mi := &file_openshell_proto_msgTypes[131] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DenialGroupCount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DenialGroupCount) ProtoMessage() {} + +func (x *DenialGroupCount) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[131] + 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 DenialGroupCount.ProtoReflect.Descriptor instead. +func (*DenialGroupCount) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{131} +} + +func (x *DenialGroupCount) GetDenyGroup() string { + if x != nil { + return x.DenyGroup + } + return "" +} + +func (x *DenialGroupCount) GetDeniedCount() uint32 { + if x != nil { + return x.DeniedCount + } + return 0 +} + +// Anonymous sandbox network activity counters. This intentionally excludes +// hosts, paths, binaries, raw deny reasons, sandbox IDs, and user content. +type NetworkActivitySummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Total observed network activities in the current window. + NetworkActivityCount uint32 `protobuf:"varint,1,opt,name=network_activity_count,json=networkActivityCount,proto3" json:"network_activity_count,omitempty"` + // Total denied actions in the current window. + DeniedActionCount uint32 `protobuf:"varint,2,opt,name=denied_action_count,json=deniedActionCount,proto3" json:"denied_action_count,omitempty"` + // Denied action counts grouped by sanitized category. + DenialsByGroup []*DenialGroupCount `protobuf:"bytes,3,rep,name=denials_by_group,json=denialsByGroup,proto3" json:"denials_by_group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkActivitySummary) Reset() { + *x = NetworkActivitySummary{} + mi := &file_openshell_proto_msgTypes[132] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkActivitySummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkActivitySummary) ProtoMessage() {} + +func (x *NetworkActivitySummary) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[132] + 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 NetworkActivitySummary.ProtoReflect.Descriptor instead. +func (*NetworkActivitySummary) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{132} +} + +func (x *NetworkActivitySummary) GetNetworkActivityCount() uint32 { + if x != nil { + return x.NetworkActivityCount + } + return 0 +} + +func (x *NetworkActivitySummary) GetDeniedActionCount() uint32 { + if x != nil { + return x.DeniedActionCount + } + return 0 +} + +func (x *NetworkActivitySummary) GetDenialsByGroup() []*DenialGroupCount { + if x != nil { + return x.DenialsByGroup + } + return nil +} + +// A proposed policy rule with rationale and approval status. +type PolicyChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Unique chunk identifier. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Approval status: "pending", "approved", "rejected". + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + // Proposed network_policies map key. + RuleName string `protobuf:"bytes,3,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + // The proposed network policy rule. + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,4,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + // Human-readable explanation of why this rule is proposed. + Rationale string `protobuf:"bytes,5,opt,name=rationale,proto3" json:"rationale,omitempty"` + // Security concerns flagged by analysis (empty if none). + SecurityNotes string `protobuf:"bytes,6,opt,name=security_notes,json=securityNotes,proto3" json:"security_notes,omitempty"` + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + Confidence float32 `protobuf:"fixed32,7,opt,name=confidence,proto3" json:"confidence,omitempty"` + // IDs of denial summaries that led to this chunk. + DenialSummaryIds []string `protobuf:"bytes,8,rep,name=denial_summary_ids,json=denialSummaryIds,proto3" json:"denial_summary_ids,omitempty"` + // Creation timestamp (ms since epoch). + CreatedAtMs int64 `protobuf:"varint,9,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // When the user approved/rejected (ms since epoch). 0 if undecided. + DecidedAtMs int64 `protobuf:"varint,10,opt,name=decided_at_ms,json=decidedAtMs,proto3" json:"decided_at_ms,omitempty"` + // Recommendation stage: "initial" or "refined" (progressive L7 visibility). + Stage string `protobuf:"bytes,11,opt,name=stage,proto3" json:"stage,omitempty"` + // For stage="refined": the initial chunk this replaces. + SupersedesChunkId string `protobuf:"bytes,12,opt,name=supersedes_chunk_id,json=supersedesChunkId,proto3" json:"supersedes_chunk_id,omitempty"` + // How many times this endpoint has been seen across denial flush cycles. + HitCount int32 `protobuf:"varint,13,opt,name=hit_count,json=hitCount,proto3" json:"hit_count,omitempty"` + // First time this endpoint was proposed (ms since epoch). + FirstSeenMs int64 `protobuf:"varint,14,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` + // Most recent time this endpoint was re-proposed (ms since epoch). + LastSeenMs int64 `protobuf:"varint,15,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Binary path that triggered the denial (denormalized for display convenience). + Binary string `protobuf:"bytes,16,opt,name=binary,proto3" json:"binary,omitempty"` + // Validation verdict from gateway-side static checks (prover output). + // Free-form summary string for human consumption in the inbox card. + // Empty until the prover has run for this chunk. + ValidationResult string `protobuf:"bytes,17,opt,name=validation_result,json=validationResult,proto3" json:"validation_result,omitempty"` + // Operator-supplied free-form text accompanying a rejection. Populated + // when the reviewer rejects via `RejectDraftChunkRequest.reason`; surfaced + // back to the in-sandbox agent so it can revise the proposal. + // Empty for non-rejected chunks. + RejectionReason string `protobuf:"bytes,18,opt,name=rejection_reason,json=rejectionReason,proto3" json:"rejection_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyChunk) Reset() { + *x = PolicyChunk{} + mi := &file_openshell_proto_msgTypes[133] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyChunk) ProtoMessage() {} + +func (x *PolicyChunk) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[133] + 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 PolicyChunk.ProtoReflect.Descriptor instead. +func (*PolicyChunk) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{133} +} + +func (x *PolicyChunk) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PolicyChunk) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *PolicyChunk) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *PolicyChunk) GetProposedRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.ProposedRule + } + return nil +} + +func (x *PolicyChunk) GetRationale() string { + if x != nil { + return x.Rationale + } + return "" +} + +func (x *PolicyChunk) GetSecurityNotes() string { + if x != nil { + return x.SecurityNotes + } + return "" +} + +func (x *PolicyChunk) GetConfidence() float32 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *PolicyChunk) GetDenialSummaryIds() []string { + if x != nil { + return x.DenialSummaryIds + } + return nil +} + +func (x *PolicyChunk) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *PolicyChunk) GetDecidedAtMs() int64 { + if x != nil { + return x.DecidedAtMs + } + return 0 +} + +func (x *PolicyChunk) GetStage() string { + if x != nil { + return x.Stage + } + return "" +} + +func (x *PolicyChunk) GetSupersedesChunkId() string { + if x != nil { + return x.SupersedesChunkId + } + return "" +} + +func (x *PolicyChunk) GetHitCount() int32 { + if x != nil { + return x.HitCount + } + return 0 +} + +func (x *PolicyChunk) GetFirstSeenMs() int64 { + if x != nil { + return x.FirstSeenMs + } + return 0 +} + +func (x *PolicyChunk) GetLastSeenMs() int64 { + if x != nil { + return x.LastSeenMs + } + return 0 +} + +func (x *PolicyChunk) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *PolicyChunk) GetValidationResult() string { + if x != nil { + return x.ValidationResult + } + return "" +} + +func (x *PolicyChunk) GetRejectionReason() string { + if x != nil { + return x.RejectionReason + } + return "" +} + +// Notification that the draft policy was updated. +type DraftPolicyUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Current draft version. + DraftVersion uint64 `protobuf:"varint,1,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + // Number of new chunks added in this update. + NewChunks uint32 `protobuf:"varint,2,opt,name=new_chunks,json=newChunks,proto3" json:"new_chunks,omitempty"` + // Total pending chunks awaiting approval. + TotalPending uint32 `protobuf:"varint,3,opt,name=total_pending,json=totalPending,proto3" json:"total_pending,omitempty"` + // Brief description of what changed. + Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DraftPolicyUpdate) Reset() { + *x = DraftPolicyUpdate{} + mi := &file_openshell_proto_msgTypes[134] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DraftPolicyUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DraftPolicyUpdate) ProtoMessage() {} + +func (x *DraftPolicyUpdate) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[134] + 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 DraftPolicyUpdate.ProtoReflect.Descriptor instead. +func (*DraftPolicyUpdate) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{134} +} + +func (x *DraftPolicyUpdate) GetDraftVersion() uint64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *DraftPolicyUpdate) GetNewChunks() uint32 { + if x != nil { + return x.NewChunks + } + return 0 +} + +func (x *DraftPolicyUpdate) GetTotalPending() uint32 { + if x != nil { + return x.TotalPending + } + return 0 +} + +func (x *DraftPolicyUpdate) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +// Submit analysis results from sandbox to gateway. +type SubmitPolicyAnalysisRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Aggregated denial summaries. + Summaries []*DenialSummary `protobuf:"bytes,1,rep,name=summaries,proto3" json:"summaries,omitempty"` + // Proposed policy chunks (validated by sandbox OPA engine). + ProposedChunks []*PolicyChunk `protobuf:"bytes,2,rep,name=proposed_chunks,json=proposedChunks,proto3" json:"proposed_chunks,omitempty"` + // Analysis mode. `mechanistic` is the observation-driven path from the + // denial aggregator — chunks targeting the same host|port|binary fold + // into one row with hit_count incremented. `agent_authored` is an + // intentional proposal from an in-sandbox agent — each submission lands + // as its own chunk so the redraft-after-rejection loop has a stable id + // to watch. Other values are treated as agent-style (no dedup) so a new + // mode does not silently collapse proposals. + AnalysisMode string `protobuf:"bytes,3,opt,name=analysis_mode,json=analysisMode,proto3" json:"analysis_mode,omitempty"` + // Sandbox name. + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + // Anonymous network activity counters. + NetworkActivitySummaries []*NetworkActivitySummary `protobuf:"bytes,5,rep,name=network_activity_summaries,json=networkActivitySummaries,proto3" json:"network_activity_summaries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitPolicyAnalysisRequest) Reset() { + *x = SubmitPolicyAnalysisRequest{} + mi := &file_openshell_proto_msgTypes[135] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitPolicyAnalysisRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitPolicyAnalysisRequest) ProtoMessage() {} + +func (x *SubmitPolicyAnalysisRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[135] + 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 SubmitPolicyAnalysisRequest.ProtoReflect.Descriptor instead. +func (*SubmitPolicyAnalysisRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{135} +} + +func (x *SubmitPolicyAnalysisRequest) GetSummaries() []*DenialSummary { + if x != nil { + return x.Summaries + } + return nil +} + +func (x *SubmitPolicyAnalysisRequest) GetProposedChunks() []*PolicyChunk { + if x != nil { + return x.ProposedChunks + } + return nil +} + +func (x *SubmitPolicyAnalysisRequest) GetAnalysisMode() string { + if x != nil { + return x.AnalysisMode + } + return "" +} + +func (x *SubmitPolicyAnalysisRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SubmitPolicyAnalysisRequest) GetNetworkActivitySummaries() []*NetworkActivitySummary { + if x != nil { + return x.NetworkActivitySummaries + } + return nil +} + +type SubmitPolicyAnalysisResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of chunks accepted by the gateway. + AcceptedChunks uint32 `protobuf:"varint,1,opt,name=accepted_chunks,json=acceptedChunks,proto3" json:"accepted_chunks,omitempty"` + // Number of chunks rejected by gateway validation. + RejectedChunks uint32 `protobuf:"varint,2,opt,name=rejected_chunks,json=rejectedChunks,proto3" json:"rejected_chunks,omitempty"` + // Reasons for each rejected chunk. + RejectionReasons []string `protobuf:"bytes,3,rep,name=rejection_reasons,json=rejectionReasons,proto3" json:"rejection_reasons,omitempty"` + // Server-assigned chunk IDs for the accepted chunks, in submission order. + // Agents use these to watch proposal state via policy.local's + // GET /v1/proposals/{id} and /wait endpoints. + AcceptedChunkIds []string `protobuf:"bytes,4,rep,name=accepted_chunk_ids,json=acceptedChunkIds,proto3" json:"accepted_chunk_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SubmitPolicyAnalysisResponse) Reset() { + *x = SubmitPolicyAnalysisResponse{} + mi := &file_openshell_proto_msgTypes[136] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SubmitPolicyAnalysisResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubmitPolicyAnalysisResponse) ProtoMessage() {} + +func (x *SubmitPolicyAnalysisResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[136] + 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 SubmitPolicyAnalysisResponse.ProtoReflect.Descriptor instead. +func (*SubmitPolicyAnalysisResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{136} +} + +func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunks() uint32 { + if x != nil { + return x.AcceptedChunks + } + return 0 +} + +func (x *SubmitPolicyAnalysisResponse) GetRejectedChunks() uint32 { + if x != nil { + return x.RejectedChunks + } + return 0 +} + +func (x *SubmitPolicyAnalysisResponse) GetRejectionReasons() []string { + if x != nil { + return x.RejectionReasons + } + return nil +} + +func (x *SubmitPolicyAnalysisResponse) GetAcceptedChunkIds() []string { + if x != nil { + return x.AcceptedChunkIds + } + return nil +} + +// Get draft policy for a sandbox. +type GetDraftPolicyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Optional status filter: "pending", "approved", "rejected", or "" for all. + StatusFilter string `protobuf:"bytes,2,opt,name=status_filter,json=statusFilter,proto3" json:"status_filter,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftPolicyRequest) Reset() { + *x = GetDraftPolicyRequest{} + mi := &file_openshell_proto_msgTypes[137] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftPolicyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftPolicyRequest) ProtoMessage() {} + +func (x *GetDraftPolicyRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[137] + 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 GetDraftPolicyRequest.ProtoReflect.Descriptor instead. +func (*GetDraftPolicyRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{137} +} + +func (x *GetDraftPolicyRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetDraftPolicyRequest) GetStatusFilter() string { + if x != nil { + return x.StatusFilter + } + return "" +} + +type GetDraftPolicyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Draft policy chunks. + Chunks []*PolicyChunk `protobuf:"bytes,1,rep,name=chunks,proto3" json:"chunks,omitempty"` + // LLM-generated summary of all analysis (empty in mechanistic mode). + RollingSummary string `protobuf:"bytes,2,opt,name=rolling_summary,json=rollingSummary,proto3" json:"rolling_summary,omitempty"` + // Current draft version. + DraftVersion uint64 `protobuf:"varint,3,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + // When the last analysis completed (ms since epoch). + LastAnalyzedAtMs int64 `protobuf:"varint,4,opt,name=last_analyzed_at_ms,json=lastAnalyzedAtMs,proto3" json:"last_analyzed_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftPolicyResponse) Reset() { + *x = GetDraftPolicyResponse{} + mi := &file_openshell_proto_msgTypes[138] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftPolicyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftPolicyResponse) ProtoMessage() {} + +func (x *GetDraftPolicyResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[138] + 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 GetDraftPolicyResponse.ProtoReflect.Descriptor instead. +func (*GetDraftPolicyResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{138} +} + +func (x *GetDraftPolicyResponse) GetChunks() []*PolicyChunk { + if x != nil { + return x.Chunks + } + return nil +} + +func (x *GetDraftPolicyResponse) GetRollingSummary() string { + if x != nil { + return x.RollingSummary + } + return "" +} + +func (x *GetDraftPolicyResponse) GetDraftVersion() uint64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *GetDraftPolicyResponse) GetLastAnalyzedAtMs() int64 { + if x != nil { + return x.LastAnalyzedAtMs + } + return 0 +} + +// Approve a single draft chunk. +type ApproveDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to approve. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveDraftChunkRequest) Reset() { + *x = ApproveDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[139] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveDraftChunkRequest) ProtoMessage() {} + +func (x *ApproveDraftChunkRequest) 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 ApproveDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*ApproveDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{139} +} + +func (x *ApproveDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ApproveDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +type ApproveDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // New policy version after merge. + PolicyVersion uint32 `protobuf:"varint,1,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + // SHA-256 hash of the new policy. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveDraftChunkResponse) Reset() { + *x = ApproveDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[140] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveDraftChunkResponse) ProtoMessage() {} + +func (x *ApproveDraftChunkResponse) 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 ApproveDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*ApproveDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{140} +} + +func (x *ApproveDraftChunkResponse) GetPolicyVersion() uint32 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *ApproveDraftChunkResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +// Reject a single draft chunk. +type RejectDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to reject. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + // Optional reason for rejection (fed to LLM context in future analysis). + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectDraftChunkRequest) Reset() { + *x = RejectDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[141] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectDraftChunkRequest) ProtoMessage() {} + +func (x *RejectDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[141] + 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 RejectDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*RejectDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{141} +} + +func (x *RejectDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *RejectDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +func (x *RejectDraftChunkRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type RejectDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RejectDraftChunkResponse) Reset() { + *x = RejectDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[142] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RejectDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RejectDraftChunkResponse) ProtoMessage() {} + +func (x *RejectDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[142] + 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 RejectDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*RejectDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{142} +} + +// Approve all pending chunks. +type ApproveAllDraftChunksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Include chunks with security_notes (default false: skips them). + IncludeSecurityFlagged bool `protobuf:"varint,2,opt,name=include_security_flagged,json=includeSecurityFlagged,proto3" json:"include_security_flagged,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveAllDraftChunksRequest) Reset() { + *x = ApproveAllDraftChunksRequest{} + mi := &file_openshell_proto_msgTypes[143] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveAllDraftChunksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveAllDraftChunksRequest) ProtoMessage() {} + +func (x *ApproveAllDraftChunksRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[143] + 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 ApproveAllDraftChunksRequest.ProtoReflect.Descriptor instead. +func (*ApproveAllDraftChunksRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{143} +} + +func (x *ApproveAllDraftChunksRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ApproveAllDraftChunksRequest) GetIncludeSecurityFlagged() bool { + if x != nil { + return x.IncludeSecurityFlagged + } + return false +} + +type ApproveAllDraftChunksResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // New policy version after merge. + PolicyVersion uint32 `protobuf:"varint,1,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + // SHA-256 hash of the new policy. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Number of chunks approved. + ChunksApproved uint32 `protobuf:"varint,3,opt,name=chunks_approved,json=chunksApproved,proto3" json:"chunks_approved,omitempty"` + // Number of chunks skipped (security-flagged). + ChunksSkipped uint32 `protobuf:"varint,4,opt,name=chunks_skipped,json=chunksSkipped,proto3" json:"chunks_skipped,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApproveAllDraftChunksResponse) Reset() { + *x = ApproveAllDraftChunksResponse{} + mi := &file_openshell_proto_msgTypes[144] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApproveAllDraftChunksResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApproveAllDraftChunksResponse) ProtoMessage() {} + +func (x *ApproveAllDraftChunksResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[144] + 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 ApproveAllDraftChunksResponse.ProtoReflect.Descriptor instead. +func (*ApproveAllDraftChunksResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{144} +} + +func (x *ApproveAllDraftChunksResponse) GetPolicyVersion() uint32 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *ApproveAllDraftChunksResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *ApproveAllDraftChunksResponse) GetChunksApproved() uint32 { + if x != nil { + return x.ChunksApproved + } + return 0 +} + +func (x *ApproveAllDraftChunksResponse) GetChunksSkipped() uint32 { + if x != nil { + return x.ChunksSkipped + } + return 0 +} + +// Edit a pending chunk in-place. +type EditDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to edit. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + // The modified rule (replaces existing proposed_rule). + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditDraftChunkRequest) Reset() { + *x = EditDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[145] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditDraftChunkRequest) ProtoMessage() {} + +func (x *EditDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[145] + 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 EditDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*EditDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{145} +} + +func (x *EditDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EditDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +func (x *EditDraftChunkRequest) GetProposedRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.ProposedRule + } + return nil +} + +type EditDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EditDraftChunkResponse) Reset() { + *x = EditDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[146] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EditDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EditDraftChunkResponse) ProtoMessage() {} + +func (x *EditDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[146] + 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 EditDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*EditDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{146} +} + +// Reverse an approval (remove merged rule from active policy). +type UndoDraftChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Chunk ID to undo. + ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UndoDraftChunkRequest) Reset() { + *x = UndoDraftChunkRequest{} + mi := &file_openshell_proto_msgTypes[147] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UndoDraftChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UndoDraftChunkRequest) ProtoMessage() {} + +func (x *UndoDraftChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[147] + 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 UndoDraftChunkRequest.ProtoReflect.Descriptor instead. +func (*UndoDraftChunkRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{147} +} + +func (x *UndoDraftChunkRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UndoDraftChunkRequest) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +type UndoDraftChunkResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // New policy version after removal. + PolicyVersion uint32 `protobuf:"varint,1,opt,name=policy_version,json=policyVersion,proto3" json:"policy_version,omitempty"` + // SHA-256 hash of the updated policy. + PolicyHash string `protobuf:"bytes,2,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UndoDraftChunkResponse) Reset() { + *x = UndoDraftChunkResponse{} + mi := &file_openshell_proto_msgTypes[148] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UndoDraftChunkResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UndoDraftChunkResponse) ProtoMessage() {} + +func (x *UndoDraftChunkResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[148] + 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 UndoDraftChunkResponse.ProtoReflect.Descriptor instead. +func (*UndoDraftChunkResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{148} +} + +func (x *UndoDraftChunkResponse) GetPolicyVersion() uint32 { + if x != nil { + return x.PolicyVersion + } + return 0 +} + +func (x *UndoDraftChunkResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +// Clear all pending draft chunks for a sandbox. +type ClearDraftChunksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearDraftChunksRequest) Reset() { + *x = ClearDraftChunksRequest{} + mi := &file_openshell_proto_msgTypes[149] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearDraftChunksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearDraftChunksRequest) ProtoMessage() {} + +func (x *ClearDraftChunksRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[149] + 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 ClearDraftChunksRequest.ProtoReflect.Descriptor instead. +func (*ClearDraftChunksRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{149} +} + +func (x *ClearDraftChunksRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type ClearDraftChunksResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of chunks cleared. + ChunksCleared uint32 `protobuf:"varint,1,opt,name=chunks_cleared,json=chunksCleared,proto3" json:"chunks_cleared,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearDraftChunksResponse) Reset() { + *x = ClearDraftChunksResponse{} + mi := &file_openshell_proto_msgTypes[150] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearDraftChunksResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearDraftChunksResponse) ProtoMessage() {} + +func (x *ClearDraftChunksResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[150] + 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 ClearDraftChunksResponse.ProtoReflect.Descriptor instead. +func (*ClearDraftChunksResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{150} +} + +func (x *ClearDraftChunksResponse) GetChunksCleared() uint32 { + if x != nil { + return x.ChunksCleared + } + return 0 +} + +// Get decision history for a sandbox's draft policy. +type GetDraftHistoryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftHistoryRequest) Reset() { + *x = GetDraftHistoryRequest{} + mi := &file_openshell_proto_msgTypes[151] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftHistoryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftHistoryRequest) ProtoMessage() {} + +func (x *GetDraftHistoryRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[151] + 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 GetDraftHistoryRequest.ProtoReflect.Descriptor instead. +func (*GetDraftHistoryRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{151} +} + +func (x *GetDraftHistoryRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DraftHistoryEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Event timestamp (ms since epoch). + TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + // Event type: "denial_detected", "analysis_cycle", "approved", + // "rejected", "edited", "undone", "cleared". + EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + // Human-readable description. + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Associated chunk ID (if applicable). + ChunkId string `protobuf:"bytes,4,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DraftHistoryEntry) Reset() { + *x = DraftHistoryEntry{} + mi := &file_openshell_proto_msgTypes[152] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DraftHistoryEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DraftHistoryEntry) ProtoMessage() {} + +func (x *DraftHistoryEntry) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[152] + 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 DraftHistoryEntry.ProtoReflect.Descriptor instead. +func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{152} +} + +func (x *DraftHistoryEntry) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *DraftHistoryEntry) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *DraftHistoryEntry) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *DraftHistoryEntry) GetChunkId() string { + if x != nil { + return x.ChunkId + } + return "" +} + +type GetDraftHistoryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Chronological decision history. + Entries []*DraftHistoryEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetDraftHistoryResponse) Reset() { + *x = GetDraftHistoryResponse{} + mi := &file_openshell_proto_msgTypes[153] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetDraftHistoryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetDraftHistoryResponse) ProtoMessage() {} + +func (x *GetDraftHistoryResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[153] + 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 GetDraftHistoryResponse.ProtoReflect.Descriptor instead. +func (*GetDraftHistoryResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{153} +} + +func (x *GetDraftHistoryResponse) GetEntries() []*DraftHistoryEntry { + if x != nil { + return x.Entries + } + return nil +} + +// Stored payload for a policy revision row in the generic objects table. +type PolicyRevisionPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Serialized policy contents. + Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` + // Deterministic hash of the policy payload. + Hash string `protobuf:"bytes,2,opt,name=hash,proto3" json:"hash,omitempty"` + // Load error reported by the sandbox, if any. + LoadError string `protobuf:"bytes,3,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` + // When the policy version was reported as loaded (ms since epoch). 0 if unset. + LoadedAtMs int64 `protobuf:"varint,4,opt,name=loaded_at_ms,json=loadedAtMs,proto3" json:"loaded_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyRevisionPayload) Reset() { + *x = PolicyRevisionPayload{} + mi := &file_openshell_proto_msgTypes[154] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyRevisionPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyRevisionPayload) ProtoMessage() {} + +func (x *PolicyRevisionPayload) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[154] + 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 PolicyRevisionPayload.ProtoReflect.Descriptor instead. +func (*PolicyRevisionPayload) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{154} +} + +func (x *PolicyRevisionPayload) GetPolicy() *sandboxv1.SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *PolicyRevisionPayload) GetHash() string { + if x != nil { + return x.Hash + } + return "" +} + +func (x *PolicyRevisionPayload) GetLoadError() string { + if x != nil { + return x.LoadError + } + return "" +} + +func (x *PolicyRevisionPayload) GetLoadedAtMs() int64 { + if x != nil { + return x.LoadedAtMs + } + return 0 +} + +// Stored payload for a draft policy chunk row in the generic objects table. +type DraftChunkPayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Proposed network_policies map key. + RuleName string `protobuf:"bytes,1,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + // Proposed network policy rule. + ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,2,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + // Human-readable explanation of why this rule is proposed. + Rationale string `protobuf:"bytes,3,opt,name=rationale,proto3" json:"rationale,omitempty"` + // Security concerns flagged by analysis (empty if none). + SecurityNotes string `protobuf:"bytes,4,opt,name=security_notes,json=securityNotes,proto3" json:"security_notes,omitempty"` + // Analysis confidence (0.0-1.0). 0 for mechanistic mode. + Confidence float32 `protobuf:"fixed32,5,opt,name=confidence,proto3" json:"confidence,omitempty"` + // When the user approved/rejected (ms since epoch). 0 if undecided. + DecidedAtMs int64 `protobuf:"varint,6,opt,name=decided_at_ms,json=decidedAtMs,proto3" json:"decided_at_ms,omitempty"` + // Denormalized endpoint host for dedup and display. + Host string `protobuf:"bytes,7,opt,name=host,proto3" json:"host,omitempty"` + // Denormalized endpoint port for dedup and display. + Port int32 `protobuf:"varint,8,opt,name=port,proto3" json:"port,omitempty"` + // Binary path that triggered the denial. + Binary string `protobuf:"bytes,9,opt,name=binary,proto3" json:"binary,omitempty"` + // Current draft version for the owning sandbox. + DraftVersion int64 `protobuf:"varint,10,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + // Gateway prover verdict for this chunk; empty until prover runs. + // Mirrors PolicyChunk.validation_result. + ValidationResult string `protobuf:"bytes,11,opt,name=validation_result,json=validationResult,proto3" json:"validation_result,omitempty"` + // Operator-supplied free-form rejection text; empty for non-rejected + // chunks. Mirrors PolicyChunk.rejection_reason. + RejectionReason string `protobuf:"bytes,12,opt,name=rejection_reason,json=rejectionReason,proto3" json:"rejection_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DraftChunkPayload) Reset() { + *x = DraftChunkPayload{} + mi := &file_openshell_proto_msgTypes[155] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DraftChunkPayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DraftChunkPayload) ProtoMessage() {} + +func (x *DraftChunkPayload) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[155] + 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 DraftChunkPayload.ProtoReflect.Descriptor instead. +func (*DraftChunkPayload) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{155} +} + +func (x *DraftChunkPayload) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *DraftChunkPayload) GetProposedRule() *sandboxv1.NetworkPolicyRule { + if x != nil { + return x.ProposedRule + } + return nil +} + +func (x *DraftChunkPayload) GetRationale() string { + if x != nil { + return x.Rationale + } + return "" +} + +func (x *DraftChunkPayload) GetSecurityNotes() string { + if x != nil { + return x.SecurityNotes + } + return "" +} + +func (x *DraftChunkPayload) GetConfidence() float32 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *DraftChunkPayload) GetDecidedAtMs() int64 { + if x != nil { + return x.DecidedAtMs + } + return 0 +} + +func (x *DraftChunkPayload) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *DraftChunkPayload) GetPort() int32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *DraftChunkPayload) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *DraftChunkPayload) GetDraftVersion() int64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *DraftChunkPayload) GetValidationResult() string { + if x != nil { + return x.ValidationResult + } + return "" +} + +func (x *DraftChunkPayload) GetRejectionReason() string { + if x != nil { + return x.RejectionReason + } + return "" +} + +// Internal stored policy revision row materialized from the generic objects table. +type StoredPolicyRevision struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Version int64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + PolicyPayload []byte `protobuf:"bytes,4,opt,name=policy_payload,json=policyPayload,proto3" json:"policy_payload,omitempty"` + PolicyHash string `protobuf:"bytes,5,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + LoadError *string `protobuf:"bytes,7,opt,name=load_error,json=loadError,proto3,oneof" json:"load_error,omitempty"` + CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + LoadedAtMs *int64 `protobuf:"varint,9,opt,name=loaded_at_ms,json=loadedAtMs,proto3,oneof" json:"loaded_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredPolicyRevision) Reset() { + *x = StoredPolicyRevision{} + mi := &file_openshell_proto_msgTypes[156] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredPolicyRevision) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredPolicyRevision) ProtoMessage() {} + +func (x *StoredPolicyRevision) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[156] + 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 StoredPolicyRevision.ProtoReflect.Descriptor instead. +func (*StoredPolicyRevision) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{156} +} + +func (x *StoredPolicyRevision) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StoredPolicyRevision) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *StoredPolicyRevision) GetVersion() int64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *StoredPolicyRevision) GetPolicyPayload() []byte { + if x != nil { + return x.PolicyPayload + } + return nil +} + +func (x *StoredPolicyRevision) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *StoredPolicyRevision) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *StoredPolicyRevision) GetLoadError() string { + if x != nil && x.LoadError != nil { + return *x.LoadError + } + return "" +} + +func (x *StoredPolicyRevision) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *StoredPolicyRevision) GetLoadedAtMs() int64 { + if x != nil && x.LoadedAtMs != nil { + return *x.LoadedAtMs + } + return 0 +} + +// Internal stored draft chunk row materialized from the generic objects table. +type StoredDraftChunk struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + DraftVersion int64 `protobuf:"varint,3,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` + RuleName string `protobuf:"bytes,5,opt,name=rule_name,json=ruleName,proto3" json:"rule_name,omitempty"` + ProposedRule []byte `protobuf:"bytes,6,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` + Rationale string `protobuf:"bytes,7,opt,name=rationale,proto3" json:"rationale,omitempty"` + SecurityNotes string `protobuf:"bytes,8,opt,name=security_notes,json=securityNotes,proto3" json:"security_notes,omitempty"` + Confidence float64 `protobuf:"fixed64,9,opt,name=confidence,proto3" json:"confidence,omitempty"` + CreatedAtMs int64 `protobuf:"varint,10,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + DecidedAtMs *int64 `protobuf:"varint,11,opt,name=decided_at_ms,json=decidedAtMs,proto3,oneof" json:"decided_at_ms,omitempty"` + Host string `protobuf:"bytes,12,opt,name=host,proto3" json:"host,omitempty"` + Port int32 `protobuf:"varint,13,opt,name=port,proto3" json:"port,omitempty"` + Binary string `protobuf:"bytes,14,opt,name=binary,proto3" json:"binary,omitempty"` + HitCount int32 `protobuf:"varint,15,opt,name=hit_count,json=hitCount,proto3" json:"hit_count,omitempty"` + FirstSeenMs int64 `protobuf:"varint,16,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` + LastSeenMs int64 `protobuf:"varint,17,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Gateway prover verdict; empty until the prover runs. See PolicyChunk. + ValidationResult string `protobuf:"bytes,18,opt,name=validation_result,json=validationResult,proto3" json:"validation_result,omitempty"` + // Operator-supplied free-form rejection text. See PolicyChunk. + RejectionReason string `protobuf:"bytes,19,opt,name=rejection_reason,json=rejectionReason,proto3" json:"rejection_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StoredDraftChunk) Reset() { + *x = StoredDraftChunk{} + mi := &file_openshell_proto_msgTypes[157] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StoredDraftChunk) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StoredDraftChunk) ProtoMessage() {} + +func (x *StoredDraftChunk) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[157] + 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 StoredDraftChunk.ProtoReflect.Descriptor instead. +func (*StoredDraftChunk) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{157} +} + +func (x *StoredDraftChunk) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *StoredDraftChunk) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *StoredDraftChunk) GetDraftVersion() int64 { + if x != nil { + return x.DraftVersion + } + return 0 +} + +func (x *StoredDraftChunk) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *StoredDraftChunk) GetRuleName() string { + if x != nil { + return x.RuleName + } + return "" +} + +func (x *StoredDraftChunk) GetProposedRule() []byte { + if x != nil { + return x.ProposedRule + } + return nil +} + +func (x *StoredDraftChunk) GetRationale() string { + if x != nil { + return x.Rationale + } + return "" +} + +func (x *StoredDraftChunk) GetSecurityNotes() string { + if x != nil { + return x.SecurityNotes + } + return "" +} + +func (x *StoredDraftChunk) GetConfidence() float64 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *StoredDraftChunk) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +func (x *StoredDraftChunk) GetDecidedAtMs() int64 { + if x != nil && x.DecidedAtMs != nil { + return *x.DecidedAtMs + } + return 0 +} + +func (x *StoredDraftChunk) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *StoredDraftChunk) GetPort() int32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *StoredDraftChunk) GetBinary() string { + if x != nil { + return x.Binary + } + return "" +} + +func (x *StoredDraftChunk) GetHitCount() int32 { + if x != nil { + return x.HitCount + } + return 0 +} + +func (x *StoredDraftChunk) GetFirstSeenMs() int64 { + if x != nil { + return x.FirstSeenMs + } + return 0 +} + +func (x *StoredDraftChunk) GetLastSeenMs() int64 { + if x != nil { + return x.LastSeenMs + } + return 0 +} + +func (x *StoredDraftChunk) GetValidationResult() string { + if x != nil { + return x.ValidationResult + } + return "" +} + +func (x *StoredDraftChunk) GetRejectionReason() string { + if x != nil { + return x.RejectionReason + } + return "" +} + +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\rsandbox.proto\"\x1a\n" + + "\x18IssueSandboxTokenRequest\"U\n" + + "\x19IssueSandboxTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x1c\n" + + "\x1aRefreshSandboxTokenRequest\"W\n" + + "\x1bRefreshSandboxTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"\x0f\n" + + "\rHealthRequest\"_\n" + + "\x0eHealthResponse\x123\n" + + "\x06status\x18\x01 \x01(\x0e2\x1b.openshell.v1.ServiceStatusR\x06status\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\"\xd8\x01\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\"\xd7\x03\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" + + "\btemplate\x18\x06 \x01(\v2\x1d.openshell.v1.SandboxTemplateR\btemplate\x12;\n" + + "\x06policy\x18\a \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1c\n" + + "\tproviders\x18\b \x03(\tR\tproviders\x12W\n" + + "\x15resource_requirements\x18\t \x01(\v2\".openshell.v1.ResourceRequirementsR\x14resourceRequirements\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\n" + + "\x10\vJ\x04\b\v\x10\fR\n" + + "gpu_deviceR\x16proposal_approval_mode\"O\n" + + "\x14ResourceRequirements\x127\n" + + "\x03gpu\x18\x01 \x01(\v2%.openshell.v1.GpuResourceRequirementsR\x03gpu\">\n" + + "\x17GpuResourceRequirements\x12\x19\n" + + "\x05count\x18\x01 \x01(\rH\x00R\x05count\x88\x01\x01B\b\n" + + "\x06_count\"\xa0\x06\n" + + "\x0fSandboxTemplate\x12\x14\n" + + "\x05image\x18\x01 \x01(\tR\x05image\x12,\n" + + "\x12runtime_class_name\x18\x02 \x01(\tR\x10runtimeClassName\x12!\n" + + "\fagent_socket\x18\x03 \x01(\tR\vagentSocket\x12A\n" + + "\x06labels\x18\x04 \x03(\v2).openshell.v1.SandboxTemplate.LabelsEntryR\x06labels\x12P\n" + + "\vannotations\x18\x05 \x03(\v2..openshell.v1.SandboxTemplate.AnnotationsEntryR\vannotations\x12P\n" + + "\venvironment\x18\x06 \x03(\v2..openshell.v1.SandboxTemplate.EnvironmentEntryR\venvironment\x125\n" + + "\tresources\x18\a \x01(\v2\x17.google.protobuf.StructR\tresources\x12M\n" + + "\x16volume_claim_templates\x18\t \x01(\v2\x17.google.protobuf.StructR\x14volumeClaimTemplates\x12,\n" + + "\x0fuser_namespaces\x18\n" + + " \x01(\bH\x00R\x0euserNamespaces\x88\x01\x01\x12<\n" + + "\rdriver_config\x18\v \x01(\v2\x17.google.protobuf.StructR\fdriverConfig\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\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x12\n" + + "\x10_user_namespaces\"\xb1\x02\n" + + "\rSandboxStatus\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12\x1b\n" + + "\tagent_pod\x18\x02 \x01(\tR\bagentPod\x12\x19\n" + + "\bagent_fd\x18\x03 \x01(\tR\aagentFd\x12\x1d\n" + + "\n" + + "sandbox_fd\x18\x04 \x01(\tR\tsandboxFd\x12>\n" + + "\n" + + "conditions\x18\x05 \x03(\v2\x1e.openshell.v1.SandboxConditionR\n" + + "conditions\x120\n" + + "\x05phase\x18\x06 \x01(\x0e2\x1a.openshell.v1.SandboxPhaseR\x05phase\x124\n" + + "\x16current_policy_version\x18\a \x01(\rR\x14currentPolicyVersion\"\xa2\x01\n" + + "\x10SandboxCondition\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x120\n" + + "\x14last_transition_time\x18\x05 \x01(\tR\x12lastTransitionTime\"\x94\x02\n" + + "\rPlatformEvent\x12!\n" + + "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x16\n" + + "\x06source\x18\x02 \x01(\tR\x06source\x12\x12\n" + + "\x04type\x18\x03 \x01(\tR\x04type\x12\x16\n" + + "\x06reason\x18\x04 \x01(\tR\x06reason\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12E\n" + + "\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\"\xdc\x01\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\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + + "\x11GetSandboxRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"k\n" + + "\x14ListSandboxesRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\x12%\n" + + "\x0elabel_selector\x18\x03 \x01(\tR\rlabelSelector\"@\n" + + "\x1bListSandboxProvidersRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\"\xa2\x01\n" + + "\x1cAttachSandboxProviderRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\"\xa2\x01\n" + + "\x1cDetachSandboxProviderRequest\x12!\n" + + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\"*\n" + + "\x14DeleteSandboxRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"B\n" + + "\x0fSandboxResponse\x12/\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\"L\n" + + "\x15ListSandboxesResponse\x123\n" + + "\tsandboxes\x18\x01 \x03(\v2\x15.openshell.v1.SandboxR\tsandboxes\"^\n" + + "\x1cListSandboxProvidersResponse\x12>\n" + + "\tproviders\x18\x01 \x03(\v2 .openshell.datamodel.v1.ProviderR\tproviders\"l\n" + + "\x1dAttachSandboxProviderResponse\x12/\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\x12\x1a\n" + + "\battached\x18\x02 \x01(\bR\battached\"l\n" + + "\x1dDetachSandboxProviderResponse\x12/\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\x12\x1a\n" + + "\bdetached\x18\x02 \x01(\bR\bdetached\"1\n" + + "\x15DeleteSandboxResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"8\n" + + "\x17CreateSshSessionRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x92\x02\n" + + "\x18CreateSshSessionResponse\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05token\x18\x02 \x01(\tR\x05token\x12!\n" + + "\fgateway_host\x18\x03 \x01(\tR\vgatewayHost\x12!\n" + + "\fgateway_port\x18\x04 \x01(\rR\vgatewayPort\x12%\n" + + "\x0egateway_scheme\x18\x05 \x01(\tR\rgatewayScheme\x120\n" + + "\x14host_key_fingerprint\x18\a \x01(\tR\x12hostKeyFingerprint\x12\"\n" + + "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\"\x83\x01\n" + + "\x14ExposeServiceRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\x12\x1f\n" + + "\vtarget_port\x18\x03 \x01(\rR\n" + + "targetPort\x12\x16\n" + + "\x06domain\x18\x04 \x01(\bR\x06domain\"G\n" + + "\x11GetServiceRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\"]\n" + + "\x13ListServicesRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x03 \x01(\rR\x06offset\"Y\n" + + "\x14ListServicesResponse\x12A\n" + + "\bservices\x18\x01 \x03(\v2%.openshell.v1.ServiceEndpointResponseR\bservices\"J\n" + + "\x14DeleteServiceRequest\x12\x18\n" + + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + + "\aservice\x18\x02 \x01(\tR\aservice\"1\n" + + "\x15DeleteServiceResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\xef\x01\n" + + "\x0fServiceEndpoint\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12!\n" + + "\fsandbox_name\x18\x03 \x01(\tR\vsandboxName\x12!\n" + + "\fservice_name\x18\x04 \x01(\tR\vserviceName\x12\x1f\n" + + "\vtarget_port\x18\x05 \x01(\rR\n" + + "targetPort\x12\x16\n" + + "\x06domain\x18\x06 \x01(\bR\x06domain\"f\n" + + "\x17ServiceEndpointResponse\x129\n" + + "\bendpoint\x18\x01 \x01(\v2\x1d.openshell.v1.ServiceEndpointR\bendpoint\x12\x10\n" + + "\x03url\x18\x02 \x01(\tR\x03url\"/\n" + + "\x17RevokeSshSessionRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"4\n" + + "\x18RevokeSshSessionResponse\x12\x18\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\xf5\x02\n" + + "\x12ExecSandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + + "\acommand\x18\x02 \x03(\tR\acommand\x12\x18\n" + + "\aworkdir\x18\x03 \x01(\tR\aworkdir\x12S\n" + + "\venvironment\x18\x04 \x03(\v21.openshell.v1.ExecSandboxRequest.EnvironmentEntryR\venvironment\x12'\n" + + "\x0ftimeout_seconds\x18\x05 \x01(\rR\x0etimeoutSeconds\x12\x14\n" + + "\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" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + + "\x11ExecSandboxStdout\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\"'\n" + + "\x11ExecSandboxStderr\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\".\n" + + "\x0fExecSandboxExit\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\"\xc8\x01\n" + + "\x10ExecSandboxEvent\x129\n" + + "\x06stdout\x18\x01 \x01(\v2\x1f.openshell.v1.ExecSandboxStdoutH\x00R\x06stdout\x129\n" + + "\x06stderr\x18\x02 \x01(\v2\x1f.openshell.v1.ExecSandboxStderrH\x00R\x06stderr\x123\n" + + "\x04exit\x18\x03 \x01(\v2\x1d.openshell.v1.ExecSandboxExitH\x00R\x04exitB\t\n" + + "\apayload\"\xed\x01\n" + + "\x0eTcpForwardInit\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1d\n" + + "\n" + + "service_id\x18\x04 \x01(\tR\tserviceId\x120\n" + + "\x03ssh\x18\x05 \x01(\v2\x1c.openshell.v1.SshRelayTargetH\x00R\x03ssh\x120\n" + + "\x03tcp\x18\x06 \x01(\v2\x1c.openshell.v1.TcpRelayTargetH\x00R\x03tcp\x12/\n" + + "\x13authorization_token\x18\a \x01(\tR\x12authorizationTokenB\b\n" + + "\x06target\"f\n" + + "\x0fTcpForwardFrame\x122\n" + + "\x04init\x18\x01 \x01(\v2\x1c.openshell.v1.TcpForwardInitH\x00R\x04init\x12\x14\n" + + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + + "\apayload\"\xb0\x01\n" + + "\x10ExecSandboxInput\x128\n" + + "\x05start\x18\x01 \x01(\v2 .openshell.v1.ExecSandboxRequestH\x00R\x05start\x12\x16\n" + + "\x05stdin\x18\x02 \x01(\fH\x00R\x05stdin\x12?\n" + + "\x06resize\x18\x03 \x01(\v2%.openshell.v1.ExecSandboxWindowResizeH\x00R\x06resizeB\t\n" + + "\apayload\"A\n" + + "\x17ExecSandboxWindowResize\x12\x12\n" + + "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + + "\x04rows\x18\x02 \x01(\rR\x04rows\"\xbf\x01\n" + + "\n" + + "SshSession\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05token\x18\x03 \x01(\tR\x05token\x12\"\n" + + "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\x12\x18\n" + + "\arevoked\x18\x05 \x01(\bR\arevoked\"\xe6\x02\n" + + "\x13WatchSandboxRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12#\n" + + "\rfollow_status\x18\x02 \x01(\bR\ffollowStatus\x12\x1f\n" + + "\vfollow_logs\x18\x03 \x01(\bR\n" + + "followLogs\x12#\n" + + "\rfollow_events\x18\x04 \x01(\bR\ffollowEvents\x12$\n" + + "\x0elog_tail_lines\x18\x05 \x01(\rR\flogTailLines\x12\x1d\n" + + "\n" + + "event_tail\x18\x06 \x01(\rR\teventTail\x12(\n" + + "\x10stop_on_terminal\x18\a \x01(\bR\x0estopOnTerminal\x12 \n" + + "\flog_since_ms\x18\b \x01(\x03R\n" + + "logSinceMs\x12\x1f\n" + + "\vlog_sources\x18\t \x03(\tR\n" + + "logSources\x12\"\n" + + "\rlog_min_level\x18\n" + + " \x01(\tR\vlogMinLevel\"\xcc\x02\n" + + "\x12SandboxStreamEvent\x121\n" + + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxH\x00R\asandbox\x120\n" + + "\x03log\x18\x02 \x01(\v2\x1c.openshell.v1.SandboxLogLineH\x00R\x03log\x123\n" + + "\x05event\x18\x03 \x01(\v2\x1b.openshell.v1.PlatformEventH\x00R\x05event\x12>\n" + + "\awarning\x18\x04 \x01(\v2\".openshell.v1.SandboxStreamWarningH\x00R\awarning\x12Q\n" + + "\x13draft_policy_update\x18\x05 \x01(\v2\x1f.openshell.v1.DraftPolicyUpdateH\x00R\x11draftPolicyUpdateB\t\n" + + "\apayload\"\xaf\x02\n" + + "\x0eSandboxLogLine\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12!\n" + + "\ftimestamp_ms\x18\x02 \x01(\x03R\vtimestampMs\x12\x14\n" + + "\x05level\x18\x03 \x01(\tR\x05level\x12\x16\n" + + "\x06target\x18\x04 \x01(\tR\x06target\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\x12@\n" + + "\x06fields\x18\a \x03(\v2(.openshell.v1.SandboxLogLine.FieldsEntryR\x06fields\x1a9\n" + + "\vFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"0\n" + + "\x14SandboxStreamWarning\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"U\n" + + "\x15CreateProviderRequest\x12<\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"(\n" + + "\x12GetProviderRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"D\n" + + "\x14ListProvidersRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\"\x98\x02\n" + + "\x15UpdateProviderRequest\x12<\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12w\n" + + "\x18credential_expires_at_ms\x18\x02 \x03(\v2>.openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x1aH\n" + + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"+\n" + + "\x15DeleteProviderRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"P\n" + + "\x10ProviderResponse\x12<\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"W\n" + + "\x15ListProvidersResponse\x12>\n" + + "\tproviders\x18\x01 \x03(\v2 .openshell.datamodel.v1.ProviderR\tproviders\"K\n" + + "\x1bListProviderProfilesRequest\x12\x14\n" + + "\x05limit\x18\x01 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x02 \x01(\rR\x06offset\"+\n" + + "\x19GetProviderProfileRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"l\n" + + "\x19ProviderProfileImportItem\x127\n" + + "\aprofile\x18\x01 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\x12\x16\n" + + "\x06source\x18\x02 \x01(\tR\x06source\"\x9e\x01\n" + + "\x19ProviderProfileDiagnostic\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12\x1d\n" + + "\n" + + "profile_id\x18\x02 \x01(\tR\tprofileId\x12\x14\n" + + "\x05field\x18\x03 \x01(\tR\x05field\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x1a\n" + + "\bseverity\x18\x05 \x01(\tR\bseverity\"\x9e\x01\n" + + ",ProviderCredentialTokenGrantAudienceOverride\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\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" + + "\x1cProviderCredentialTokenGrant\x12%\n" + + "\x0etoken_endpoint\x18\x01 \x01(\tR\rtokenEndpoint\x12\x1a\n" + + "\baudience\x18\x02 \x01(\tR\baudience\x12*\n" + + "\x11jwt_svid_audience\x18\x06 \x01(\tR\x0fjwtSvidAudience\x12\x16\n" + + "\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" + + "\x19ProviderProfileCredential\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + + "\benv_vars\x18\x03 \x03(\tR\aenvVars\x12\x1a\n" + + "\brequired\x18\x04 \x01(\bR\brequired\x12\x1d\n" + + "\n" + + "auth_style\x18\x05 \x01(\tR\tauthStyle\x12\x1f\n" + + "\vheader_name\x18\x06 \x01(\tR\n" + + "headerName\x12\x1f\n" + + "\vquery_param\x18\a \x01(\tR\n" + + "queryParam\x12A\n" + + "\arefresh\x18\b \x01(\v2'.openshell.v1.ProviderCredentialRefreshR\arefresh\x12#\n" + + "\rpath_template\x18\t \x01(\tR\fpathTemplate\x12K\n" + + "\vtoken_grant\x18\n" + + " \x01(\v2*.openshell.v1.ProviderCredentialTokenGrantR\n" + + "tokenGrant\"\x8d\x01\n" + + "!ProviderCredentialRefreshMaterial\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" + + "\brequired\x18\x03 \x01(\bR\brequired\x12\x16\n" + + "\x06secret\x18\x04 \x01(\bR\x06secret\"\xd2\x02\n" + + "\x19ProviderCredentialRefresh\x12K\n" + + "\bstrategy\x18\x01 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\x1b\n" + + "\ttoken_url\x18\x02 \x01(\tR\btokenUrl\x12\x16\n" + + "\x06scopes\x18\x03 \x03(\tR\x06scopes\x124\n" + + "\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\"\x90\x03\n" + + "\x1fProviderCredentialRefreshStatus\x12#\n" + + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x1f\n" + + "\vprovider_id\x18\x02 \x01(\tR\n" + + "providerId\x12%\n" + + "\x0ecredential_key\x18\x03 \x01(\tR\rcredentialKey\x12K\n" + + "\bstrategy\x18\x04 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\x16\n" + + "\x06status\x18\x05 \x01(\tR\x06status\x12\"\n" + + "\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\x12+\n" + + "\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" + + "\x18ProviderProfileDiscovery\x12 \n" + + "\vcredentials\x18\x01 \x03(\tR\vcredentials\"\xbf\x06\n" + + "$StoredProviderCredentialRefreshState\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1f\n" + + "\vprovider_id\x18\x02 \x01(\tR\n" + + "providerId\x12#\n" + + "\rprovider_name\x18\x03 \x01(\tR\fproviderName\x12%\n" + + "\x0ecredential_key\x18\x04 \x01(\tR\rcredentialKey\x12K\n" + + "\bstrategy\x18\x05 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\\\n" + + "\bmaterial\x18\x06 \x03(\v2@.openshell.v1.StoredProviderCredentialRefreshState.MaterialEntryR\bmaterial\x120\n" + + "\x14secret_material_keys\x18\a \x03(\tR\x12secretMaterialKeys\x12\"\n" + + "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\x12+\n" + + "\x12next_refresh_at_ms\x18\t \x01(\x03R\x0fnextRefreshAtMs\x12+\n" + + "\x12last_refresh_at_ms\x18\n" + + " \x01(\x03R\x0flastRefreshAtMs\x12\x16\n" + + "\x06status\x18\v \x01(\tR\x06status\x12\x1d\n" + + "\n" + + "last_error\x18\f \x01(\tR\tlastError\x12\x1b\n" + + "\ttoken_url\x18\r \x01(\tR\btokenUrl\x12\x16\n" + + "\x06scopes\x18\x0e \x03(\tR\x06scopes\x124\n" + + "\x16refresh_before_seconds\x18\x0f \x01(\x03R\x14refreshBeforeSeconds\x120\n" + + "\x14max_lifetime_seconds\x18\x10 \x01(\x03R\x12maxLifetimeSeconds\x1a;\n" + + "\rMaterialEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"d\n" + + "\x1fGetProviderRefreshStatusRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\"s\n" + + " GetProviderRefreshStatusResponse\x12O\n" + + "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\xb4\x03\n" + + "\x1fConfigureProviderRefreshRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12K\n" + + "\bstrategy\x18\x03 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12W\n" + + "\bmaterial\x18\x04 \x03(\v2;.openshell.v1.ConfigureProviderRefreshRequest.MaterialEntryR\bmaterial\x120\n" + + "\x14secret_material_keys\x18\x05 \x03(\tR\x12secretMaterialKeys\x12'\n" + + "\rexpires_at_ms\x18\x06 \x01(\x03H\x00R\vexpiresAtMs\x88\x01\x01\x1a;\n" + + "\rMaterialEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x10\n" + + "\x0e_expires_at_ms\"i\n" + + " ConfigureProviderRefreshResponse\x12E\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"d\n" + + "\x1fRotateProviderCredentialRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\"i\n" + + " RotateProviderCredentialResponse\x12E\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"a\n" + + "\x1cDeleteProviderRefreshRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\"9\n" + + "\x1dDeleteProviderRefreshResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\x98\x04\n" + + "\x0fProviderProfile\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12A\n" + + "\bcategory\x18\x04 \x01(\x0e2%.openshell.v1.ProviderProfileCategoryR\bcategory\x12I\n" + + "\vcredentials\x18\x05 \x03(\v2'.openshell.v1.ProviderProfileCredentialR\vcredentials\x12C\n" + + "\tendpoints\x18\x06 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + + "\bbinaries\x18\a \x03(\v2#.openshell.sandbox.v1.NetworkBinaryR\bbinaries\x12+\n" + + "\x11inference_capable\x18\b \x01(\bR\x10inferenceCapable\x12D\n" + + "\tdiscovery\x18\t \x01(\v2&.openshell.v1.ProviderProfileDiscoveryR\tdiscovery\x12)\n" + + "\x10resource_version\x18\n" + + " \x01(\x04R\x0fresourceVersion\"\x90\x01\n" + + "\x15StoredProviderProfile\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x127\n" + + "\aprofile\x18\x02 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\"R\n" + + "\x17ProviderProfileResponse\x127\n" + + "\aprofile\x18\x01 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\"Y\n" + + "\x1cListProviderProfilesResponse\x129\n" + + "\bprofiles\x18\x01 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\"d\n" + + "\x1dImportProviderProfilesRequest\x12C\n" + + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\"\xc2\x01\n" + + "\x1eImportProviderProfilesResponse\x12I\n" + + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x129\n" + + "\bprofiles\x18\x02 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\x12\x1a\n" + + "\bimported\x18\x03 \x01(\bR\bimported\"\xae\x01\n" + + "\x1dUpdateProviderProfilesRequest\x12A\n" + + "\aprofile\x18\x01 \x01(\v2'.openshell.v1.ProviderProfileImportItemR\aprofile\x12:\n" + + "\x19expected_resource_version\x18\x02 \x01(\x04R\x17expectedResourceVersion\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"\xbe\x01\n" + + "\x1eUpdateProviderProfilesResponse\x12I\n" + + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x127\n" + + "\aprofile\x18\x02 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\x12\x18\n" + + "\aupdated\x18\x03 \x01(\bR\aupdated\"b\n" + + "\x1bLintProviderProfilesRequest\x12C\n" + + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\"\x7f\n" + + "\x1cLintProviderProfilesResponse\x12I\n" + + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x12\x14\n" + + "\x05valid\x18\x02 \x01(\bR\x05valid\"2\n" + + "\x16DeleteProviderResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\".\n" + + "\x1cDeleteProviderProfileRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"9\n" + + "\x1dDeleteProviderProfileResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"E\n" + + "$GetSandboxProviderEnvironmentRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\xc5\x05\n" + + "%GetSandboxProviderEnvironmentResponse\x12f\n" + + "\venvironment\x18\x01 \x03(\v2D.openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntryR\venvironment\x122\n" + + "\x15provider_env_revision\x18\x02 \x01(\x04R\x13providerEnvRevision\x12\x87\x01\n" + + "\x18credential_expires_at_ms\x18\x03 \x03(\v2N.openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12|\n" + + "\x13dynamic_credentials\x18\x04 \x03(\v2K.openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntryR\x12dynamicCredentials\x1a>\n" + + "\x10EnvironmentEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\x1an\n" + + "\x17DynamicCredentialsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + + "\x05value\x18\x02 \x01(\v2'.openshell.v1.ProviderProfileCredentialR\x05value:\x028\x01\"\x9a\x03\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" + + "\vsetting_key\x18\x03 \x01(\tR\n" + + "settingKey\x12G\n" + + "\rsetting_value\x18\x04 \x01(\v2\".openshell.sandbox.v1.SettingValueR\fsettingValue\x12%\n" + + "\x0edelete_setting\x18\x05 \x01(\bR\rdeleteSetting\x12\x16\n" + + "\x06global\x18\x06 \x01(\bR\x06global\x12M\n" + + "\x10merge_operations\x18\a \x03(\v2\".openshell.v1.PolicyMergeOperationR\x0fmergeOperations\x12:\n" + + "\x19expected_resource_version\x18\b \x01(\x04R\x17expectedResourceVersion\"\xc7\x03\n" + + "\x14PolicyMergeOperation\x129\n" + + "\badd_rule\x18\x01 \x01(\v2\x1c.openshell.v1.AddNetworkRuleH\x00R\aaddRule\x12N\n" + + "\x0fremove_endpoint\x18\x02 \x01(\v2#.openshell.v1.RemoveNetworkEndpointH\x00R\x0eremoveEndpoint\x12B\n" + + "\vremove_rule\x18\x03 \x01(\v2\x1f.openshell.v1.RemoveNetworkRuleH\x00R\n" + + "removeRule\x12B\n" + + "\x0eadd_deny_rules\x18\x04 \x01(\v2\x1a.openshell.v1.AddDenyRulesH\x00R\faddDenyRules\x12E\n" + + "\x0fadd_allow_rules\x18\x05 \x01(\v2\x1b.openshell.v1.AddAllowRulesH\x00R\raddAllowRules\x12H\n" + + "\rremove_binary\x18\x06 \x01(\v2!.openshell.v1.RemoveNetworkBinaryH\x00R\fremoveBinaryB\v\n" + + "\toperation\"j\n" + + "\x0eAddNetworkRule\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12;\n" + + "\x04rule\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x04rule\"\\\n" + + "\x15RemoveNetworkEndpoint\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x12\n" + + "\x04host\x18\x02 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x03 \x01(\rR\x04port\"0\n" + + "\x11RemoveNetworkRule\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\"w\n" + + "\fAddDenyRules\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12?\n" + + "\n" + + "deny_rules\x18\x03 \x03(\v2 .openshell.sandbox.v1.L7DenyRuleR\tdenyRules\"k\n" + + "\rAddAllowRules\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x122\n" + + "\x05rules\x18\x03 \x03(\v2\x1c.openshell.sandbox.v1.L7RuleR\x05rules\"S\n" + + "\x13RemoveNetworkBinary\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12\x1f\n" + + "\vbinary_path\x18\x02 \x01(\tR\n" + + "binaryPath\"\x98\x01\n" + + "\x14UpdateConfigResponse\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x12+\n" + + "\x11settings_revision\x18\x03 \x01(\x04R\x10settingsRevision\x12\x18\n" + + "\adeleted\x18\x04 \x01(\bR\adeleted\"e\n" + + "\x1dGetSandboxPolicyStatusRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x12\x16\n" + + "\x06global\x18\x03 \x01(\bR\x06global\"\x88\x01\n" + + "\x1eGetSandboxPolicyStatusResponse\x12?\n" + + "\brevision\x18\x01 \x01(\v2#.openshell.v1.SandboxPolicyRevisionR\brevision\x12%\n" + + "\x0eactive_version\x18\x02 \x01(\rR\ractiveVersion\"v\n" + + "\x1aListSandboxPoliciesRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05limit\x18\x02 \x01(\rR\x05limit\x12\x16\n" + + "\x06offset\x18\x03 \x01(\rR\x06offset\x12\x16\n" + + "\x06global\x18\x04 \x01(\bR\x06global\"`\n" + + "\x1bListSandboxPoliciesResponse\x12A\n" + + "\trevisions\x18\x01 \x03(\v2#.openshell.v1.SandboxPolicyRevisionR\trevisions\"\xa7\x01\n" + + "\x19ReportPolicyStatusRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x122\n" + + "\x06status\x18\x03 \x01(\x0e2\x1a.openshell.v1.PolicyStatusR\x06status\x12\x1d\n" + + "\n" + + "load_error\x18\x04 \x01(\tR\tloadError\"\x1c\n" + + "\x1aReportPolicyStatusResponse\"\xa8\x02\n" + + "\x15SandboxPolicyRevision\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x122\n" + + "\x06status\x18\x03 \x01(\x0e2\x1a.openshell.v1.PolicyStatusR\x06status\x12\x1d\n" + + "\n" + + "load_error\x18\x04 \x01(\tR\tloadError\x12\"\n" + + "\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12 \n" + + "\floaded_at_ms\x18\x06 \x01(\x03R\n" + + "loadedAtMs\x12;\n" + + "\x06policy\x18\a \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\"\x9e\x01\n" + + "\x15GetSandboxLogsRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" + + "\x05lines\x18\x02 \x01(\rR\x05lines\x12\x19\n" + + "\bsince_ms\x18\x03 \x01(\x03R\asinceMs\x12\x18\n" + + "\asources\x18\x04 \x03(\tR\asources\x12\x1b\n" + + "\tmin_level\x18\x05 \x01(\tR\bminLevel\"i\n" + + "\x16PushSandboxLogsRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x120\n" + + "\x04logs\x18\x02 \x03(\v2\x1c.openshell.v1.SandboxLogLineR\x04logs\"\x19\n" + + "\x17PushSandboxLogsResponse\"m\n" + + "\x16GetSandboxLogsResponse\x120\n" + + "\x04logs\x18\x01 \x03(\v2\x1c.openshell.v1.SandboxLogLineR\x04logs\x12!\n" + + "\fbuffer_total\x18\x02 \x01(\rR\vbufferTotal\"\xa2\x02\n" + + "\x11SupervisorMessage\x125\n" + + "\x05hello\x18\x01 \x01(\v2\x1d.openshell.v1.SupervisorHelloH\x00R\x05hello\x12A\n" + + "\theartbeat\x18\x02 \x01(\v2!.openshell.v1.SupervisorHeartbeatH\x00R\theartbeat\x12K\n" + + "\x11relay_open_result\x18\x03 \x01(\v2\x1d.openshell.v1.RelayOpenResultH\x00R\x0frelayOpenResult\x12;\n" + + "\vrelay_close\x18\x04 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + + "relayCloseB\t\n" + + "\apayload\"\xea\x02\n" + + "\x0eGatewayMessage\x12J\n" + + "\x10session_accepted\x18\x01 \x01(\v2\x1d.openshell.v1.SessionAcceptedH\x00R\x0fsessionAccepted\x12J\n" + + "\x10session_rejected\x18\x02 \x01(\v2\x1d.openshell.v1.SessionRejectedH\x00R\x0fsessionRejected\x12>\n" + + "\theartbeat\x18\x03 \x01(\v2\x1e.openshell.v1.GatewayHeartbeatH\x00R\theartbeat\x128\n" + + "\n" + + "relay_open\x18\x04 \x01(\v2\x17.openshell.v1.RelayOpenH\x00R\trelayOpen\x12;\n" + + "\vrelay_close\x18\x05 \x01(\v2\x18.openshell.v1.RelayCloseH\x00R\n" + + "relayCloseB\t\n" + + "\apayload\"Q\n" + + "\x0fSupervisorHello\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\"h\n" + + "\x0fSessionAccepted\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x126\n" + + "\x17heartbeat_interval_secs\x18\x02 \x01(\rR\x15heartbeatIntervalSecs\")\n" + + "\x0fSessionRejected\x12\x16\n" + + "\x06reason\x18\x01 \x01(\tR\x06reason\"\x15\n" + + "\x13SupervisorHeartbeat\"\x12\n" + + "\x10GatewayHeartbeat\"\xb7\x01\n" + + "\tRelayOpen\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\x120\n" + + "\x03ssh\x18\x02 \x01(\v2\x1c.openshell.v1.SshRelayTargetH\x00R\x03ssh\x120\n" + + "\x03tcp\x18\x03 \x01(\v2\x1c.openshell.v1.TcpRelayTargetH\x00R\x03tcp\x12\x1d\n" + + "\n" + + "service_id\x18\x05 \x01(\tR\tserviceIdB\b\n" + + "\x06target\"\x10\n" + + "\x0eSshRelayTarget\"8\n" + + "\x0eTcpRelayTarget\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\"*\n" + + "\tRelayInit\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\"\\\n" + + "\n" + + "RelayFrame\x12-\n" + + "\x04init\x18\x01 \x01(\v2\x17.openshell.v1.RelayInitH\x00R\x04init\x12\x14\n" + + "\x04data\x18\x02 \x01(\fH\x00R\x04dataB\t\n" + + "\apayload\"`\n" + + "\x0fRelayOpenResult\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"C\n" + + "\n" + + "RelayClose\x12\x1d\n" + + "\n" + + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"o\n" + + "\x0fL7RequestSample\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1a\n" + + "\bdecision\x18\x03 \x01(\tR\bdecision\x12\x14\n" + + "\x05count\x18\x04 \x01(\rR\x05count\"\xe5\x04\n" + + "\rDenialSummary\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04host\x18\x02 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x03 \x01(\rR\x04port\x12\x16\n" + + "\x06binary\x18\x04 \x01(\tR\x06binary\x12\x1c\n" + + "\tancestors\x18\x05 \x03(\tR\tancestors\x12\x1f\n" + + "\vdeny_reason\x18\x06 \x01(\tR\n" + + "denyReason\x12\"\n" + + "\rfirst_seen_ms\x18\a \x01(\x03R\vfirstSeenMs\x12 \n" + + "\flast_seen_ms\x18\b \x01(\x03R\n" + + "lastSeenMs\x12\x14\n" + + "\x05count\x18\t \x01(\rR\x05count\x12)\n" + + "\x10suppressed_count\x18\n" + + " \x01(\rR\x0fsuppressedCount\x12\x1f\n" + + "\vtotal_count\x18\v \x01(\rR\n" + + "totalCount\x12'\n" + + "\x0fsample_cmdlines\x18\f \x03(\tR\x0esampleCmdlines\x12#\n" + + "\rbinary_sha256\x18\r \x01(\tR\fbinarySha256\x12\x1e\n" + + "\n" + + "persistent\x18\x0e \x01(\bR\n" + + "persistent\x12!\n" + + "\fdenial_stage\x18\x0f \x01(\tR\vdenialStage\x12K\n" + + "\x12l7_request_samples\x18\x10 \x03(\v2\x1d.openshell.v1.L7RequestSampleR\x10l7RequestSamples\x120\n" + + "\x14l7_inspection_active\x18\x11 \x01(\bR\x12l7InspectionActive\"T\n" + + "\x10DenialGroupCount\x12\x1d\n" + + "\n" + + "deny_group\x18\x01 \x01(\tR\tdenyGroup\x12!\n" + + "\fdenied_count\x18\x02 \x01(\rR\vdeniedCount\"\xc8\x01\n" + + "\x16NetworkActivitySummary\x124\n" + + "\x16network_activity_count\x18\x01 \x01(\rR\x14networkActivityCount\x12.\n" + + "\x13denied_action_count\x18\x02 \x01(\rR\x11deniedActionCount\x12H\n" + + "\x10denials_by_group\x18\x03 \x03(\v2\x1e.openshell.v1.DenialGroupCountR\x0edenialsByGroup\"\x94\x05\n" + + "\vPolicyChunk\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" + + "\trule_name\x18\x03 \x01(\tR\bruleName\x12L\n" + + "\rproposed_rule\x18\x04 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + + "\trationale\x18\x05 \x01(\tR\trationale\x12%\n" + + "\x0esecurity_notes\x18\x06 \x01(\tR\rsecurityNotes\x12\x1e\n" + + "\n" + + "confidence\x18\a \x01(\x02R\n" + + "confidence\x12,\n" + + "\x12denial_summary_ids\x18\b \x03(\tR\x10denialSummaryIds\x12\"\n" + + "\rcreated_at_ms\x18\t \x01(\x03R\vcreatedAtMs\x12\"\n" + + "\rdecided_at_ms\x18\n" + + " \x01(\x03R\vdecidedAtMs\x12\x14\n" + + "\x05stage\x18\v \x01(\tR\x05stage\x12.\n" + + "\x13supersedes_chunk_id\x18\f \x01(\tR\x11supersedesChunkId\x12\x1b\n" + + "\thit_count\x18\r \x01(\x05R\bhitCount\x12\"\n" + + "\rfirst_seen_ms\x18\x0e \x01(\x03R\vfirstSeenMs\x12 \n" + + "\flast_seen_ms\x18\x0f \x01(\x03R\n" + + "lastSeenMs\x12\x16\n" + + "\x06binary\x18\x10 \x01(\tR\x06binary\x12+\n" + + "\x11validation_result\x18\x11 \x01(\tR\x10validationResult\x12)\n" + + "\x10rejection_reason\x18\x12 \x01(\tR\x0frejectionReason\"\x96\x01\n" + + "\x11DraftPolicyUpdate\x12#\n" + + "\rdraft_version\x18\x01 \x01(\x04R\fdraftVersion\x12\x1d\n" + + "\n" + + "new_chunks\x18\x02 \x01(\rR\tnewChunks\x12#\n" + + "\rtotal_pending\x18\x03 \x01(\rR\ftotalPending\x12\x18\n" + + "\asummary\x18\x04 \x01(\tR\asummary\"\xb9\x02\n" + + "\x1bSubmitPolicyAnalysisRequest\x129\n" + + "\tsummaries\x18\x01 \x03(\v2\x1b.openshell.v1.DenialSummaryR\tsummaries\x12B\n" + + "\x0fproposed_chunks\x18\x02 \x03(\v2\x19.openshell.v1.PolicyChunkR\x0eproposedChunks\x12#\n" + + "\ranalysis_mode\x18\x03 \x01(\tR\fanalysisMode\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12b\n" + + "\x1anetwork_activity_summaries\x18\x05 \x03(\v2$.openshell.v1.NetworkActivitySummaryR\x18networkActivitySummaries\"\xcb\x01\n" + + "\x1cSubmitPolicyAnalysisResponse\x12'\n" + + "\x0faccepted_chunks\x18\x01 \x01(\rR\x0eacceptedChunks\x12'\n" + + "\x0frejected_chunks\x18\x02 \x01(\rR\x0erejectedChunks\x12+\n" + + "\x11rejection_reasons\x18\x03 \x03(\tR\x10rejectionReasons\x12,\n" + + "\x12accepted_chunk_ids\x18\x04 \x03(\tR\x10acceptedChunkIds\"P\n" + + "\x15GetDraftPolicyRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + + "\rstatus_filter\x18\x02 \x01(\tR\fstatusFilter\"\xc8\x01\n" + + "\x16GetDraftPolicyResponse\x121\n" + + "\x06chunks\x18\x01 \x03(\v2\x19.openshell.v1.PolicyChunkR\x06chunks\x12'\n" + + "\x0frolling_summary\x18\x02 \x01(\tR\x0erollingSummary\x12#\n" + + "\rdraft_version\x18\x03 \x01(\x04R\fdraftVersion\x12-\n" + + "\x13last_analyzed_at_ms\x18\x04 \x01(\x03R\x10lastAnalyzedAtMs\"I\n" + + "\x18ApproveDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\"c\n" + + "\x19ApproveDraftChunkResponse\x12%\n" + + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\"`\n" + + "\x17RejectDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\"\x1a\n" + + "\x18RejectDraftChunkResponse\"l\n" + + "\x1cApproveAllDraftChunksRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + + "\x18include_security_flagged\x18\x02 \x01(\bR\x16includeSecurityFlagged\"\xb7\x01\n" + + "\x1dApproveAllDraftChunksResponse\x12%\n" + + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\x12'\n" + + "\x0fchunks_approved\x18\x03 \x01(\rR\x0echunksApproved\x12%\n" + + "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x94\x01\n" + + "\x15EditDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12L\n" + + "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\"\x18\n" + + "\x16EditDraftChunkResponse\"F\n" + + "\x15UndoDraftChunkRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bchunk_id\x18\x02 \x01(\tR\achunkId\"`\n" + + "\x16UndoDraftChunkResponse\x12%\n" + + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + + "\vpolicy_hash\x18\x02 \x01(\tR\n" + + "policyHash\"-\n" + + "\x17ClearDraftChunksRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"A\n" + + "\x18ClearDraftChunksResponse\x12%\n" + + "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\",\n" + + "\x16GetDraftHistoryRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x92\x01\n" + + "\x11DraftHistoryEntry\x12!\n" + + "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x1d\n" + + "\n" + + "event_type\x18\x02 \x01(\tR\teventType\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x19\n" + + "\bchunk_id\x18\x04 \x01(\tR\achunkId\"T\n" + + "\x17GetDraftHistoryResponse\x129\n" + + "\aentries\x18\x01 \x03(\v2\x1f.openshell.v1.DraftHistoryEntryR\aentries\"\xa9\x01\n" + + "\x15PolicyRevisionPayload\x12;\n" + + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x12\n" + + "\x04hash\x18\x02 \x01(\tR\x04hash\x12\x1d\n" + + "\n" + + "load_error\x18\x03 \x01(\tR\tloadError\x12 \n" + + "\floaded_at_ms\x18\x04 \x01(\x03R\n" + + "loadedAtMs\"\xc4\x03\n" + + "\x11DraftChunkPayload\x12\x1b\n" + + "\trule_name\x18\x01 \x01(\tR\bruleName\x12L\n" + + "\rproposed_rule\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12\x1c\n" + + "\trationale\x18\x03 \x01(\tR\trationale\x12%\n" + + "\x0esecurity_notes\x18\x04 \x01(\tR\rsecurityNotes\x12\x1e\n" + + "\n" + + "confidence\x18\x05 \x01(\x02R\n" + + "confidence\x12\"\n" + + "\rdecided_at_ms\x18\x06 \x01(\x03R\vdecidedAtMs\x12\x12\n" + + "\x04host\x18\a \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\b \x01(\x05R\x04port\x12\x16\n" + + "\x06binary\x18\t \x01(\tR\x06binary\x12#\n" + + "\rdraft_version\x18\n" + + " \x01(\x03R\fdraftVersion\x12+\n" + + "\x11validation_result\x18\v \x01(\tR\x10validationResult\x12)\n" + + "\x10rejection_reason\x18\f \x01(\tR\x0frejectionReason\"\xce\x02\n" + + "\x14StoredPolicyRevision\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12\x18\n" + + "\aversion\x18\x03 \x01(\x03R\aversion\x12%\n" + + "\x0epolicy_payload\x18\x04 \x01(\fR\rpolicyPayload\x12\x1f\n" + + "\vpolicy_hash\x18\x05 \x01(\tR\n" + + "policyHash\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12\"\n" + + "\n" + + "load_error\x18\a \x01(\tH\x00R\tloadError\x88\x01\x01\x12\"\n" + + "\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12%\n" + + "\floaded_at_ms\x18\t \x01(\x03H\x01R\n" + + "loadedAtMs\x88\x01\x01B\r\n" + + "\v_load_errorB\x0f\n" + + "\r_loaded_at_ms\"\xff\x04\n" + + "\x10StoredDraftChunk\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12#\n" + + "\rdraft_version\x18\x03 \x01(\x03R\fdraftVersion\x12\x16\n" + + "\x06status\x18\x04 \x01(\tR\x06status\x12\x1b\n" + + "\trule_name\x18\x05 \x01(\tR\bruleName\x12#\n" + + "\rproposed_rule\x18\x06 \x01(\fR\fproposedRule\x12\x1c\n" + + "\trationale\x18\a \x01(\tR\trationale\x12%\n" + + "\x0esecurity_notes\x18\b \x01(\tR\rsecurityNotes\x12\x1e\n" + + "\n" + + "confidence\x18\t \x01(\x01R\n" + + "confidence\x12\"\n" + + "\rcreated_at_ms\x18\n" + + " \x01(\x03R\vcreatedAtMs\x12'\n" + + "\rdecided_at_ms\x18\v \x01(\x03H\x00R\vdecidedAtMs\x88\x01\x01\x12\x12\n" + + "\x04host\x18\f \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\r \x01(\x05R\x04port\x12\x16\n" + + "\x06binary\x18\x0e \x01(\tR\x06binary\x12\x1b\n" + + "\thit_count\x18\x0f \x01(\x05R\bhitCount\x12\"\n" + + "\rfirst_seen_ms\x18\x10 \x01(\x03R\vfirstSeenMs\x12 \n" + + "\flast_seen_ms\x18\x11 \x01(\x03R\n" + + "lastSeenMs\x12+\n" + + "\x11validation_result\x18\x12 \x01(\tR\x10validationResult\x12)\n" + + "\x10rejection_reason\x18\x13 \x01(\tR\x0frejectionReasonB\x10\n" + + "\x0e_decided_at_ms*\xb6\x01\n" + + "\fSandboxPhase\x12\x1d\n" + + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + + "\x13SANDBOX_PHASE_READY\x10\x02\x12\x17\n" + + "\x13SANDBOX_PHASE_ERROR\x10\x03\x12\x1a\n" + + "\x16SANDBOX_PHASE_DELETING\x10\x04\x12\x19\n" + + "\x15SANDBOX_PHASE_UNKNOWN\x10\x05*\x85\x03\n" + + "!ProviderCredentialRefreshStrategy\x124\n" + + "0PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED\x10\x00\x12/\n" + + "+PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC\x10\x01\x121\n" + + "-PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL\x10\x02\x12=\n" + + "9PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN\x10\x03\x12B\n" + + ">PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS\x10\x04\x12C\n" + + "?PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT\x10\x05*\xdb\x02\n" + + "\x17ProviderProfileCategory\x12)\n" + + "%PROVIDER_PROFILE_CATEGORY_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fPROVIDER_PROFILE_CATEGORY_OTHER\x10\x01\x12'\n" + + "#PROVIDER_PROFILE_CATEGORY_INFERENCE\x10\x02\x12#\n" + + "\x1fPROVIDER_PROFILE_CATEGORY_AGENT\x10\x03\x12,\n" + + "(PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL\x10\x04\x12'\n" + + "#PROVIDER_PROFILE_CATEGORY_MESSAGING\x10\x05\x12\"\n" + + "\x1ePROVIDER_PROFILE_CATEGORY_DATA\x10\x06\x12'\n" + + "#PROVIDER_PROFILE_CATEGORY_KNOWLEDGE\x10\a*\x9a\x01\n" + + "\fPolicyStatus\x12\x1d\n" + + "\x19POLICY_STATUS_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15POLICY_STATUS_PENDING\x10\x01\x12\x18\n" + + "\x14POLICY_STATUS_LOADED\x10\x02\x12\x18\n" + + "\x14POLICY_STATUS_FAILED\x10\x03\x12\x1c\n" + + "\x18POLICY_STATUS_SUPERSEDED\x10\x04*\x86\x01\n" + + "\rServiceStatus\x12\x1e\n" + + "\x1aSERVICE_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16SERVICE_STATUS_HEALTHY\x10\x01\x12\x1b\n" + + "\x17SERVICE_STATUS_DEGRADED\x10\x02\x12\x1c\n" + + "\x18SERVICE_STATUS_UNHEALTHY\x10\x032\xf9*\n" + + "\tOpenShell\x12C\n" + + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\x12R\n" + + "\rCreateSandbox\x12\".openshell.v1.CreateSandboxRequest\x1a\x1d.openshell.v1.SandboxResponse\x12L\n" + + "\n" + + "GetSandbox\x12\x1f.openshell.v1.GetSandboxRequest\x1a\x1d.openshell.v1.SandboxResponse\x12X\n" + + "\rListSandboxes\x12\".openshell.v1.ListSandboxesRequest\x1a#.openshell.v1.ListSandboxesResponse\x12m\n" + + "\x14ListSandboxProviders\x12).openshell.v1.ListSandboxProvidersRequest\x1a*.openshell.v1.ListSandboxProvidersResponse\x12p\n" + + "\x15AttachSandboxProvider\x12*.openshell.v1.AttachSandboxProviderRequest\x1a+.openshell.v1.AttachSandboxProviderResponse\x12p\n" + + "\x15DetachSandboxProvider\x12*.openshell.v1.DetachSandboxProviderRequest\x1a+.openshell.v1.DetachSandboxProviderResponse\x12X\n" + + "\rDeleteSandbox\x12\".openshell.v1.DeleteSandboxRequest\x1a#.openshell.v1.DeleteSandboxResponse\x12a\n" + + "\x10CreateSshSession\x12%.openshell.v1.CreateSshSessionRequest\x1a&.openshell.v1.CreateSshSessionResponse\x12Z\n" + + "\rExposeService\x12\".openshell.v1.ExposeServiceRequest\x1a%.openshell.v1.ServiceEndpointResponse\x12T\n" + + "\n" + + "GetService\x12\x1f.openshell.v1.GetServiceRequest\x1a%.openshell.v1.ServiceEndpointResponse\x12U\n" + + "\fListServices\x12!.openshell.v1.ListServicesRequest\x1a\".openshell.v1.ListServicesResponse\x12X\n" + + "\rDeleteService\x12\".openshell.v1.DeleteServiceRequest\x1a#.openshell.v1.DeleteServiceResponse\x12a\n" + + "\x10RevokeSshSession\x12%.openshell.v1.RevokeSshSessionRequest\x1a&.openshell.v1.RevokeSshSessionResponse\x12Q\n" + + "\vExecSandbox\x12 .openshell.v1.ExecSandboxRequest\x1a\x1e.openshell.v1.ExecSandboxEvent0\x01\x12N\n" + + "\n" + + "ForwardTcp\x12\x1d.openshell.v1.TcpForwardFrame\x1a\x1d.openshell.v1.TcpForwardFrame(\x010\x01\x12\\\n" + + "\x16ExecSandboxInteractive\x12\x1e.openshell.v1.ExecSandboxInput\x1a\x1e.openshell.v1.ExecSandboxEvent(\x010\x01\x12U\n" + + "\x0eCreateProvider\x12#.openshell.v1.CreateProviderRequest\x1a\x1e.openshell.v1.ProviderResponse\x12O\n" + + "\vGetProvider\x12 .openshell.v1.GetProviderRequest\x1a\x1e.openshell.v1.ProviderResponse\x12X\n" + + "\rListProviders\x12\".openshell.v1.ListProvidersRequest\x1a#.openshell.v1.ListProvidersResponse\x12m\n" + + "\x14ListProviderProfiles\x12).openshell.v1.ListProviderProfilesRequest\x1a*.openshell.v1.ListProviderProfilesResponse\x12d\n" + + "\x12GetProviderProfile\x12'.openshell.v1.GetProviderProfileRequest\x1a%.openshell.v1.ProviderProfileResponse\x12s\n" + + "\x16ImportProviderProfiles\x12+.openshell.v1.ImportProviderProfilesRequest\x1a,.openshell.v1.ImportProviderProfilesResponse\x12s\n" + + "\x16UpdateProviderProfiles\x12+.openshell.v1.UpdateProviderProfilesRequest\x1a,.openshell.v1.UpdateProviderProfilesResponse\x12m\n" + + "\x14LintProviderProfiles\x12).openshell.v1.LintProviderProfilesRequest\x1a*.openshell.v1.LintProviderProfilesResponse\x12U\n" + + "\x0eUpdateProvider\x12#.openshell.v1.UpdateProviderRequest\x1a\x1e.openshell.v1.ProviderResponse\x12y\n" + + "\x18GetProviderRefreshStatus\x12-.openshell.v1.GetProviderRefreshStatusRequest\x1a..openshell.v1.GetProviderRefreshStatusResponse\x12y\n" + + "\x18ConfigureProviderRefresh\x12-.openshell.v1.ConfigureProviderRefreshRequest\x1a..openshell.v1.ConfigureProviderRefreshResponse\x12y\n" + + "\x18RotateProviderCredential\x12-.openshell.v1.RotateProviderCredentialRequest\x1a..openshell.v1.RotateProviderCredentialResponse\x12p\n" + + "\x15DeleteProviderRefresh\x12*.openshell.v1.DeleteProviderRefreshRequest\x1a+.openshell.v1.DeleteProviderRefreshResponse\x12[\n" + + "\x0eDeleteProvider\x12#.openshell.v1.DeleteProviderRequest\x1a$.openshell.v1.DeleteProviderResponse\x12p\n" + + "\x15DeleteProviderProfile\x12*.openshell.v1.DeleteProviderProfileRequest\x1a+.openshell.v1.DeleteProviderProfileResponse\x12q\n" + + "\x10GetSandboxConfig\x12-.openshell.sandbox.v1.GetSandboxConfigRequest\x1a..openshell.sandbox.v1.GetSandboxConfigResponse\x12q\n" + + "\x10GetGatewayConfig\x12-.openshell.sandbox.v1.GetGatewayConfigRequest\x1a..openshell.sandbox.v1.GetGatewayConfigResponse\x12U\n" + + "\fUpdateConfig\x12!.openshell.v1.UpdateConfigRequest\x1a\".openshell.v1.UpdateConfigResponse\x12s\n" + + "\x16GetSandboxPolicyStatus\x12+.openshell.v1.GetSandboxPolicyStatusRequest\x1a,.openshell.v1.GetSandboxPolicyStatusResponse\x12j\n" + + "\x13ListSandboxPolicies\x12(.openshell.v1.ListSandboxPoliciesRequest\x1a).openshell.v1.ListSandboxPoliciesResponse\x12g\n" + + "\x12ReportPolicyStatus\x12'.openshell.v1.ReportPolicyStatusRequest\x1a(.openshell.v1.ReportPolicyStatusResponse\x12\x88\x01\n" + + "\x1dGetSandboxProviderEnvironment\x122.openshell.v1.GetSandboxProviderEnvironmentRequest\x1a3.openshell.v1.GetSandboxProviderEnvironmentResponse\x12[\n" + + "\x0eGetSandboxLogs\x12#.openshell.v1.GetSandboxLogsRequest\x1a$.openshell.v1.GetSandboxLogsResponse\x12`\n" + + "\x0fPushSandboxLogs\x12$.openshell.v1.PushSandboxLogsRequest\x1a%.openshell.v1.PushSandboxLogsResponse(\x01\x12V\n" + + "\x11ConnectSupervisor\x12\x1f.openshell.v1.SupervisorMessage\x1a\x1c.openshell.v1.GatewayMessage(\x010\x01\x12E\n" + + "\vRelayStream\x12\x18.openshell.v1.RelayFrame\x1a\x18.openshell.v1.RelayFrame(\x010\x01\x12U\n" + + "\fWatchSandbox\x12!.openshell.v1.WatchSandboxRequest\x1a .openshell.v1.SandboxStreamEvent0\x01\x12m\n" + + "\x14SubmitPolicyAnalysis\x12).openshell.v1.SubmitPolicyAnalysisRequest\x1a*.openshell.v1.SubmitPolicyAnalysisResponse\x12[\n" + + "\x0eGetDraftPolicy\x12#.openshell.v1.GetDraftPolicyRequest\x1a$.openshell.v1.GetDraftPolicyResponse\x12d\n" + + "\x11ApproveDraftChunk\x12&.openshell.v1.ApproveDraftChunkRequest\x1a'.openshell.v1.ApproveDraftChunkResponse\x12a\n" + + "\x10RejectDraftChunk\x12%.openshell.v1.RejectDraftChunkRequest\x1a&.openshell.v1.RejectDraftChunkResponse\x12p\n" + + "\x15ApproveAllDraftChunks\x12*.openshell.v1.ApproveAllDraftChunksRequest\x1a+.openshell.v1.ApproveAllDraftChunksResponse\x12[\n" + + "\x0eEditDraftChunk\x12#.openshell.v1.EditDraftChunkRequest\x1a$.openshell.v1.EditDraftChunkResponse\x12[\n" + + "\x0eUndoDraftChunk\x12#.openshell.v1.UndoDraftChunkRequest\x1a$.openshell.v1.UndoDraftChunkResponse\x12a\n" + + "\x10ClearDraftChunks\x12%.openshell.v1.ClearDraftChunksRequest\x1a&.openshell.v1.ClearDraftChunksResponse\x12^\n" + + "\x0fGetDraftHistory\x12$.openshell.v1.GetDraftHistoryRequest\x1a%.openshell.v1.GetDraftHistoryResponse\x12d\n" + + "\x11IssueSandboxToken\x12&.openshell.v1.IssueSandboxTokenRequest\x1a'.openshell.v1.IssueSandboxTokenResponse\x12j\n" + + "\x13RefreshSandboxToken\x12(.openshell.v1.RefreshSandboxTokenRequest\x1a).openshell.v1.RefreshSandboxTokenResponseb\x06proto3" + +var ( + file_openshell_proto_rawDescOnce sync.Once + file_openshell_proto_rawDescData []byte +) + +func file_openshell_proto_rawDescGZIP() []byte { + file_openshell_proto_rawDescOnce.Do(func() { + file_openshell_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc))) + }) + return file_openshell_proto_rawDescData +} + +var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 172) +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 + (*IssueSandboxTokenRequest)(nil), // 5: openshell.v1.IssueSandboxTokenRequest + (*IssueSandboxTokenResponse)(nil), // 6: openshell.v1.IssueSandboxTokenResponse + (*RefreshSandboxTokenRequest)(nil), // 7: openshell.v1.RefreshSandboxTokenRequest + (*RefreshSandboxTokenResponse)(nil), // 8: openshell.v1.RefreshSandboxTokenResponse + (*HealthRequest)(nil), // 9: openshell.v1.HealthRequest + (*HealthResponse)(nil), // 10: openshell.v1.HealthResponse + (*Sandbox)(nil), // 11: openshell.v1.Sandbox + (*SandboxSpec)(nil), // 12: openshell.v1.SandboxSpec + (*ResourceRequirements)(nil), // 13: openshell.v1.ResourceRequirements + (*GpuResourceRequirements)(nil), // 14: openshell.v1.GpuResourceRequirements + (*SandboxTemplate)(nil), // 15: openshell.v1.SandboxTemplate + (*SandboxStatus)(nil), // 16: openshell.v1.SandboxStatus + (*SandboxCondition)(nil), // 17: openshell.v1.SandboxCondition + (*PlatformEvent)(nil), // 18: openshell.v1.PlatformEvent + (*CreateSandboxRequest)(nil), // 19: openshell.v1.CreateSandboxRequest + (*GetSandboxRequest)(nil), // 20: openshell.v1.GetSandboxRequest + (*ListSandboxesRequest)(nil), // 21: openshell.v1.ListSandboxesRequest + (*ListSandboxProvidersRequest)(nil), // 22: openshell.v1.ListSandboxProvidersRequest + (*AttachSandboxProviderRequest)(nil), // 23: openshell.v1.AttachSandboxProviderRequest + (*DetachSandboxProviderRequest)(nil), // 24: openshell.v1.DetachSandboxProviderRequest + (*DeleteSandboxRequest)(nil), // 25: openshell.v1.DeleteSandboxRequest + (*SandboxResponse)(nil), // 26: openshell.v1.SandboxResponse + (*ListSandboxesResponse)(nil), // 27: openshell.v1.ListSandboxesResponse + (*ListSandboxProvidersResponse)(nil), // 28: openshell.v1.ListSandboxProvidersResponse + (*AttachSandboxProviderResponse)(nil), // 29: openshell.v1.AttachSandboxProviderResponse + (*DetachSandboxProviderResponse)(nil), // 30: openshell.v1.DetachSandboxProviderResponse + (*DeleteSandboxResponse)(nil), // 31: openshell.v1.DeleteSandboxResponse + (*CreateSshSessionRequest)(nil), // 32: openshell.v1.CreateSshSessionRequest + (*CreateSshSessionResponse)(nil), // 33: openshell.v1.CreateSshSessionResponse + (*ExposeServiceRequest)(nil), // 34: openshell.v1.ExposeServiceRequest + (*GetServiceRequest)(nil), // 35: openshell.v1.GetServiceRequest + (*ListServicesRequest)(nil), // 36: openshell.v1.ListServicesRequest + (*ListServicesResponse)(nil), // 37: openshell.v1.ListServicesResponse + (*DeleteServiceRequest)(nil), // 38: openshell.v1.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 39: openshell.v1.DeleteServiceResponse + (*ServiceEndpoint)(nil), // 40: openshell.v1.ServiceEndpoint + (*ServiceEndpointResponse)(nil), // 41: openshell.v1.ServiceEndpointResponse + (*RevokeSshSessionRequest)(nil), // 42: openshell.v1.RevokeSshSessionRequest + (*RevokeSshSessionResponse)(nil), // 43: openshell.v1.RevokeSshSessionResponse + (*ExecSandboxRequest)(nil), // 44: openshell.v1.ExecSandboxRequest + (*ExecSandboxStdout)(nil), // 45: openshell.v1.ExecSandboxStdout + (*ExecSandboxStderr)(nil), // 46: openshell.v1.ExecSandboxStderr + (*ExecSandboxExit)(nil), // 47: openshell.v1.ExecSandboxExit + (*ExecSandboxEvent)(nil), // 48: openshell.v1.ExecSandboxEvent + (*TcpForwardInit)(nil), // 49: openshell.v1.TcpForwardInit + (*TcpForwardFrame)(nil), // 50: openshell.v1.TcpForwardFrame + (*ExecSandboxInput)(nil), // 51: openshell.v1.ExecSandboxInput + (*ExecSandboxWindowResize)(nil), // 52: openshell.v1.ExecSandboxWindowResize + (*SshSession)(nil), // 53: openshell.v1.SshSession + (*WatchSandboxRequest)(nil), // 54: openshell.v1.WatchSandboxRequest + (*SandboxStreamEvent)(nil), // 55: openshell.v1.SandboxStreamEvent + (*SandboxLogLine)(nil), // 56: openshell.v1.SandboxLogLine + (*SandboxStreamWarning)(nil), // 57: openshell.v1.SandboxStreamWarning + (*CreateProviderRequest)(nil), // 58: openshell.v1.CreateProviderRequest + (*GetProviderRequest)(nil), // 59: openshell.v1.GetProviderRequest + (*ListProvidersRequest)(nil), // 60: openshell.v1.ListProvidersRequest + (*UpdateProviderRequest)(nil), // 61: openshell.v1.UpdateProviderRequest + (*DeleteProviderRequest)(nil), // 62: openshell.v1.DeleteProviderRequest + (*ProviderResponse)(nil), // 63: openshell.v1.ProviderResponse + (*ListProvidersResponse)(nil), // 64: openshell.v1.ListProvidersResponse + (*ListProviderProfilesRequest)(nil), // 65: openshell.v1.ListProviderProfilesRequest + (*GetProviderProfileRequest)(nil), // 66: openshell.v1.GetProviderProfileRequest + (*ProviderProfileImportItem)(nil), // 67: openshell.v1.ProviderProfileImportItem + (*ProviderProfileDiagnostic)(nil), // 68: openshell.v1.ProviderProfileDiagnostic + (*ProviderCredentialTokenGrantAudienceOverride)(nil), // 69: openshell.v1.ProviderCredentialTokenGrantAudienceOverride + (*ProviderCredentialTokenGrant)(nil), // 70: openshell.v1.ProviderCredentialTokenGrant + (*ProviderProfileCredential)(nil), // 71: openshell.v1.ProviderProfileCredential + (*ProviderCredentialRefreshMaterial)(nil), // 72: openshell.v1.ProviderCredentialRefreshMaterial + (*ProviderCredentialRefresh)(nil), // 73: openshell.v1.ProviderCredentialRefresh + (*ProviderCredentialRefreshStatus)(nil), // 74: openshell.v1.ProviderCredentialRefreshStatus + (*ProviderProfileDiscovery)(nil), // 75: openshell.v1.ProviderProfileDiscovery + (*StoredProviderCredentialRefreshState)(nil), // 76: openshell.v1.StoredProviderCredentialRefreshState + (*GetProviderRefreshStatusRequest)(nil), // 77: openshell.v1.GetProviderRefreshStatusRequest + (*GetProviderRefreshStatusResponse)(nil), // 78: openshell.v1.GetProviderRefreshStatusResponse + (*ConfigureProviderRefreshRequest)(nil), // 79: openshell.v1.ConfigureProviderRefreshRequest + (*ConfigureProviderRefreshResponse)(nil), // 80: openshell.v1.ConfigureProviderRefreshResponse + (*RotateProviderCredentialRequest)(nil), // 81: openshell.v1.RotateProviderCredentialRequest + (*RotateProviderCredentialResponse)(nil), // 82: openshell.v1.RotateProviderCredentialResponse + (*DeleteProviderRefreshRequest)(nil), // 83: openshell.v1.DeleteProviderRefreshRequest + (*DeleteProviderRefreshResponse)(nil), // 84: openshell.v1.DeleteProviderRefreshResponse + (*ProviderProfile)(nil), // 85: openshell.v1.ProviderProfile + (*StoredProviderProfile)(nil), // 86: openshell.v1.StoredProviderProfile + (*ProviderProfileResponse)(nil), // 87: openshell.v1.ProviderProfileResponse + (*ListProviderProfilesResponse)(nil), // 88: openshell.v1.ListProviderProfilesResponse + (*ImportProviderProfilesRequest)(nil), // 89: openshell.v1.ImportProviderProfilesRequest + (*ImportProviderProfilesResponse)(nil), // 90: openshell.v1.ImportProviderProfilesResponse + (*UpdateProviderProfilesRequest)(nil), // 91: openshell.v1.UpdateProviderProfilesRequest + (*UpdateProviderProfilesResponse)(nil), // 92: openshell.v1.UpdateProviderProfilesResponse + (*LintProviderProfilesRequest)(nil), // 93: openshell.v1.LintProviderProfilesRequest + (*LintProviderProfilesResponse)(nil), // 94: openshell.v1.LintProviderProfilesResponse + (*DeleteProviderResponse)(nil), // 95: openshell.v1.DeleteProviderResponse + (*DeleteProviderProfileRequest)(nil), // 96: openshell.v1.DeleteProviderProfileRequest + (*DeleteProviderProfileResponse)(nil), // 97: openshell.v1.DeleteProviderProfileResponse + (*GetSandboxProviderEnvironmentRequest)(nil), // 98: openshell.v1.GetSandboxProviderEnvironmentRequest + (*GetSandboxProviderEnvironmentResponse)(nil), // 99: openshell.v1.GetSandboxProviderEnvironmentResponse + (*UpdateConfigRequest)(nil), // 100: openshell.v1.UpdateConfigRequest + (*PolicyMergeOperation)(nil), // 101: openshell.v1.PolicyMergeOperation + (*AddNetworkRule)(nil), // 102: openshell.v1.AddNetworkRule + (*RemoveNetworkEndpoint)(nil), // 103: openshell.v1.RemoveNetworkEndpoint + (*RemoveNetworkRule)(nil), // 104: openshell.v1.RemoveNetworkRule + (*AddDenyRules)(nil), // 105: openshell.v1.AddDenyRules + (*AddAllowRules)(nil), // 106: openshell.v1.AddAllowRules + (*RemoveNetworkBinary)(nil), // 107: openshell.v1.RemoveNetworkBinary + (*UpdateConfigResponse)(nil), // 108: openshell.v1.UpdateConfigResponse + (*GetSandboxPolicyStatusRequest)(nil), // 109: openshell.v1.GetSandboxPolicyStatusRequest + (*GetSandboxPolicyStatusResponse)(nil), // 110: openshell.v1.GetSandboxPolicyStatusResponse + (*ListSandboxPoliciesRequest)(nil), // 111: openshell.v1.ListSandboxPoliciesRequest + (*ListSandboxPoliciesResponse)(nil), // 112: openshell.v1.ListSandboxPoliciesResponse + (*ReportPolicyStatusRequest)(nil), // 113: openshell.v1.ReportPolicyStatusRequest + (*ReportPolicyStatusResponse)(nil), // 114: openshell.v1.ReportPolicyStatusResponse + (*SandboxPolicyRevision)(nil), // 115: openshell.v1.SandboxPolicyRevision + (*GetSandboxLogsRequest)(nil), // 116: openshell.v1.GetSandboxLogsRequest + (*PushSandboxLogsRequest)(nil), // 117: openshell.v1.PushSandboxLogsRequest + (*PushSandboxLogsResponse)(nil), // 118: openshell.v1.PushSandboxLogsResponse + (*GetSandboxLogsResponse)(nil), // 119: openshell.v1.GetSandboxLogsResponse + (*SupervisorMessage)(nil), // 120: openshell.v1.SupervisorMessage + (*GatewayMessage)(nil), // 121: openshell.v1.GatewayMessage + (*SupervisorHello)(nil), // 122: openshell.v1.SupervisorHello + (*SessionAccepted)(nil), // 123: openshell.v1.SessionAccepted + (*SessionRejected)(nil), // 124: openshell.v1.SessionRejected + (*SupervisorHeartbeat)(nil), // 125: openshell.v1.SupervisorHeartbeat + (*GatewayHeartbeat)(nil), // 126: openshell.v1.GatewayHeartbeat + (*RelayOpen)(nil), // 127: openshell.v1.RelayOpen + (*SshRelayTarget)(nil), // 128: openshell.v1.SshRelayTarget + (*TcpRelayTarget)(nil), // 129: openshell.v1.TcpRelayTarget + (*RelayInit)(nil), // 130: openshell.v1.RelayInit + (*RelayFrame)(nil), // 131: openshell.v1.RelayFrame + (*RelayOpenResult)(nil), // 132: openshell.v1.RelayOpenResult + (*RelayClose)(nil), // 133: openshell.v1.RelayClose + (*L7RequestSample)(nil), // 134: openshell.v1.L7RequestSample + (*DenialSummary)(nil), // 135: openshell.v1.DenialSummary + (*DenialGroupCount)(nil), // 136: openshell.v1.DenialGroupCount + (*NetworkActivitySummary)(nil), // 137: openshell.v1.NetworkActivitySummary + (*PolicyChunk)(nil), // 138: openshell.v1.PolicyChunk + (*DraftPolicyUpdate)(nil), // 139: openshell.v1.DraftPolicyUpdate + (*SubmitPolicyAnalysisRequest)(nil), // 140: openshell.v1.SubmitPolicyAnalysisRequest + (*SubmitPolicyAnalysisResponse)(nil), // 141: openshell.v1.SubmitPolicyAnalysisResponse + (*GetDraftPolicyRequest)(nil), // 142: openshell.v1.GetDraftPolicyRequest + (*GetDraftPolicyResponse)(nil), // 143: openshell.v1.GetDraftPolicyResponse + (*ApproveDraftChunkRequest)(nil), // 144: openshell.v1.ApproveDraftChunkRequest + (*ApproveDraftChunkResponse)(nil), // 145: openshell.v1.ApproveDraftChunkResponse + (*RejectDraftChunkRequest)(nil), // 146: openshell.v1.RejectDraftChunkRequest + (*RejectDraftChunkResponse)(nil), // 147: openshell.v1.RejectDraftChunkResponse + (*ApproveAllDraftChunksRequest)(nil), // 148: openshell.v1.ApproveAllDraftChunksRequest + (*ApproveAllDraftChunksResponse)(nil), // 149: openshell.v1.ApproveAllDraftChunksResponse + (*EditDraftChunkRequest)(nil), // 150: openshell.v1.EditDraftChunkRequest + (*EditDraftChunkResponse)(nil), // 151: openshell.v1.EditDraftChunkResponse + (*UndoDraftChunkRequest)(nil), // 152: openshell.v1.UndoDraftChunkRequest + (*UndoDraftChunkResponse)(nil), // 153: openshell.v1.UndoDraftChunkResponse + (*ClearDraftChunksRequest)(nil), // 154: openshell.v1.ClearDraftChunksRequest + (*ClearDraftChunksResponse)(nil), // 155: openshell.v1.ClearDraftChunksResponse + (*GetDraftHistoryRequest)(nil), // 156: openshell.v1.GetDraftHistoryRequest + (*DraftHistoryEntry)(nil), // 157: openshell.v1.DraftHistoryEntry + (*GetDraftHistoryResponse)(nil), // 158: openshell.v1.GetDraftHistoryResponse + (*PolicyRevisionPayload)(nil), // 159: openshell.v1.PolicyRevisionPayload + (*DraftChunkPayload)(nil), // 160: openshell.v1.DraftChunkPayload + (*StoredPolicyRevision)(nil), // 161: openshell.v1.StoredPolicyRevision + (*StoredDraftChunk)(nil), // 162: openshell.v1.StoredDraftChunk + nil, // 163: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 164: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 165: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 166: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 167: openshell.v1.PlatformEvent.MetadataEntry + nil, // 168: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 169: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 170: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 171: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 172: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 173: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 174: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 175: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 176: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + (*datamodelv1.ObjectMeta)(nil), // 177: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 178: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 179: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 180: openshell.datamodel.v1.Provider + (*sandboxv1.NetworkEndpoint)(nil), // 181: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 182: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 183: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 184: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 185: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 186: openshell.sandbox.v1.L7Rule + (*sandboxv1.GetSandboxConfigRequest)(nil), // 187: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 188: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 189: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 190: openshell.sandbox.v1.GetGatewayConfigResponse +} +var file_openshell_proto_depIdxs = []int32{ + 4, // 0: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 177, // 1: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 12, // 2: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 16, // 3: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 163, // 4: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 15, // 5: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 178, // 6: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 13, // 7: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 14, // 8: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 164, // 9: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 165, // 10: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 166, // 11: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 179, // 12: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 179, // 13: openshell.v1.SandboxTemplate.volume_claim_templates:type_name -> google.protobuf.Struct + 179, // 14: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 17, // 15: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 0, // 16: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 167, // 17: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 12, // 18: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 168, // 19: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 11, // 20: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 11, // 21: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 180, // 22: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 11, // 23: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 11, // 24: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 41, // 25: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 177, // 26: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 40, // 27: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 169, // 28: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 45, // 29: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 46, // 30: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 47, // 31: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 128, // 32: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 129, // 33: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 49, // 34: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 44, // 35: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 52, // 36: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 177, // 37: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 11, // 38: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 56, // 39: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 18, // 40: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 57, // 41: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 139, // 42: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 170, // 43: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 180, // 44: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 180, // 45: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 171, // 46: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 180, // 47: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 180, // 48: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 85, // 49: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 69, // 50: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 73, // 51: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 70, // 52: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 1, // 53: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 72, // 54: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 1, // 55: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 177, // 56: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 1, // 57: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 172, // 58: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 74, // 59: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 1, // 60: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 173, // 61: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 74, // 62: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 74, // 63: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 64: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 71, // 65: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 181, // 66: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 182, // 67: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 75, // 68: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 177, // 69: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 85, // 70: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile + 85, // 71: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 85, // 72: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 67, // 73: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 68, // 74: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 85, // 75: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 67, // 76: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 68, // 77: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 85, // 78: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 67, // 79: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 68, // 80: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 174, // 81: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 175, // 82: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 176, // 83: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 178, // 84: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 183, // 85: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 101, // 86: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 102, // 87: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 103, // 88: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 104, // 89: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 105, // 90: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 106, // 91: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 107, // 92: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 184, // 93: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 185, // 94: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 186, // 95: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 115, // 96: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 115, // 97: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 3, // 98: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 3, // 99: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 178, // 100: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 56, // 101: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 56, // 102: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 122, // 103: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 125, // 104: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 132, // 105: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 133, // 106: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 123, // 107: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 124, // 108: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 126, // 109: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 127, // 110: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 133, // 111: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 128, // 112: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 129, // 113: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 130, // 114: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 134, // 115: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 136, // 116: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 184, // 117: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 135, // 118: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 138, // 119: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 137, // 120: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 138, // 121: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 184, // 122: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 157, // 123: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 178, // 124: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 184, // 125: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 71, // 126: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 9, // 127: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 19, // 128: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 20, // 129: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 21, // 130: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 22, // 131: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 23, // 132: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 24, // 133: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 25, // 134: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 32, // 135: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 34, // 136: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 35, // 137: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 36, // 138: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 38, // 139: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 42, // 140: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 44, // 141: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 50, // 142: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 51, // 143: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 58, // 144: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 59, // 145: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 60, // 146: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 65, // 147: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 66, // 148: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 89, // 149: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 91, // 150: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 93, // 151: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 61, // 152: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 77, // 153: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 79, // 154: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 81, // 155: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 83, // 156: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 62, // 157: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 96, // 158: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 187, // 159: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 188, // 160: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 100, // 161: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 109, // 162: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 111, // 163: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 113, // 164: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 98, // 165: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 116, // 166: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 117, // 167: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 120, // 168: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 131, // 169: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 54, // 170: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 140, // 171: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 142, // 172: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 144, // 173: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 146, // 174: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 148, // 175: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 150, // 176: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 152, // 177: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 154, // 178: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 156, // 179: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 5, // 180: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 7, // 181: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 10, // 182: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 26, // 183: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 26, // 184: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 27, // 185: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 28, // 186: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 29, // 187: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 30, // 188: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 31, // 189: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 33, // 190: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 41, // 191: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 41, // 192: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 37, // 193: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 39, // 194: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 43, // 195: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 48, // 196: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 50, // 197: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 48, // 198: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 63, // 199: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 63, // 200: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 64, // 201: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 88, // 202: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 87, // 203: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 90, // 204: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 92, // 205: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 94, // 206: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 63, // 207: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 78, // 208: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 80, // 209: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 82, // 210: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 84, // 211: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 95, // 212: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 97, // 213: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 189, // 214: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 190, // 215: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 108, // 216: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 110, // 217: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 112, // 218: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 114, // 219: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 99, // 220: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 119, // 221: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 118, // 222: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 121, // 223: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 131, // 224: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 55, // 225: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 141, // 226: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 143, // 227: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 145, // 228: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 147, // 229: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 149, // 230: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 151, // 231: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 153, // 232: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 155, // 233: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 158, // 234: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 6, // 235: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 8, // 236: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 182, // [182:237] is the sub-list for method output_type + 127, // [127:182] is the sub-list for method input_type + 127, // [127:127] is the sub-list for extension type_name + 127, // [127:127] is the sub-list for extension extendee + 0, // [0:127] is the sub-list for field type_name +} + +func init() { file_openshell_proto_init() } +func file_openshell_proto_init() { + if File_openshell_proto != nil { + return + } + file_openshell_proto_msgTypes[9].OneofWrappers = []any{} + file_openshell_proto_msgTypes[10].OneofWrappers = []any{} + file_openshell_proto_msgTypes[43].OneofWrappers = []any{ + (*ExecSandboxEvent_Stdout)(nil), + (*ExecSandboxEvent_Stderr)(nil), + (*ExecSandboxEvent_Exit)(nil), + } + file_openshell_proto_msgTypes[44].OneofWrappers = []any{ + (*TcpForwardInit_Ssh)(nil), + (*TcpForwardInit_Tcp)(nil), + } + file_openshell_proto_msgTypes[45].OneofWrappers = []any{ + (*TcpForwardFrame_Init)(nil), + (*TcpForwardFrame_Data)(nil), + } + file_openshell_proto_msgTypes[46].OneofWrappers = []any{ + (*ExecSandboxInput_Start)(nil), + (*ExecSandboxInput_Stdin)(nil), + (*ExecSandboxInput_Resize)(nil), + } + file_openshell_proto_msgTypes[50].OneofWrappers = []any{ + (*SandboxStreamEvent_Sandbox)(nil), + (*SandboxStreamEvent_Log)(nil), + (*SandboxStreamEvent_Event)(nil), + (*SandboxStreamEvent_Warning)(nil), + (*SandboxStreamEvent_DraftPolicyUpdate)(nil), + } + file_openshell_proto_msgTypes[74].OneofWrappers = []any{} + file_openshell_proto_msgTypes[96].OneofWrappers = []any{ + (*PolicyMergeOperation_AddRule)(nil), + (*PolicyMergeOperation_RemoveEndpoint)(nil), + (*PolicyMergeOperation_RemoveRule)(nil), + (*PolicyMergeOperation_AddDenyRules)(nil), + (*PolicyMergeOperation_AddAllowRules)(nil), + (*PolicyMergeOperation_RemoveBinary)(nil), + } + file_openshell_proto_msgTypes[115].OneofWrappers = []any{ + (*SupervisorMessage_Hello)(nil), + (*SupervisorMessage_Heartbeat)(nil), + (*SupervisorMessage_RelayOpenResult)(nil), + (*SupervisorMessage_RelayClose)(nil), + } + file_openshell_proto_msgTypes[116].OneofWrappers = []any{ + (*GatewayMessage_SessionAccepted)(nil), + (*GatewayMessage_SessionRejected)(nil), + (*GatewayMessage_Heartbeat)(nil), + (*GatewayMessage_RelayOpen)(nil), + (*GatewayMessage_RelayClose)(nil), + } + file_openshell_proto_msgTypes[122].OneofWrappers = []any{ + (*RelayOpen_Ssh)(nil), + (*RelayOpen_Tcp)(nil), + } + file_openshell_proto_msgTypes[126].OneofWrappers = []any{ + (*RelayFrame_Init)(nil), + (*RelayFrame_Data)(nil), + } + file_openshell_proto_msgTypes[156].OneofWrappers = []any{} + file_openshell_proto_msgTypes[157].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: 5, + NumMessages: 172, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_openshell_proto_goTypes, + DependencyIndexes: file_openshell_proto_depIdxs, + EnumInfos: file_openshell_proto_enumTypes, + MessageInfos: file_openshell_proto_msgTypes, + }.Build() + File_openshell_proto = out.File + file_openshell_proto_goTypes = nil + file_openshell_proto_depIdxs = nil +} diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go new file mode 100644 index 0000000000..eff1f9ad76 --- /dev/null +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -0,0 +1,2345 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v5.29.6 +// source: openshell.proto + +package openshellv1 + +import ( + context "context" + sandboxv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + OpenShell_Health_FullMethodName = "/openshell.v1.OpenShell/Health" + OpenShell_CreateSandbox_FullMethodName = "/openshell.v1.OpenShell/CreateSandbox" + OpenShell_GetSandbox_FullMethodName = "/openshell.v1.OpenShell/GetSandbox" + OpenShell_ListSandboxes_FullMethodName = "/openshell.v1.OpenShell/ListSandboxes" + OpenShell_ListSandboxProviders_FullMethodName = "/openshell.v1.OpenShell/ListSandboxProviders" + OpenShell_AttachSandboxProvider_FullMethodName = "/openshell.v1.OpenShell/AttachSandboxProvider" + OpenShell_DetachSandboxProvider_FullMethodName = "/openshell.v1.OpenShell/DetachSandboxProvider" + OpenShell_DeleteSandbox_FullMethodName = "/openshell.v1.OpenShell/DeleteSandbox" + OpenShell_CreateSshSession_FullMethodName = "/openshell.v1.OpenShell/CreateSshSession" + OpenShell_ExposeService_FullMethodName = "/openshell.v1.OpenShell/ExposeService" + OpenShell_GetService_FullMethodName = "/openshell.v1.OpenShell/GetService" + OpenShell_ListServices_FullMethodName = "/openshell.v1.OpenShell/ListServices" + OpenShell_DeleteService_FullMethodName = "/openshell.v1.OpenShell/DeleteService" + OpenShell_RevokeSshSession_FullMethodName = "/openshell.v1.OpenShell/RevokeSshSession" + OpenShell_ExecSandbox_FullMethodName = "/openshell.v1.OpenShell/ExecSandbox" + OpenShell_ForwardTcp_FullMethodName = "/openshell.v1.OpenShell/ForwardTcp" + OpenShell_ExecSandboxInteractive_FullMethodName = "/openshell.v1.OpenShell/ExecSandboxInteractive" + OpenShell_CreateProvider_FullMethodName = "/openshell.v1.OpenShell/CreateProvider" + OpenShell_GetProvider_FullMethodName = "/openshell.v1.OpenShell/GetProvider" + OpenShell_ListProviders_FullMethodName = "/openshell.v1.OpenShell/ListProviders" + OpenShell_ListProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/ListProviderProfiles" + OpenShell_GetProviderProfile_FullMethodName = "/openshell.v1.OpenShell/GetProviderProfile" + OpenShell_ImportProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/ImportProviderProfiles" + OpenShell_UpdateProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/UpdateProviderProfiles" + OpenShell_LintProviderProfiles_FullMethodName = "/openshell.v1.OpenShell/LintProviderProfiles" + OpenShell_UpdateProvider_FullMethodName = "/openshell.v1.OpenShell/UpdateProvider" + OpenShell_GetProviderRefreshStatus_FullMethodName = "/openshell.v1.OpenShell/GetProviderRefreshStatus" + OpenShell_ConfigureProviderRefresh_FullMethodName = "/openshell.v1.OpenShell/ConfigureProviderRefresh" + OpenShell_RotateProviderCredential_FullMethodName = "/openshell.v1.OpenShell/RotateProviderCredential" + OpenShell_DeleteProviderRefresh_FullMethodName = "/openshell.v1.OpenShell/DeleteProviderRefresh" + OpenShell_DeleteProvider_FullMethodName = "/openshell.v1.OpenShell/DeleteProvider" + OpenShell_DeleteProviderProfile_FullMethodName = "/openshell.v1.OpenShell/DeleteProviderProfile" + OpenShell_GetSandboxConfig_FullMethodName = "/openshell.v1.OpenShell/GetSandboxConfig" + OpenShell_GetGatewayConfig_FullMethodName = "/openshell.v1.OpenShell/GetGatewayConfig" + OpenShell_UpdateConfig_FullMethodName = "/openshell.v1.OpenShell/UpdateConfig" + OpenShell_GetSandboxPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/GetSandboxPolicyStatus" + OpenShell_ListSandboxPolicies_FullMethodName = "/openshell.v1.OpenShell/ListSandboxPolicies" + OpenShell_ReportPolicyStatus_FullMethodName = "/openshell.v1.OpenShell/ReportPolicyStatus" + OpenShell_GetSandboxProviderEnvironment_FullMethodName = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" + OpenShell_GetSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/GetSandboxLogs" + OpenShell_PushSandboxLogs_FullMethodName = "/openshell.v1.OpenShell/PushSandboxLogs" + OpenShell_ConnectSupervisor_FullMethodName = "/openshell.v1.OpenShell/ConnectSupervisor" + OpenShell_RelayStream_FullMethodName = "/openshell.v1.OpenShell/RelayStream" + OpenShell_WatchSandbox_FullMethodName = "/openshell.v1.OpenShell/WatchSandbox" + OpenShell_SubmitPolicyAnalysis_FullMethodName = "/openshell.v1.OpenShell/SubmitPolicyAnalysis" + OpenShell_GetDraftPolicy_FullMethodName = "/openshell.v1.OpenShell/GetDraftPolicy" + OpenShell_ApproveDraftChunk_FullMethodName = "/openshell.v1.OpenShell/ApproveDraftChunk" + OpenShell_RejectDraftChunk_FullMethodName = "/openshell.v1.OpenShell/RejectDraftChunk" + OpenShell_ApproveAllDraftChunks_FullMethodName = "/openshell.v1.OpenShell/ApproveAllDraftChunks" + OpenShell_EditDraftChunk_FullMethodName = "/openshell.v1.OpenShell/EditDraftChunk" + OpenShell_UndoDraftChunk_FullMethodName = "/openshell.v1.OpenShell/UndoDraftChunk" + OpenShell_ClearDraftChunks_FullMethodName = "/openshell.v1.OpenShell/ClearDraftChunks" + OpenShell_GetDraftHistory_FullMethodName = "/openshell.v1.OpenShell/GetDraftHistory" + OpenShell_IssueSandboxToken_FullMethodName = "/openshell.v1.OpenShell/IssueSandboxToken" + OpenShell_RefreshSandboxToken_FullMethodName = "/openshell.v1.OpenShell/RefreshSandboxToken" +) + +// OpenShellClient is the client API for OpenShell service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +type OpenShellClient interface { + // Check the health of the service. + Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) + // Create a new sandbox. + CreateSandbox(ctx context.Context, in *CreateSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) + // Fetch a sandbox by name. + GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) + // List sandboxes. + ListSandboxes(ctx context.Context, in *ListSandboxesRequest, opts ...grpc.CallOption) (*ListSandboxesResponse, 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. + AttachSandboxProvider(ctx context.Context, in *AttachSandboxProviderRequest, opts ...grpc.CallOption) (*AttachSandboxProviderResponse, error) + // Detach a provider record from an existing sandbox. + DetachSandboxProvider(ctx context.Context, in *DetachSandboxProviderRequest, opts ...grpc.CallOption) (*DetachSandboxProviderResponse, error) + // Delete a sandbox by name. + DeleteSandbox(ctx context.Context, in *DeleteSandboxRequest, opts ...grpc.CallOption) (*DeleteSandboxResponse, error) + // Create a short-lived SSH session for a sandbox. + CreateSshSession(ctx context.Context, in *CreateSshSessionRequest, opts ...grpc.CallOption) (*CreateSshSessionResponse, error) + // Create or update a sandbox HTTP service endpoint for local routing. + ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) + // Fetch one sandbox HTTP service endpoint. + GetService(ctx context.Context, in *GetServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) + // List sandbox HTTP service endpoints. + ListServices(ctx context.Context, in *ListServicesRequest, opts ...grpc.CallOption) (*ListServicesResponse, error) + // Delete one sandbox HTTP service endpoint. + DeleteService(ctx context.Context, in *DeleteServiceRequest, opts ...grpc.CallOption) (*DeleteServiceResponse, error) + // Revoke a previously issued SSH session. + RevokeSshSession(ctx context.Context, in *RevokeSshSessionRequest, opts ...grpc.CallOption) (*RevokeSshSessionResponse, error) + // Execute a command in a ready sandbox and stream output. + ExecSandbox(ctx context.Context, in *ExecSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecSandboxEvent], error) + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + ForwardTcp(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TcpForwardFrame, TcpForwardFrame], error) + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + ExecSandboxInteractive(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecSandboxInput, ExecSandboxEvent], error) + // Create a provider. + CreateProvider(ctx context.Context, in *CreateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) + // Fetch a provider by name. + GetProvider(ctx context.Context, in *GetProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) + // List providers. + ListProviders(ctx context.Context, in *ListProvidersRequest, opts ...grpc.CallOption) (*ListProvidersResponse, error) + // List available provider type profiles. + ListProviderProfiles(ctx context.Context, in *ListProviderProfilesRequest, opts ...grpc.CallOption) (*ListProviderProfilesResponse, error) + // Fetch one provider type profile by id. + GetProviderProfile(ctx context.Context, in *GetProviderProfileRequest, opts ...grpc.CallOption) (*ProviderProfileResponse, error) + // Import custom provider type profiles. + ImportProviderProfiles(ctx context.Context, in *ImportProviderProfilesRequest, opts ...grpc.CallOption) (*ImportProviderProfilesResponse, error) + // Update an existing custom provider type profile. + UpdateProviderProfiles(ctx context.Context, in *UpdateProviderProfilesRequest, opts ...grpc.CallOption) (*UpdateProviderProfilesResponse, error) + // Validate provider type profiles without registering them. + LintProviderProfiles(ctx context.Context, in *LintProviderProfilesRequest, opts ...grpc.CallOption) (*LintProviderProfilesResponse, error) + // Update an existing provider by name. + UpdateProvider(ctx context.Context, in *UpdateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) + // Fetch refresh status for one provider or provider credential. + GetProviderRefreshStatus(ctx context.Context, in *GetProviderRefreshStatusRequest, opts ...grpc.CallOption) (*GetProviderRefreshStatusResponse, error) + // Configure gateway-owned refresh material for one provider credential. + ConfigureProviderRefresh(ctx context.Context, in *ConfigureProviderRefreshRequest, opts ...grpc.CallOption) (*ConfigureProviderRefreshResponse, error) + // Record a gateway-owned refresh request for one provider credential. + RotateProviderCredential(ctx context.Context, in *RotateProviderCredentialRequest, opts ...grpc.CallOption) (*RotateProviderCredentialResponse, error) + // Delete gateway-owned refresh configuration for one provider credential. + DeleteProviderRefresh(ctx context.Context, in *DeleteProviderRefreshRequest, opts ...grpc.CallOption) (*DeleteProviderRefreshResponse, error) + // Delete a provider by name. + DeleteProvider(ctx context.Context, in *DeleteProviderRequest, opts ...grpc.CallOption) (*DeleteProviderResponse, error) + // Delete a custom provider type profile by id. + 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. + GetGatewayConfig(ctx context.Context, in *sandboxv1.GetGatewayConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetGatewayConfigResponse, error) + // Update settings or policy at sandbox or global scope. + UpdateConfig(ctx context.Context, in *UpdateConfigRequest, opts ...grpc.CallOption) (*UpdateConfigResponse, error) + // Get the load status of a specific policy version. + GetSandboxPolicyStatus(ctx context.Context, in *GetSandboxPolicyStatusRequest, opts ...grpc.CallOption) (*GetSandboxPolicyStatusResponse, error) + // List policy history for a sandbox. + ListSandboxPolicies(ctx context.Context, in *ListSandboxPoliciesRequest, opts ...grpc.CallOption) (*ListSandboxPoliciesResponse, error) + // Report policy load result (called by sandbox after reload attempt). + 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) + // 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). + PushSandboxLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushSandboxLogsRequest, PushSandboxLogsResponse], error) + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + RelayStream(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[RelayFrame, RelayFrame], error) + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + WatchSandbox(ctx context.Context, in *WatchSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxStreamEvent], error) + // Submit denial analysis results from sandbox (summaries + proposed chunks). + SubmitPolicyAnalysis(ctx context.Context, in *SubmitPolicyAnalysisRequest, opts ...grpc.CallOption) (*SubmitPolicyAnalysisResponse, error) + // Get draft policy recommendations for a sandbox. + GetDraftPolicy(ctx context.Context, in *GetDraftPolicyRequest, opts ...grpc.CallOption) (*GetDraftPolicyResponse, error) + // Approve a single draft policy chunk (merges into active policy). + ApproveDraftChunk(ctx context.Context, in *ApproveDraftChunkRequest, opts ...grpc.CallOption) (*ApproveDraftChunkResponse, error) + // Reject a single draft policy chunk. + RejectDraftChunk(ctx context.Context, in *RejectDraftChunkRequest, opts ...grpc.CallOption) (*RejectDraftChunkResponse, error) + // Approve all pending draft chunks (skips security-flagged unless forced). + ApproveAllDraftChunks(ctx context.Context, in *ApproveAllDraftChunksRequest, opts ...grpc.CallOption) (*ApproveAllDraftChunksResponse, error) + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + EditDraftChunk(ctx context.Context, in *EditDraftChunkRequest, opts ...grpc.CallOption) (*EditDraftChunkResponse, error) + // Reverse an approval (remove merged rule from active policy). + UndoDraftChunk(ctx context.Context, in *UndoDraftChunkRequest, opts ...grpc.CallOption) (*UndoDraftChunkResponse, error) + // Clear all pending draft chunks for a sandbox. + ClearDraftChunks(ctx context.Context, in *ClearDraftChunksRequest, opts ...grpc.CallOption) (*ClearDraftChunksResponse, error) + // Get decision history for a sandbox's draft policy. + GetDraftHistory(ctx context.Context, in *GetDraftHistoryRequest, opts ...grpc.CallOption) (*GetDraftHistoryResponse, error) + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + IssueSandboxToken(ctx context.Context, in *IssueSandboxTokenRequest, opts ...grpc.CallOption) (*IssueSandboxTokenResponse, error) + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + RefreshSandboxToken(ctx context.Context, in *RefreshSandboxTokenRequest, opts ...grpc.CallOption) (*RefreshSandboxTokenResponse, error) +} + +type openShellClient struct { + cc grpc.ClientConnInterface +} + +func NewOpenShellClient(cc grpc.ClientConnInterface) OpenShellClient { + return &openShellClient{cc} +} + +func (c *openShellClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, OpenShell_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) CreateSandbox(ctx context.Context, in *CreateSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SandboxResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateSandbox_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandbox(ctx context.Context, in *GetSandboxRequest, opts ...grpc.CallOption) (*SandboxResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SandboxResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandbox_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListSandboxes(ctx context.Context, in *ListSandboxesRequest, opts ...grpc.CallOption) (*ListSandboxesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSandboxesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxes_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) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxProviders_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) AttachSandboxProvider(ctx context.Context, in *AttachSandboxProviderRequest, opts ...grpc.CallOption) (*AttachSandboxProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AttachSandboxProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_AttachSandboxProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DetachSandboxProvider(ctx context.Context, in *DetachSandboxProviderRequest, opts ...grpc.CallOption) (*DetachSandboxProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DetachSandboxProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_DetachSandboxProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteSandbox(ctx context.Context, in *DeleteSandboxRequest, opts ...grpc.CallOption) (*DeleteSandboxResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteSandboxResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteSandbox_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) CreateSshSession(ctx context.Context, in *CreateSshSessionRequest, opts ...grpc.CallOption) (*CreateSshSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateSshSessionResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateSshSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ServiceEndpointResponse) + err := c.cc.Invoke(ctx, OpenShell_ExposeService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetService(ctx context.Context, in *GetServiceRequest, opts ...grpc.CallOption) (*ServiceEndpointResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ServiceEndpointResponse) + err := c.cc.Invoke(ctx, OpenShell_GetService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListServices(ctx context.Context, in *ListServicesRequest, opts ...grpc.CallOption) (*ListServicesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListServicesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListServices_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteService(ctx context.Context, in *DeleteServiceRequest, opts ...grpc.CallOption) (*DeleteServiceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteServiceResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RevokeSshSession(ctx context.Context, in *RevokeSshSessionRequest, opts ...grpc.CallOption) (*RevokeSshSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevokeSshSessionResponse) + err := c.cc.Invoke(ctx, OpenShell_RevokeSshSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ExecSandbox(ctx context.Context, in *ExecSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecSandboxEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[0], OpenShell_ExecSandbox_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecSandboxRequest, ExecSandboxEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxClient = grpc.ServerStreamingClient[ExecSandboxEvent] + +func (c *openShellClient) ForwardTcp(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TcpForwardFrame, TcpForwardFrame], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[1], OpenShell_ForwardTcp_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[TcpForwardFrame, TcpForwardFrame]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ForwardTcpClient = grpc.BidiStreamingClient[TcpForwardFrame, TcpForwardFrame] + +func (c *openShellClient) ExecSandboxInteractive(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecSandboxInput, ExecSandboxEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[2], OpenShell_ExecSandboxInteractive_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecSandboxInput, ExecSandboxEvent]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxInteractiveClient = grpc.BidiStreamingClient[ExecSandboxInput, ExecSandboxEvent] + +func (c *openShellClient) CreateProvider(ctx context.Context, in *CreateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_CreateProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetProvider(ctx context.Context, in *GetProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_GetProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListProviders(ctx context.Context, in *ListProvidersRequest, opts ...grpc.CallOption) (*ListProvidersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListProvidersResponse) + err := c.cc.Invoke(ctx, OpenShell_ListProviders_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListProviderProfiles(ctx context.Context, in *ListProviderProfilesRequest, opts ...grpc.CallOption) (*ListProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetProviderProfile(ctx context.Context, in *GetProviderProfileRequest, opts ...grpc.CallOption) (*ProviderProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderProfileResponse) + err := c.cc.Invoke(ctx, OpenShell_GetProviderProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ImportProviderProfiles(ctx context.Context, in *ImportProviderProfilesRequest, opts ...grpc.CallOption) (*ImportProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ImportProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_ImportProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UpdateProviderProfiles(ctx context.Context, in *UpdateProviderProfilesRequest, opts ...grpc.CallOption) (*UpdateProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_UpdateProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) LintProviderProfiles(ctx context.Context, in *LintProviderProfilesRequest, opts ...grpc.CallOption) (*LintProviderProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LintProviderProfilesResponse) + err := c.cc.Invoke(ctx, OpenShell_LintProviderProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UpdateProvider(ctx context.Context, in *UpdateProviderRequest, opts ...grpc.CallOption) (*ProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_UpdateProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetProviderRefreshStatus(ctx context.Context, in *GetProviderRefreshStatusRequest, opts ...grpc.CallOption) (*GetProviderRefreshStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetProviderRefreshStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_GetProviderRefreshStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ConfigureProviderRefresh(ctx context.Context, in *ConfigureProviderRefreshRequest, opts ...grpc.CallOption) (*ConfigureProviderRefreshResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigureProviderRefreshResponse) + err := c.cc.Invoke(ctx, OpenShell_ConfigureProviderRefresh_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RotateProviderCredential(ctx context.Context, in *RotateProviderCredentialRequest, opts ...grpc.CallOption) (*RotateProviderCredentialResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RotateProviderCredentialResponse) + err := c.cc.Invoke(ctx, OpenShell_RotateProviderCredential_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteProviderRefresh(ctx context.Context, in *DeleteProviderRefreshRequest, opts ...grpc.CallOption) (*DeleteProviderRefreshResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteProviderRefreshResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteProviderRefresh_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteProvider(ctx context.Context, in *DeleteProviderRequest, opts ...grpc.CallOption) (*DeleteProviderResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteProviderResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteProvider_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) DeleteProviderProfile(ctx context.Context, in *DeleteProviderProfileRequest, opts ...grpc.CallOption) (*DeleteProviderProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteProviderProfileResponse) + err := c.cc.Invoke(ctx, OpenShell_DeleteProviderProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxConfig(ctx context.Context, in *sandboxv1.GetSandboxConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetSandboxConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(sandboxv1.GetSandboxConfigResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetGatewayConfig(ctx context.Context, in *sandboxv1.GetGatewayConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetGatewayConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(sandboxv1.GetGatewayConfigResponse) + err := c.cc.Invoke(ctx, OpenShell_GetGatewayConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UpdateConfig(ctx context.Context, in *UpdateConfigRequest, opts ...grpc.CallOption) (*UpdateConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateConfigResponse) + err := c.cc.Invoke(ctx, OpenShell_UpdateConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxPolicyStatus(ctx context.Context, in *GetSandboxPolicyStatusRequest, opts ...grpc.CallOption) (*GetSandboxPolicyStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxPolicyStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxPolicyStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ListSandboxPolicies(ctx context.Context, in *ListSandboxPoliciesRequest, opts ...grpc.CallOption) (*ListSandboxPoliciesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSandboxPoliciesResponse) + err := c.cc.Invoke(ctx, OpenShell_ListSandboxPolicies_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ReportPolicyStatus(ctx context.Context, in *ReportPolicyStatusRequest, opts ...grpc.CallOption) (*ReportPolicyStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportPolicyStatusResponse) + err := c.cc.Invoke(ctx, OpenShell_ReportPolicyStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetSandboxProviderEnvironment(ctx context.Context, in *GetSandboxProviderEnvironmentRequest, opts ...grpc.CallOption) (*GetSandboxProviderEnvironmentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSandboxProviderEnvironmentResponse) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxProviderEnvironment_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) + err := c.cc.Invoke(ctx, OpenShell_GetSandboxLogs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) PushSandboxLogs(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[PushSandboxLogsRequest, PushSandboxLogsResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[3], OpenShell_PushSandboxLogs_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[PushSandboxLogsRequest, PushSandboxLogsResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_PushSandboxLogsClient = grpc.ClientStreamingClient[PushSandboxLogsRequest, PushSandboxLogsResponse] + +func (c *openShellClient) ConnectSupervisor(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[4], OpenShell_ConnectSupervisor_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[SupervisorMessage, GatewayMessage]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ConnectSupervisorClient = grpc.BidiStreamingClient[SupervisorMessage, GatewayMessage] + +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...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[RelayFrame, RelayFrame]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_RelayStreamClient = grpc.BidiStreamingClient[RelayFrame, RelayFrame] + +func (c *openShellClient) WatchSandbox(ctx context.Context, in *WatchSandboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SandboxStreamEvent], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &OpenShell_ServiceDesc.Streams[6], OpenShell_WatchSandbox_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[WatchSandboxRequest, SandboxStreamEvent]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_WatchSandboxClient = grpc.ServerStreamingClient[SandboxStreamEvent] + +func (c *openShellClient) SubmitPolicyAnalysis(ctx context.Context, in *SubmitPolicyAnalysisRequest, opts ...grpc.CallOption) (*SubmitPolicyAnalysisResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SubmitPolicyAnalysisResponse) + err := c.cc.Invoke(ctx, OpenShell_SubmitPolicyAnalysis_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetDraftPolicy(ctx context.Context, in *GetDraftPolicyRequest, opts ...grpc.CallOption) (*GetDraftPolicyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetDraftPolicyResponse) + err := c.cc.Invoke(ctx, OpenShell_GetDraftPolicy_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ApproveDraftChunk(ctx context.Context, in *ApproveDraftChunkRequest, opts ...grpc.CallOption) (*ApproveDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApproveDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_ApproveDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RejectDraftChunk(ctx context.Context, in *RejectDraftChunkRequest, opts ...grpc.CallOption) (*RejectDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RejectDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_RejectDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ApproveAllDraftChunks(ctx context.Context, in *ApproveAllDraftChunksRequest, opts ...grpc.CallOption) (*ApproveAllDraftChunksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApproveAllDraftChunksResponse) + err := c.cc.Invoke(ctx, OpenShell_ApproveAllDraftChunks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) EditDraftChunk(ctx context.Context, in *EditDraftChunkRequest, opts ...grpc.CallOption) (*EditDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EditDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_EditDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) UndoDraftChunk(ctx context.Context, in *UndoDraftChunkRequest, opts ...grpc.CallOption) (*UndoDraftChunkResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UndoDraftChunkResponse) + err := c.cc.Invoke(ctx, OpenShell_UndoDraftChunk_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ClearDraftChunks(ctx context.Context, in *ClearDraftChunksRequest, opts ...grpc.CallOption) (*ClearDraftChunksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClearDraftChunksResponse) + err := c.cc.Invoke(ctx, OpenShell_ClearDraftChunks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) GetDraftHistory(ctx context.Context, in *GetDraftHistoryRequest, opts ...grpc.CallOption) (*GetDraftHistoryResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetDraftHistoryResponse) + err := c.cc.Invoke(ctx, OpenShell_GetDraftHistory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) IssueSandboxToken(ctx context.Context, in *IssueSandboxTokenRequest, opts ...grpc.CallOption) (*IssueSandboxTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(IssueSandboxTokenResponse) + err := c.cc.Invoke(ctx, OpenShell_IssueSandboxToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RefreshSandboxToken(ctx context.Context, in *RefreshSandboxTokenRequest, opts ...grpc.CallOption) (*RefreshSandboxTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RefreshSandboxTokenResponse) + err := c.cc.Invoke(ctx, OpenShell_RefreshSandboxToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// OpenShellServer is the server API for OpenShell service. +// All implementations must embed UnimplementedOpenShellServer +// for forward compatibility. +// +// OpenShell service provides sandbox, provider, and runtime management capabilities. +// +// Conventions: +// - This file owns the public API resource model exposed to OpenShell clients. +// - `Sandbox`, `SandboxSpec`, `SandboxStatus`, and `SandboxPhase` are gateway-owned +// public types. Internal compute drivers must not import or return them directly. +// - The gateway translates internal compute-driver observations into these public +// resource messages before persisting or returning them to clients. +type OpenShellServer interface { + // Check the health of the service. + Health(context.Context, *HealthRequest) (*HealthResponse, error) + // Create a new sandbox. + CreateSandbox(context.Context, *CreateSandboxRequest) (*SandboxResponse, error) + // Fetch a sandbox by name. + GetSandbox(context.Context, *GetSandboxRequest) (*SandboxResponse, error) + // List sandboxes. + ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) + // List provider records attached to a sandbox. + ListSandboxProviders(context.Context, *ListSandboxProvidersRequest) (*ListSandboxProvidersResponse, error) + // Attach a provider record to an existing sandbox. + AttachSandboxProvider(context.Context, *AttachSandboxProviderRequest) (*AttachSandboxProviderResponse, error) + // Detach a provider record from an existing sandbox. + DetachSandboxProvider(context.Context, *DetachSandboxProviderRequest) (*DetachSandboxProviderResponse, error) + // Delete a sandbox by name. + DeleteSandbox(context.Context, *DeleteSandboxRequest) (*DeleteSandboxResponse, error) + // Create a short-lived SSH session for a sandbox. + CreateSshSession(context.Context, *CreateSshSessionRequest) (*CreateSshSessionResponse, error) + // Create or update a sandbox HTTP service endpoint for local routing. + ExposeService(context.Context, *ExposeServiceRequest) (*ServiceEndpointResponse, error) + // Fetch one sandbox HTTP service endpoint. + GetService(context.Context, *GetServiceRequest) (*ServiceEndpointResponse, error) + // List sandbox HTTP service endpoints. + ListServices(context.Context, *ListServicesRequest) (*ListServicesResponse, error) + // Delete one sandbox HTTP service endpoint. + DeleteService(context.Context, *DeleteServiceRequest) (*DeleteServiceResponse, error) + // Revoke a previously issued SSH session. + RevokeSshSession(context.Context, *RevokeSshSessionRequest) (*RevokeSshSessionResponse, error) + // Execute a command in a ready sandbox and stream output. + ExecSandbox(*ExecSandboxRequest, grpc.ServerStreamingServer[ExecSandboxEvent]) error + // Forward one CLI-side TCP connection to a loopback TCP target in a sandbox. + ForwardTcp(grpc.BidiStreamingServer[TcpForwardFrame, TcpForwardFrame]) error + // Execute an interactive command with bidirectional stdin/stdout streaming. + // The first client message MUST carry an ExecSandboxInput with the start + // variant. Subsequent messages carry stdin bytes or window resize events. + ExecSandboxInteractive(grpc.BidiStreamingServer[ExecSandboxInput, ExecSandboxEvent]) error + // Create a provider. + CreateProvider(context.Context, *CreateProviderRequest) (*ProviderResponse, error) + // Fetch a provider by name. + GetProvider(context.Context, *GetProviderRequest) (*ProviderResponse, error) + // List providers. + ListProviders(context.Context, *ListProvidersRequest) (*ListProvidersResponse, error) + // List available provider type profiles. + ListProviderProfiles(context.Context, *ListProviderProfilesRequest) (*ListProviderProfilesResponse, error) + // Fetch one provider type profile by id. + GetProviderProfile(context.Context, *GetProviderProfileRequest) (*ProviderProfileResponse, error) + // Import custom provider type profiles. + ImportProviderProfiles(context.Context, *ImportProviderProfilesRequest) (*ImportProviderProfilesResponse, error) + // Update an existing custom provider type profile. + UpdateProviderProfiles(context.Context, *UpdateProviderProfilesRequest) (*UpdateProviderProfilesResponse, error) + // Validate provider type profiles without registering them. + LintProviderProfiles(context.Context, *LintProviderProfilesRequest) (*LintProviderProfilesResponse, error) + // Update an existing provider by name. + UpdateProvider(context.Context, *UpdateProviderRequest) (*ProviderResponse, error) + // Fetch refresh status for one provider or provider credential. + GetProviderRefreshStatus(context.Context, *GetProviderRefreshStatusRequest) (*GetProviderRefreshStatusResponse, error) + // Configure gateway-owned refresh material for one provider credential. + ConfigureProviderRefresh(context.Context, *ConfigureProviderRefreshRequest) (*ConfigureProviderRefreshResponse, error) + // Record a gateway-owned refresh request for one provider credential. + RotateProviderCredential(context.Context, *RotateProviderCredentialRequest) (*RotateProviderCredentialResponse, error) + // Delete gateway-owned refresh configuration for one provider credential. + DeleteProviderRefresh(context.Context, *DeleteProviderRefreshRequest) (*DeleteProviderRefreshResponse, error) + // Delete a provider by name. + DeleteProvider(context.Context, *DeleteProviderRequest) (*DeleteProviderResponse, error) + // Delete a custom provider type profile by id. + 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. + GetGatewayConfig(context.Context, *sandboxv1.GetGatewayConfigRequest) (*sandboxv1.GetGatewayConfigResponse, error) + // Update settings or policy at sandbox or global scope. + UpdateConfig(context.Context, *UpdateConfigRequest) (*UpdateConfigResponse, error) + // Get the load status of a specific policy version. + GetSandboxPolicyStatus(context.Context, *GetSandboxPolicyStatusRequest) (*GetSandboxPolicyStatusResponse, error) + // List policy history for a sandbox. + ListSandboxPolicies(context.Context, *ListSandboxPoliciesRequest) (*ListSandboxPoliciesResponse, error) + // Report policy load result (called by sandbox after reload attempt). + ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) + // Get provider environment for a sandbox (called by sandbox supervisor at startup). + GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) + // Fetch recent sandbox logs (one-shot). + GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) + // Push sandbox supervisor logs to the server (client-streaming). + PushSandboxLogs(grpc.ClientStreamingServer[PushSandboxLogsRequest, PushSandboxLogsResponse]) error + // Persistent supervisor-to-gateway session (bidirectional streaming). + // + // The supervisor opens this stream at startup and keeps it alive for the + // sandbox lifetime. The gateway uses it to coordinate relay channels for + // SSH connect, ExecSandbox, and targetable sandbox services. Raw service + // bytes flow over RelayStream calls (separate HTTP/2 streams on the same + // connection), not over this stream. + ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error + // Raw byte relay between supervisor and gateway. + // + // The supervisor initiates this call after receiving a RelayOpen message + // on its ConnectSupervisor stream. The first RelayFrame carries a + // RelayInit with the channel_id to associate the new HTTP/2 stream with + // the pending relay slot on the gateway. Subsequent frames carry raw bytes in either + // direction between the gateway-side waiter (ForwardTcp / exec handler) + // and the supervisor-side target bridge. + // + // This rides the same TCP+TLS+HTTP/2 connection as ConnectSupervisor — + // no new TLS handshake, no reverse HTTP CONNECT. + RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error + // Watch a sandbox and stream updates. + // + // This stream can include: + // - Sandbox status snapshots (phase/status) + // - OpenShell server process logs correlated by sandbox_id + // - Platform events correlated to the sandbox + WatchSandbox(*WatchSandboxRequest, grpc.ServerStreamingServer[SandboxStreamEvent]) error + // Submit denial analysis results from sandbox (summaries + proposed chunks). + SubmitPolicyAnalysis(context.Context, *SubmitPolicyAnalysisRequest) (*SubmitPolicyAnalysisResponse, error) + // Get draft policy recommendations for a sandbox. + GetDraftPolicy(context.Context, *GetDraftPolicyRequest) (*GetDraftPolicyResponse, error) + // Approve a single draft policy chunk (merges into active policy). + ApproveDraftChunk(context.Context, *ApproveDraftChunkRequest) (*ApproveDraftChunkResponse, error) + // Reject a single draft policy chunk. + RejectDraftChunk(context.Context, *RejectDraftChunkRequest) (*RejectDraftChunkResponse, error) + // Approve all pending draft chunks (skips security-flagged unless forced). + ApproveAllDraftChunks(context.Context, *ApproveAllDraftChunksRequest) (*ApproveAllDraftChunksResponse, error) + // Edit a pending draft chunk in-place (e.g. narrow allowed_ips). + EditDraftChunk(context.Context, *EditDraftChunkRequest) (*EditDraftChunkResponse, error) + // Reverse an approval (remove merged rule from active policy). + UndoDraftChunk(context.Context, *UndoDraftChunkRequest) (*UndoDraftChunkResponse, error) + // Clear all pending draft chunks for a sandbox. + ClearDraftChunks(context.Context, *ClearDraftChunksRequest) (*ClearDraftChunksResponse, error) + // Get decision history for a sandbox's draft policy. + GetDraftHistory(context.Context, *GetDraftHistoryRequest) (*GetDraftHistoryResponse, error) + // Exchange a sandbox-bootstrap credential (e.g. a Kubernetes projected + // ServiceAccount token) for a gateway-minted JWT bound to the calling + // sandbox's UUID. Used by the Kubernetes driver path; singleplayer + // drivers receive the gateway JWT directly from the create-sandbox flow + // and never call this RPC. + IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) + // Renew the calling sandbox's gateway JWT. Older tokens remain valid + // until their own expiry; deployments should keep token TTLs short to + // bound replay exposure. The supervisor calls this from a background + // task at ~80% of the token's lifetime; the new token is cached in + // memory only — the on-disk bootstrap file is intentionally not + // rewritten. + RefreshSandboxToken(context.Context, *RefreshSandboxTokenRequest) (*RefreshSandboxTokenResponse, error) + mustEmbedUnimplementedOpenShellServer() +} + +// UnimplementedOpenShellServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedOpenShellServer struct{} + +func (UnimplementedOpenShellServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedOpenShellServer) CreateSandbox(context.Context, *CreateSandboxRequest) (*SandboxResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSandbox not implemented") +} +func (UnimplementedOpenShellServer) GetSandbox(context.Context, *GetSandboxRequest) (*SandboxResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandbox not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxes(context.Context, *ListSandboxesRequest) (*ListSandboxesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxes not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxProviders(context.Context, *ListSandboxProvidersRequest) (*ListSandboxProvidersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxProviders not implemented") +} +func (UnimplementedOpenShellServer) AttachSandboxProvider(context.Context, *AttachSandboxProviderRequest) (*AttachSandboxProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AttachSandboxProvider not implemented") +} +func (UnimplementedOpenShellServer) DetachSandboxProvider(context.Context, *DetachSandboxProviderRequest) (*DetachSandboxProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DetachSandboxProvider not implemented") +} +func (UnimplementedOpenShellServer) DeleteSandbox(context.Context, *DeleteSandboxRequest) (*DeleteSandboxResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteSandbox not implemented") +} +func (UnimplementedOpenShellServer) CreateSshSession(context.Context, *CreateSshSessionRequest) (*CreateSshSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSshSession not implemented") +} +func (UnimplementedOpenShellServer) ExposeService(context.Context, *ExposeServiceRequest) (*ServiceEndpointResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ExposeService not implemented") +} +func (UnimplementedOpenShellServer) GetService(context.Context, *GetServiceRequest) (*ServiceEndpointResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetService not implemented") +} +func (UnimplementedOpenShellServer) ListServices(context.Context, *ListServicesRequest) (*ListServicesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListServices not implemented") +} +func (UnimplementedOpenShellServer) DeleteService(context.Context, *DeleteServiceRequest) (*DeleteServiceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteService not implemented") +} +func (UnimplementedOpenShellServer) RevokeSshSession(context.Context, *RevokeSshSessionRequest) (*RevokeSshSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RevokeSshSession not implemented") +} +func (UnimplementedOpenShellServer) ExecSandbox(*ExecSandboxRequest, grpc.ServerStreamingServer[ExecSandboxEvent]) error { + return status.Error(codes.Unimplemented, "method ExecSandbox not implemented") +} +func (UnimplementedOpenShellServer) ForwardTcp(grpc.BidiStreamingServer[TcpForwardFrame, TcpForwardFrame]) error { + return status.Error(codes.Unimplemented, "method ForwardTcp not implemented") +} +func (UnimplementedOpenShellServer) ExecSandboxInteractive(grpc.BidiStreamingServer[ExecSandboxInput, ExecSandboxEvent]) error { + return status.Error(codes.Unimplemented, "method ExecSandboxInteractive not implemented") +} +func (UnimplementedOpenShellServer) CreateProvider(context.Context, *CreateProviderRequest) (*ProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateProvider not implemented") +} +func (UnimplementedOpenShellServer) GetProvider(context.Context, *GetProviderRequest) (*ProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProvider not implemented") +} +func (UnimplementedOpenShellServer) ListProviders(context.Context, *ListProvidersRequest) (*ListProvidersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProviders not implemented") +} +func (UnimplementedOpenShellServer) ListProviderProfiles(context.Context, *ListProviderProfilesRequest) (*ListProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) GetProviderProfile(context.Context, *GetProviderProfileRequest) (*ProviderProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProviderProfile not implemented") +} +func (UnimplementedOpenShellServer) ImportProviderProfiles(context.Context, *ImportProviderProfilesRequest) (*ImportProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ImportProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) UpdateProviderProfiles(context.Context, *UpdateProviderProfilesRequest) (*UpdateProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) LintProviderProfiles(context.Context, *LintProviderProfilesRequest) (*LintProviderProfilesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method LintProviderProfiles not implemented") +} +func (UnimplementedOpenShellServer) UpdateProvider(context.Context, *UpdateProviderRequest) (*ProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateProvider not implemented") +} +func (UnimplementedOpenShellServer) GetProviderRefreshStatus(context.Context, *GetProviderRefreshStatusRequest) (*GetProviderRefreshStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetProviderRefreshStatus not implemented") +} +func (UnimplementedOpenShellServer) ConfigureProviderRefresh(context.Context, *ConfigureProviderRefreshRequest) (*ConfigureProviderRefreshResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ConfigureProviderRefresh not implemented") +} +func (UnimplementedOpenShellServer) RotateProviderCredential(context.Context, *RotateProviderCredentialRequest) (*RotateProviderCredentialResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RotateProviderCredential not implemented") +} +func (UnimplementedOpenShellServer) DeleteProviderRefresh(context.Context, *DeleteProviderRefreshRequest) (*DeleteProviderRefreshResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProviderRefresh not implemented") +} +func (UnimplementedOpenShellServer) DeleteProvider(context.Context, *DeleteProviderRequest) (*DeleteProviderResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProvider not implemented") +} +func (UnimplementedOpenShellServer) DeleteProviderProfile(context.Context, *DeleteProviderProfileRequest) (*DeleteProviderProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteProviderProfile not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxConfig(context.Context, *sandboxv1.GetSandboxConfigRequest) (*sandboxv1.GetSandboxConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxConfig not implemented") +} +func (UnimplementedOpenShellServer) GetGatewayConfig(context.Context, *sandboxv1.GetGatewayConfigRequest) (*sandboxv1.GetGatewayConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetGatewayConfig not implemented") +} +func (UnimplementedOpenShellServer) UpdateConfig(context.Context, *UpdateConfigRequest) (*UpdateConfigResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateConfig not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxPolicyStatus(context.Context, *GetSandboxPolicyStatusRequest) (*GetSandboxPolicyStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxPolicyStatus not implemented") +} +func (UnimplementedOpenShellServer) ListSandboxPolicies(context.Context, *ListSandboxPoliciesRequest) (*ListSandboxPoliciesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSandboxPolicies not implemented") +} +func (UnimplementedOpenShellServer) ReportPolicyStatus(context.Context, *ReportPolicyStatusRequest) (*ReportPolicyStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReportPolicyStatus not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxProviderEnvironment(context.Context, *GetSandboxProviderEnvironmentRequest) (*GetSandboxProviderEnvironmentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxProviderEnvironment not implemented") +} +func (UnimplementedOpenShellServer) GetSandboxLogs(context.Context, *GetSandboxLogsRequest) (*GetSandboxLogsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSandboxLogs not implemented") +} +func (UnimplementedOpenShellServer) PushSandboxLogs(grpc.ClientStreamingServer[PushSandboxLogsRequest, PushSandboxLogsResponse]) error { + return status.Error(codes.Unimplemented, "method PushSandboxLogs not implemented") +} +func (UnimplementedOpenShellServer) ConnectSupervisor(grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage]) error { + return status.Error(codes.Unimplemented, "method ConnectSupervisor not implemented") +} +func (UnimplementedOpenShellServer) RelayStream(grpc.BidiStreamingServer[RelayFrame, RelayFrame]) error { + return status.Error(codes.Unimplemented, "method RelayStream not implemented") +} +func (UnimplementedOpenShellServer) WatchSandbox(*WatchSandboxRequest, grpc.ServerStreamingServer[SandboxStreamEvent]) error { + return status.Error(codes.Unimplemented, "method WatchSandbox not implemented") +} +func (UnimplementedOpenShellServer) SubmitPolicyAnalysis(context.Context, *SubmitPolicyAnalysisRequest) (*SubmitPolicyAnalysisResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitPolicyAnalysis not implemented") +} +func (UnimplementedOpenShellServer) GetDraftPolicy(context.Context, *GetDraftPolicyRequest) (*GetDraftPolicyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetDraftPolicy not implemented") +} +func (UnimplementedOpenShellServer) ApproveDraftChunk(context.Context, *ApproveDraftChunkRequest) (*ApproveDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApproveDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) RejectDraftChunk(context.Context, *RejectDraftChunkRequest) (*RejectDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RejectDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) ApproveAllDraftChunks(context.Context, *ApproveAllDraftChunksRequest) (*ApproveAllDraftChunksResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApproveAllDraftChunks not implemented") +} +func (UnimplementedOpenShellServer) EditDraftChunk(context.Context, *EditDraftChunkRequest) (*EditDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method EditDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) UndoDraftChunk(context.Context, *UndoDraftChunkRequest) (*UndoDraftChunkResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UndoDraftChunk not implemented") +} +func (UnimplementedOpenShellServer) ClearDraftChunks(context.Context, *ClearDraftChunksRequest) (*ClearDraftChunksResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ClearDraftChunks not implemented") +} +func (UnimplementedOpenShellServer) GetDraftHistory(context.Context, *GetDraftHistoryRequest) (*GetDraftHistoryResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetDraftHistory not implemented") +} +func (UnimplementedOpenShellServer) IssueSandboxToken(context.Context, *IssueSandboxTokenRequest) (*IssueSandboxTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IssueSandboxToken not implemented") +} +func (UnimplementedOpenShellServer) RefreshSandboxToken(context.Context, *RefreshSandboxTokenRequest) (*RefreshSandboxTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RefreshSandboxToken not implemented") +} +func (UnimplementedOpenShellServer) mustEmbedUnimplementedOpenShellServer() {} +func (UnimplementedOpenShellServer) testEmbeddedByValue() {} + +// UnsafeOpenShellServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to OpenShellServer will +// result in compilation errors. +type UnsafeOpenShellServer interface { + mustEmbedUnimplementedOpenShellServer() +} + +func RegisterOpenShellServer(s grpc.ServiceRegistrar, srv OpenShellServer) { + // If the following call panics, it indicates UnimplementedOpenShellServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&OpenShell_ServiceDesc, srv) +} + +func _OpenShell_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HealthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).Health(ctx, req.(*HealthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_CreateSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSandboxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateSandbox(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateSandbox_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateSandbox(ctx, req.(*CreateSandboxRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandbox(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandbox_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandbox(ctx, req.(*GetSandboxRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListSandboxes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSandboxesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxes(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxes_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxes(ctx, req.(*ListSandboxesRequest)) + } + 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 { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxProviders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxProviders_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxProviders(ctx, req.(*ListSandboxProvidersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_AttachSandboxProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AttachSandboxProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).AttachSandboxProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_AttachSandboxProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).AttachSandboxProvider(ctx, req.(*AttachSandboxProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DetachSandboxProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DetachSandboxProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DetachSandboxProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DetachSandboxProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DetachSandboxProvider(ctx, req.(*DetachSandboxProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteSandbox_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteSandboxRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteSandbox(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteSandbox_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteSandbox(ctx, req.(*DeleteSandboxRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_CreateSshSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateSshSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateSshSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateSshSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateSshSession(ctx, req.(*CreateSshSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ExposeService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExposeServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ExposeService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ExposeService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ExposeService(ctx, req.(*ExposeServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetService(ctx, req.(*GetServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListServices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListServicesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListServices(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListServices_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListServices(ctx, req.(*ListServicesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteService(ctx, req.(*DeleteServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RevokeSshSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeSshSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RevokeSshSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RevokeSshSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RevokeSshSession(ctx, req.(*RevokeSshSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ExecSandbox_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ExecSandboxRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OpenShellServer).ExecSandbox(m, &grpc.GenericServerStream[ExecSandboxRequest, ExecSandboxEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxServer = grpc.ServerStreamingServer[ExecSandboxEvent] + +func _OpenShell_ForwardTcp_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).ForwardTcp(&grpc.GenericServerStream[TcpForwardFrame, TcpForwardFrame]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ForwardTcpServer = grpc.BidiStreamingServer[TcpForwardFrame, TcpForwardFrame] + +func _OpenShell_ExecSandboxInteractive_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).ExecSandboxInteractive(&grpc.GenericServerStream[ExecSandboxInput, ExecSandboxEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ExecSandboxInteractiveServer = grpc.BidiStreamingServer[ExecSandboxInput, ExecSandboxEvent] + +func _OpenShell_CreateProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).CreateProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_CreateProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).CreateProvider(ctx, req.(*CreateProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetProvider(ctx, req.(*GetProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListProviders_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListProvidersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListProviders(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListProviders_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListProviders(ctx, req.(*ListProvidersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListProviderProfiles(ctx, req.(*ListProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetProviderProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetProviderProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetProviderProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetProviderProfile(ctx, req.(*GetProviderProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ImportProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ImportProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ImportProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ImportProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ImportProviderProfiles(ctx, req.(*ImportProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UpdateProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UpdateProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UpdateProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UpdateProviderProfiles(ctx, req.(*UpdateProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_LintProviderProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LintProviderProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).LintProviderProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_LintProviderProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).LintProviderProfiles(ctx, req.(*LintProviderProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UpdateProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UpdateProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UpdateProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UpdateProvider(ctx, req.(*UpdateProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetProviderRefreshStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetProviderRefreshStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetProviderRefreshStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetProviderRefreshStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetProviderRefreshStatus(ctx, req.(*GetProviderRefreshStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ConfigureProviderRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigureProviderRefreshRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ConfigureProviderRefresh(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ConfigureProviderRefresh_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ConfigureProviderRefresh(ctx, req.(*ConfigureProviderRefreshRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RotateProviderCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RotateProviderCredentialRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RotateProviderCredential(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RotateProviderCredential_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RotateProviderCredential(ctx, req.(*RotateProviderCredentialRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteProviderRefresh_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteProviderRefreshRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteProviderRefresh(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteProviderRefresh_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteProviderRefresh(ctx, req.(*DeleteProviderRefreshRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteProvider_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteProviderRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteProvider(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteProvider_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteProvider(ctx, req.(*DeleteProviderRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_DeleteProviderProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteProviderProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).DeleteProviderProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_DeleteProviderProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).DeleteProviderProfile(ctx, req.(*DeleteProviderProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(sandboxv1.GetSandboxConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxConfig(ctx, req.(*sandboxv1.GetSandboxConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetGatewayConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(sandboxv1.GetGatewayConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetGatewayConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetGatewayConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetGatewayConfig(ctx, req.(*sandboxv1.GetGatewayConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UpdateConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UpdateConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UpdateConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UpdateConfig(ctx, req.(*UpdateConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxPolicyStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxPolicyStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxPolicyStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxPolicyStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxPolicyStatus(ctx, req.(*GetSandboxPolicyStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ListSandboxPolicies_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSandboxPoliciesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ListSandboxPolicies(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ListSandboxPolicies_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ListSandboxPolicies(ctx, req.(*ListSandboxPoliciesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ReportPolicyStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportPolicyStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ReportPolicyStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ReportPolicyStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ReportPolicyStatus(ctx, req.(*ReportPolicyStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetSandboxProviderEnvironment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSandboxProviderEnvironmentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxProviderEnvironment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxProviderEnvironment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxProviderEnvironment(ctx, req.(*GetSandboxProviderEnvironmentRequest)) + } + 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 { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetSandboxLogs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetSandboxLogs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetSandboxLogs(ctx, req.(*GetSandboxLogsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_PushSandboxLogs_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).PushSandboxLogs(&grpc.GenericServerStream[PushSandboxLogsRequest, PushSandboxLogsResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_PushSandboxLogsServer = grpc.ClientStreamingServer[PushSandboxLogsRequest, PushSandboxLogsResponse] + +func _OpenShell_ConnectSupervisor_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).ConnectSupervisor(&grpc.GenericServerStream[SupervisorMessage, GatewayMessage]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_ConnectSupervisorServer = grpc.BidiStreamingServer[SupervisorMessage, GatewayMessage] + +func _OpenShell_RelayStream_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(OpenShellServer).RelayStream(&grpc.GenericServerStream[RelayFrame, RelayFrame]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_RelayStreamServer = grpc.BidiStreamingServer[RelayFrame, RelayFrame] + +func _OpenShell_WatchSandbox_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(WatchSandboxRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(OpenShellServer).WatchSandbox(m, &grpc.GenericServerStream[WatchSandboxRequest, SandboxStreamEvent]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type OpenShell_WatchSandboxServer = grpc.ServerStreamingServer[SandboxStreamEvent] + +func _OpenShell_SubmitPolicyAnalysis_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SubmitPolicyAnalysisRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).SubmitPolicyAnalysis(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_SubmitPolicyAnalysis_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).SubmitPolicyAnalysis(ctx, req.(*SubmitPolicyAnalysisRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetDraftPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetDraftPolicyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetDraftPolicy(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetDraftPolicy_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetDraftPolicy(ctx, req.(*GetDraftPolicyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ApproveDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApproveDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ApproveDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ApproveDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ApproveDraftChunk(ctx, req.(*ApproveDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RejectDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RejectDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RejectDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RejectDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RejectDraftChunk(ctx, req.(*RejectDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ApproveAllDraftChunks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApproveAllDraftChunksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ApproveAllDraftChunks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ApproveAllDraftChunks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ApproveAllDraftChunks(ctx, req.(*ApproveAllDraftChunksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_EditDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EditDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).EditDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_EditDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).EditDraftChunk(ctx, req.(*EditDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_UndoDraftChunk_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UndoDraftChunkRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).UndoDraftChunk(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_UndoDraftChunk_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).UndoDraftChunk(ctx, req.(*UndoDraftChunkRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ClearDraftChunks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClearDraftChunksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ClearDraftChunks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ClearDraftChunks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ClearDraftChunks(ctx, req.(*ClearDraftChunksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_GetDraftHistory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetDraftHistoryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).GetDraftHistory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_GetDraftHistory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).GetDraftHistory(ctx, req.(*GetDraftHistoryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_IssueSandboxToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(IssueSandboxTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).IssueSandboxToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_IssueSandboxToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).IssueSandboxToken(ctx, req.(*IssueSandboxTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RefreshSandboxToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RefreshSandboxTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RefreshSandboxToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RefreshSandboxToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RefreshSandboxToken(ctx, req.(*RefreshSandboxTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// OpenShell_ServiceDesc is the grpc.ServiceDesc for OpenShell service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var OpenShell_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "openshell.v1.OpenShell", + HandlerType: (*OpenShellServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Health", + Handler: _OpenShell_Health_Handler, + }, + { + MethodName: "CreateSandbox", + Handler: _OpenShell_CreateSandbox_Handler, + }, + { + MethodName: "GetSandbox", + Handler: _OpenShell_GetSandbox_Handler, + }, + { + MethodName: "ListSandboxes", + Handler: _OpenShell_ListSandboxes_Handler, + }, + { + MethodName: "ListSandboxProviders", + Handler: _OpenShell_ListSandboxProviders_Handler, + }, + { + MethodName: "AttachSandboxProvider", + Handler: _OpenShell_AttachSandboxProvider_Handler, + }, + { + MethodName: "DetachSandboxProvider", + Handler: _OpenShell_DetachSandboxProvider_Handler, + }, + { + MethodName: "DeleteSandbox", + Handler: _OpenShell_DeleteSandbox_Handler, + }, + { + MethodName: "CreateSshSession", + Handler: _OpenShell_CreateSshSession_Handler, + }, + { + MethodName: "ExposeService", + Handler: _OpenShell_ExposeService_Handler, + }, + { + MethodName: "GetService", + Handler: _OpenShell_GetService_Handler, + }, + { + MethodName: "ListServices", + Handler: _OpenShell_ListServices_Handler, + }, + { + MethodName: "DeleteService", + Handler: _OpenShell_DeleteService_Handler, + }, + { + MethodName: "RevokeSshSession", + Handler: _OpenShell_RevokeSshSession_Handler, + }, + { + MethodName: "CreateProvider", + Handler: _OpenShell_CreateProvider_Handler, + }, + { + MethodName: "GetProvider", + Handler: _OpenShell_GetProvider_Handler, + }, + { + MethodName: "ListProviders", + Handler: _OpenShell_ListProviders_Handler, + }, + { + MethodName: "ListProviderProfiles", + Handler: _OpenShell_ListProviderProfiles_Handler, + }, + { + MethodName: "GetProviderProfile", + Handler: _OpenShell_GetProviderProfile_Handler, + }, + { + MethodName: "ImportProviderProfiles", + Handler: _OpenShell_ImportProviderProfiles_Handler, + }, + { + MethodName: "UpdateProviderProfiles", + Handler: _OpenShell_UpdateProviderProfiles_Handler, + }, + { + MethodName: "LintProviderProfiles", + Handler: _OpenShell_LintProviderProfiles_Handler, + }, + { + MethodName: "UpdateProvider", + Handler: _OpenShell_UpdateProvider_Handler, + }, + { + MethodName: "GetProviderRefreshStatus", + Handler: _OpenShell_GetProviderRefreshStatus_Handler, + }, + { + MethodName: "ConfigureProviderRefresh", + Handler: _OpenShell_ConfigureProviderRefresh_Handler, + }, + { + MethodName: "RotateProviderCredential", + Handler: _OpenShell_RotateProviderCredential_Handler, + }, + { + MethodName: "DeleteProviderRefresh", + Handler: _OpenShell_DeleteProviderRefresh_Handler, + }, + { + MethodName: "DeleteProvider", + Handler: _OpenShell_DeleteProvider_Handler, + }, + { + MethodName: "DeleteProviderProfile", + Handler: _OpenShell_DeleteProviderProfile_Handler, + }, + { + MethodName: "GetSandboxConfig", + Handler: _OpenShell_GetSandboxConfig_Handler, + }, + { + MethodName: "GetGatewayConfig", + Handler: _OpenShell_GetGatewayConfig_Handler, + }, + { + MethodName: "UpdateConfig", + Handler: _OpenShell_UpdateConfig_Handler, + }, + { + MethodName: "GetSandboxPolicyStatus", + Handler: _OpenShell_GetSandboxPolicyStatus_Handler, + }, + { + MethodName: "ListSandboxPolicies", + Handler: _OpenShell_ListSandboxPolicies_Handler, + }, + { + MethodName: "ReportPolicyStatus", + Handler: _OpenShell_ReportPolicyStatus_Handler, + }, + { + MethodName: "GetSandboxProviderEnvironment", + Handler: _OpenShell_GetSandboxProviderEnvironment_Handler, + }, + { + MethodName: "GetSandboxLogs", + Handler: _OpenShell_GetSandboxLogs_Handler, + }, + { + MethodName: "SubmitPolicyAnalysis", + Handler: _OpenShell_SubmitPolicyAnalysis_Handler, + }, + { + MethodName: "GetDraftPolicy", + Handler: _OpenShell_GetDraftPolicy_Handler, + }, + { + MethodName: "ApproveDraftChunk", + Handler: _OpenShell_ApproveDraftChunk_Handler, + }, + { + MethodName: "RejectDraftChunk", + Handler: _OpenShell_RejectDraftChunk_Handler, + }, + { + MethodName: "ApproveAllDraftChunks", + Handler: _OpenShell_ApproveAllDraftChunks_Handler, + }, + { + MethodName: "EditDraftChunk", + Handler: _OpenShell_EditDraftChunk_Handler, + }, + { + MethodName: "UndoDraftChunk", + Handler: _OpenShell_UndoDraftChunk_Handler, + }, + { + MethodName: "ClearDraftChunks", + Handler: _OpenShell_ClearDraftChunks_Handler, + }, + { + MethodName: "GetDraftHistory", + Handler: _OpenShell_GetDraftHistory_Handler, + }, + { + MethodName: "IssueSandboxToken", + Handler: _OpenShell_IssueSandboxToken_Handler, + }, + { + MethodName: "RefreshSandboxToken", + Handler: _OpenShell_RefreshSandboxToken_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "ExecSandbox", + Handler: _OpenShell_ExecSandbox_Handler, + ServerStreams: true, + }, + { + StreamName: "ForwardTcp", + Handler: _OpenShell_ForwardTcp_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "ExecSandboxInteractive", + Handler: _OpenShell_ExecSandboxInteractive_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "PushSandboxLogs", + Handler: _OpenShell_PushSandboxLogs_Handler, + ClientStreams: true, + }, + { + StreamName: "ConnectSupervisor", + Handler: _OpenShell_ConnectSupervisor_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "RelayStream", + Handler: _OpenShell_RelayStream_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "WatchSandbox", + Handler: _OpenShell_WatchSandbox_Handler, + ServerStreams: true, + }, + }, + Metadata: "openshell.proto", +} diff --git a/sdk/go/proto/sandbox.proto b/sdk/go/proto/sandbox.proto new file mode 100644 index 0000000000..ef0b0540f7 --- /dev/null +++ b/sdk/go/proto/sandbox.proto @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.sandbox.v1; + +// Sandbox-supervisor configuration and policy messages. +// +// Conventions: +// - This file owns messages exchanged between the gateway and the sandbox +// supervisor/runtime. +// - Public sandbox resource types live in `openshell.proto`. +// - Internal compute-driver sandbox observation types live in `compute_driver.proto`. + +// Sandbox security policy configuration. +message SandboxPolicy { + // Policy version. + uint32 version = 1; + // Filesystem access policy. + FilesystemPolicy filesystem = 2; + // Landlock configuration. + LandlockPolicy landlock = 3; + // Process execution policy. + ProcessPolicy process = 4; + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + map network_policies = 5; +} + +// Filesystem access policy. +message FilesystemPolicy { + // Automatically include the workdir as read-write. + bool include_workdir = 1; + // Read-only directory allow list. + repeated string read_only = 2; + // Read-write directory allow list. + repeated string read_write = 3; +} + +// Landlock policy configuration. +message LandlockPolicy { + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + string compatibility = 1; +} + +// Process execution policy. +message ProcessPolicy { + // User name to run the sandboxed process as. + string run_as_user = 1; + // Group name to run the sandboxed process as. + string run_as_group = 2; +} + +// A named network access policy rule. +message NetworkPolicyRule { + // Human-readable name for this policy rule. + string name = 1; + // Allowed endpoint (host:port) pairs. + repeated NetworkEndpoint endpoints = 2; + // Allowed binary identities. + repeated NetworkBinary binaries = 3; +} + +// A network endpoint (host + port) with optional L7 inspection config. +message NetworkEndpoint { + // Hostname or host glob pattern. Exact match is case-insensitive. + // Glob patterns use "." as delimiter: "*.example.com" matches a single + // subdomain label, "**.example.com" matches across labels. + string host = 1; + // Single port (backwards compat). Use `ports` for multiple ports. + // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. + uint32 port = 2; + // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + string protocol = 3; + // TLS handling: "terminate" or "passthrough" (default). + string tls = 4; + // Enforcement mode: "enforce" or "audit" (default). + string enforcement = 5; + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + string access = 6; + // Explicit L7 rules (mutually exclusive with access). + repeated L7Rule rules = 7; + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + repeated string allowed_ips = 8; + // Multiple ports. When non-empty, this endpoint covers all listed ports. + // If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`. + // If both are set, `ports` takes precedence. + repeated uint32 ports = 9; + // Explicit L7 deny rules. When present, requests matching any deny rule + // are blocked even if they match an allow rule or access preset. + // Deny rules take precedence over allow rules. + repeated L7DenyRule deny_rules = 10; + // When true, percent-encoded '/' (%2F) is preserved in path segments + // rather than rejected by the L7 path canonicalizer. Required for + // upstreams like GitLab that embed %2F in namespaced resource paths. + // Defaults to false (strict). + bool allow_encoded_slash = 11; + // GraphQL persisted-query behavior for hash-only/saved-query requests: + // "deny" (default) or "allow_registered". + string persisted_queries = 12; + // Trusted GraphQL persisted-query registry keyed by hash or service-specific ID. + // Only used when persisted_queries is "allow_registered". + map graphql_persisted_queries = 13; + // Maximum GraphQL request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + uint32 graphql_max_body_bytes = 14; + // Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs. + // Example: use path "/graphql" for protocol "graphql" and "/repos/**" for + // protocol "rest" when both surfaces live under api.example.com:443. + // Empty means all paths. + string path = 15; + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside client-to-server WebSocket text messages after an allowed HTTP 101 + // upgrade. Defaults to false. + bool websocket_credential_rewrite = 16; + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside supported textual HTTP request bodies before forwarding upstream. + // Defaults to false. + bool request_body_credential_rewrite = 17; + // Internal provenance marker for policy-advisor generated endpoints. + // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless + // they are converted through an explicit user-authored policy path. + bool advisor_proposed = 18; +} + +// Trusted GraphQL operation classification. +message GraphqlOperation { + // Operation type: "query", "mutation", or "subscription". + string operation_type = 1; + // Operation name, if known. + string operation_name = 2; + // Root field names selected by the operation. + repeated string fields = 3; +} + +// An L7 deny rule that blocks specific requests. +// Mirrors L7Allow — same fields, same matching semantics, inverted effect. +// Deny rules are evaluated after allow rules and take precedence. +message L7DenyRule { + // HTTP method (REST): GET, POST, etc. or "*" for any. + string method = 1; + // URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; + // Query parameter matcher map (REST). + // Same semantics as L7Allow.query. + map query = 4; + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + string operation_type = 5; + // GraphQL operation name glob. "*" matches any operation name. + string operation_name = 6; + // GraphQL root field globs. Deny rules match when any selected root field + // matches any configured glob. + repeated string fields = 7; +} + +// An L7 policy rule (allow-only). +message L7Rule { + L7Allow allow = 1; +} + +// Allowed action definition for L7 rules. +message L7Allow { + // HTTP method (REST): GET, POST, etc. or "*" for any. + string method = 1; + // URL path glob pattern (REST): "/repos/**", "**" for any. + string path = 2; + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + string command = 3; + // Query parameter matcher map (REST). + // Key is the decoded query parameter name (case-sensitive). + // Value supports either a single glob (`glob`) or a list (`any`). + map query = 4; + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + string operation_type = 5; + // GraphQL operation name glob. "*" matches any operation name. + string operation_name = 6; + // GraphQL root field globs. Allow rules match only when every selected root + // field matches one of the configured globs. Omit to match all fields. + repeated string fields = 7; +} + +// Query value matcher for one query parameter key. +message L7QueryMatcher { + // Single glob pattern. + string glob = 1; + // Any-of glob patterns. + repeated string any = 2; +} + +// A binary identity for network policy matching. +message NetworkBinary { + string path = 1; + // Deprecated: the harness concept has been removed. This field is ignored. + bool harness = 2 [deprecated = true]; +} + +// Request to get sandbox settings by sandbox ID. +message GetSandboxConfigRequest { + // The sandbox ID. + string sandbox_id = 1; +} + +// Request to get gateway-global settings. +message GetGatewayConfigRequest {} + +// Response containing gateway-global settings. +message GetGatewayConfigResponse { + // Gateway-global settings map excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty SettingValue. + map settings = 1; + // Monotonically increasing revision for gateway-global settings. + uint64 settings_revision = 2; +} + +// Scope that currently controls a setting. +enum SettingScope { + SETTING_SCOPE_UNSPECIFIED = 0; + SETTING_SCOPE_SANDBOX = 1; + SETTING_SCOPE_GLOBAL = 2; +} + +// Type-aware setting value for sandbox/gateway settings. +message SettingValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + bytes bytes_value = 4; + } +} + +// Effective setting value and the scope it was resolved from. +message EffectiveSetting { + SettingValue value = 1; + SettingScope scope = 2; +} + +// Source used for the policy payload in GetSandboxConfigResponse. +enum PolicySource { + POLICY_SOURCE_UNSPECIFIED = 0; + POLICY_SOURCE_SANDBOX = 1; + POLICY_SOURCE_GLOBAL = 2; +} + +// Response containing effective sandbox settings and policy. +message GetSandboxConfigResponse { + // The sandbox policy configuration. + SandboxPolicy policy = 1; + // Current policy version (monotonically increasing per sandbox). + uint32 version = 2; + // SHA-256 hash of the serialized policy payload. + string policy_hash = 3; + // Effective settings resolved for this sandbox, excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty EffectiveSetting.value. + map settings = 4; + // Fingerprint for effective config (policy + settings). Changes when any effective input changes. + uint64 config_revision = 5; + // Source of the policy payload for this response. + PolicySource policy_source = 6; + // When policy_source is GLOBAL, the version of the global policy revision. + // Zero when no global policy is active or when policy_source is SANDBOX. + uint32 global_policy_version = 7; + // Fingerprint for provider credential inputs attached to this sandbox. + // Changes when attached provider names or attached provider records change. + uint64 provider_env_revision = 8; +} diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go new file mode 100644 index 0000000000..405f8c83fa --- /dev/null +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -0,0 +1,1753 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.6 +// source: sandbox.proto + +package sandboxv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Scope that currently controls a setting. +type SettingScope int32 + +const ( + SettingScope_SETTING_SCOPE_UNSPECIFIED SettingScope = 0 + SettingScope_SETTING_SCOPE_SANDBOX SettingScope = 1 + SettingScope_SETTING_SCOPE_GLOBAL SettingScope = 2 +) + +// Enum value maps for SettingScope. +var ( + SettingScope_name = map[int32]string{ + 0: "SETTING_SCOPE_UNSPECIFIED", + 1: "SETTING_SCOPE_SANDBOX", + 2: "SETTING_SCOPE_GLOBAL", + } + SettingScope_value = map[string]int32{ + "SETTING_SCOPE_UNSPECIFIED": 0, + "SETTING_SCOPE_SANDBOX": 1, + "SETTING_SCOPE_GLOBAL": 2, + } +) + +func (x SettingScope) Enum() *SettingScope { + p := new(SettingScope) + *p = x + return p +} + +func (x SettingScope) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (SettingScope) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[0].Descriptor() +} + +func (SettingScope) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[0] +} + +func (x SettingScope) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use SettingScope.Descriptor instead. +func (SettingScope) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + +// Source used for the policy payload in GetSandboxConfigResponse. +type PolicySource int32 + +const ( + PolicySource_POLICY_SOURCE_UNSPECIFIED PolicySource = 0 + PolicySource_POLICY_SOURCE_SANDBOX PolicySource = 1 + PolicySource_POLICY_SOURCE_GLOBAL PolicySource = 2 +) + +// Enum value maps for PolicySource. +var ( + PolicySource_name = map[int32]string{ + 0: "POLICY_SOURCE_UNSPECIFIED", + 1: "POLICY_SOURCE_SANDBOX", + 2: "POLICY_SOURCE_GLOBAL", + } + PolicySource_value = map[string]int32{ + "POLICY_SOURCE_UNSPECIFIED": 0, + "POLICY_SOURCE_SANDBOX": 1, + "POLICY_SOURCE_GLOBAL": 2, + } +) + +func (x PolicySource) Enum() *PolicySource { + p := new(PolicySource) + *p = x + return p +} + +func (x PolicySource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PolicySource) Descriptor() protoreflect.EnumDescriptor { + return file_sandbox_proto_enumTypes[1].Descriptor() +} + +func (PolicySource) Type() protoreflect.EnumType { + return &file_sandbox_proto_enumTypes[1] +} + +func (x PolicySource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PolicySource.Descriptor instead. +func (PolicySource) EnumDescriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{1} +} + +// Sandbox security policy configuration. +type SandboxPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Policy version. + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + // Filesystem access policy. + Filesystem *FilesystemPolicy `protobuf:"bytes,2,opt,name=filesystem,proto3" json:"filesystem,omitempty"` + // Landlock configuration. + Landlock *LandlockPolicy `protobuf:"bytes,3,opt,name=landlock,proto3" json:"landlock,omitempty"` + // Process execution policy. + Process *ProcessPolicy `protobuf:"bytes,4,opt,name=process,proto3" json:"process,omitempty"` + // Network access policies keyed by name (e.g. "claude_code", "gitlab"). + NetworkPolicies map[string]*NetworkPolicyRule `protobuf:"bytes,5,rep,name=network_policies,json=networkPolicies,proto3" json:"network_policies,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxPolicy) Reset() { + *x = SandboxPolicy{} + mi := &file_sandbox_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxPolicy) ProtoMessage() {} + +func (x *SandboxPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[0] + 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 SandboxPolicy.ProtoReflect.Descriptor instead. +func (*SandboxPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{0} +} + +func (x *SandboxPolicy) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *SandboxPolicy) GetFilesystem() *FilesystemPolicy { + if x != nil { + return x.Filesystem + } + return nil +} + +func (x *SandboxPolicy) GetLandlock() *LandlockPolicy { + if x != nil { + return x.Landlock + } + return nil +} + +func (x *SandboxPolicy) GetProcess() *ProcessPolicy { + if x != nil { + return x.Process + } + return nil +} + +func (x *SandboxPolicy) GetNetworkPolicies() map[string]*NetworkPolicyRule { + if x != nil { + return x.NetworkPolicies + } + return nil +} + +// Filesystem access policy. +type FilesystemPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Automatically include the workdir as read-write. + IncludeWorkdir bool `protobuf:"varint,1,opt,name=include_workdir,json=includeWorkdir,proto3" json:"include_workdir,omitempty"` + // Read-only directory allow list. + ReadOnly []string `protobuf:"bytes,2,rep,name=read_only,json=readOnly,proto3" json:"read_only,omitempty"` + // Read-write directory allow list. + ReadWrite []string `protobuf:"bytes,3,rep,name=read_write,json=readWrite,proto3" json:"read_write,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemPolicy) Reset() { + *x = FilesystemPolicy{} + mi := &file_sandbox_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemPolicy) ProtoMessage() {} + +func (x *FilesystemPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[1] + 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 FilesystemPolicy.ProtoReflect.Descriptor instead. +func (*FilesystemPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{1} +} + +func (x *FilesystemPolicy) GetIncludeWorkdir() bool { + if x != nil { + return x.IncludeWorkdir + } + return false +} + +func (x *FilesystemPolicy) GetReadOnly() []string { + if x != nil { + return x.ReadOnly + } + return nil +} + +func (x *FilesystemPolicy) GetReadWrite() []string { + if x != nil { + return x.ReadWrite + } + return nil +} + +// Landlock policy configuration. +type LandlockPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Compatibility mode (e.g. "best_effort", "hard_requirement"). + Compatibility string `protobuf:"bytes,1,opt,name=compatibility,proto3" json:"compatibility,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LandlockPolicy) Reset() { + *x = LandlockPolicy{} + mi := &file_sandbox_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LandlockPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LandlockPolicy) ProtoMessage() {} + +func (x *LandlockPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[2] + 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 LandlockPolicy.ProtoReflect.Descriptor instead. +func (*LandlockPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{2} +} + +func (x *LandlockPolicy) GetCompatibility() string { + if x != nil { + return x.Compatibility + } + return "" +} + +// Process execution policy. +type ProcessPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + // User name to run the sandboxed process as. + RunAsUser string `protobuf:"bytes,1,opt,name=run_as_user,json=runAsUser,proto3" json:"run_as_user,omitempty"` + // Group name to run the sandboxed process as. + RunAsGroup string `protobuf:"bytes,2,opt,name=run_as_group,json=runAsGroup,proto3" json:"run_as_group,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessPolicy) Reset() { + *x = ProcessPolicy{} + mi := &file_sandbox_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessPolicy) ProtoMessage() {} + +func (x *ProcessPolicy) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[3] + 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 ProcessPolicy.ProtoReflect.Descriptor instead. +func (*ProcessPolicy) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessPolicy) GetRunAsUser() string { + if x != nil { + return x.RunAsUser + } + return "" +} + +func (x *ProcessPolicy) GetRunAsGroup() string { + if x != nil { + return x.RunAsGroup + } + return "" +} + +// A named network access policy rule. +type NetworkPolicyRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Human-readable name for this policy rule. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Allowed endpoint (host:port) pairs. + Endpoints []*NetworkEndpoint `protobuf:"bytes,2,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + // Allowed binary identities. + Binaries []*NetworkBinary `protobuf:"bytes,3,rep,name=binaries,proto3" json:"binaries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkPolicyRule) Reset() { + *x = NetworkPolicyRule{} + mi := &file_sandbox_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkPolicyRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkPolicyRule) ProtoMessage() {} + +func (x *NetworkPolicyRule) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[4] + 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 NetworkPolicyRule.ProtoReflect.Descriptor instead. +func (*NetworkPolicyRule) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{4} +} + +func (x *NetworkPolicyRule) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *NetworkPolicyRule) GetEndpoints() []*NetworkEndpoint { + if x != nil { + return x.Endpoints + } + return nil +} + +func (x *NetworkPolicyRule) GetBinaries() []*NetworkBinary { + if x != nil { + return x.Binaries + } + return nil +} + +// A network endpoint (host + port) with optional L7 inspection config. +type NetworkEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Hostname or host glob pattern. Exact match is case-insensitive. + // Glob patterns use "." as delimiter: "*.example.com" matches a single + // subdomain label, "**.example.com" matches across labels. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // Single port (backwards compat). Use `ports` for multiple ports. + // Mutually exclusive with `ports` — if both are set, `ports` takes precedence. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + // Application protocol for L7 inspection: "rest", "websocket", "graphql", "sql", or "" (L4-only). + Protocol string `protobuf:"bytes,3,opt,name=protocol,proto3" json:"protocol,omitempty"` + // TLS handling: "terminate" or "passthrough" (default). + Tls string `protobuf:"bytes,4,opt,name=tls,proto3" json:"tls,omitempty"` + // Enforcement mode: "enforce" or "audit" (default). + Enforcement string `protobuf:"bytes,5,opt,name=enforcement,proto3" json:"enforcement,omitempty"` + // Access preset shorthand: "read-only", "read-write", "full". + // Mutually exclusive with rules. + Access string `protobuf:"bytes,6,opt,name=access,proto3" json:"access,omitempty"` + // Explicit L7 rules (mutually exclusive with access). + Rules []*L7Rule `protobuf:"bytes,7,rep,name=rules,proto3" json:"rules,omitempty"` + // Allowed resolved IP addresses or CIDR ranges for this endpoint. + // When non-empty, the SSRF internal-IP check is replaced by an allowlist check: + // - If host is also set: domain must resolve to an IP in this list. + // - If host is empty: any domain is allowed as long as it resolves to an IP in this list. + // + // Supports exact IPs ("10.0.5.20") and CIDR notation ("10.0.5.0/24"). + // Loopback (127.0.0.0/8) and link-local (169.254.0.0/16) are always blocked + // regardless of this field. + AllowedIps []string `protobuf:"bytes,8,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"` + // Multiple ports. When non-empty, this endpoint covers all listed ports. + // If `port` is set and `ports` is empty, `port` is normalized to `ports: [port]`. + // If both are set, `ports` takes precedence. + Ports []uint32 `protobuf:"varint,9,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Explicit L7 deny rules. When present, requests matching any deny rule + // are blocked even if they match an allow rule or access preset. + // Deny rules take precedence over allow rules. + DenyRules []*L7DenyRule `protobuf:"bytes,10,rep,name=deny_rules,json=denyRules,proto3" json:"deny_rules,omitempty"` + // When true, percent-encoded '/' (%2F) is preserved in path segments + // rather than rejected by the L7 path canonicalizer. Required for + // upstreams like GitLab that embed %2F in namespaced resource paths. + // Defaults to false (strict). + AllowEncodedSlash bool `protobuf:"varint,11,opt,name=allow_encoded_slash,json=allowEncodedSlash,proto3" json:"allow_encoded_slash,omitempty"` + // GraphQL persisted-query behavior for hash-only/saved-query requests: + // "deny" (default) or "allow_registered". + PersistedQueries string `protobuf:"bytes,12,opt,name=persisted_queries,json=persistedQueries,proto3" json:"persisted_queries,omitempty"` + // Trusted GraphQL persisted-query registry keyed by hash or service-specific ID. + // Only used when persisted_queries is "allow_registered". + GraphqlPersistedQueries map[string]*GraphqlOperation `protobuf:"bytes,13,rep,name=graphql_persisted_queries,json=graphqlPersistedQueries,proto3" json:"graphql_persisted_queries,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Maximum GraphQL request body bytes to buffer for inspection. + // Defaults to 65536 when unset. + GraphqlMaxBodyBytes uint32 `protobuf:"varint,14,opt,name=graphql_max_body_bytes,json=graphqlMaxBodyBytes,proto3" json:"graphql_max_body_bytes,omitempty"` + // Optional HTTP path glob that scopes this L7 endpoint on shared host:port APIs. + // Example: use path "/graphql" for protocol "graphql" and "/repos/**" for + // protocol "rest" when both surfaces live under api.example.com:443. + // Empty means all paths. + Path string `protobuf:"bytes,15,opt,name=path,proto3" json:"path,omitempty"` + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside client-to-server WebSocket text messages after an allowed HTTP 101 + // upgrade. Defaults to false. + WebsocketCredentialRewrite bool `protobuf:"varint,16,opt,name=websocket_credential_rewrite,json=websocketCredentialRewrite,proto3" json:"websocket_credential_rewrite,omitempty"` + // When true on a "rest" endpoint, OpenShell rewrites credential placeholders + // inside supported textual HTTP request bodies before forwarding upstream. + // Defaults to false. + RequestBodyCredentialRewrite bool `protobuf:"varint,17,opt,name=request_body_credential_rewrite,json=requestBodyCredentialRewrite,proto3" json:"request_body_credential_rewrite,omitempty"` + // Internal provenance marker for policy-advisor generated endpoints. + // Advisor-proposed endpoints must not satisfy exact-host SSRF trust unless + // they are converted through an explicit user-authored policy path. + AdvisorProposed bool `protobuf:"varint,18,opt,name=advisor_proposed,json=advisorProposed,proto3" json:"advisor_proposed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkEndpoint) Reset() { + *x = NetworkEndpoint{} + mi := &file_sandbox_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkEndpoint) ProtoMessage() {} + +func (x *NetworkEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[5] + 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 NetworkEndpoint.ProtoReflect.Descriptor instead. +func (*NetworkEndpoint) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{5} +} + +func (x *NetworkEndpoint) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *NetworkEndpoint) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *NetworkEndpoint) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *NetworkEndpoint) GetTls() string { + if x != nil { + return x.Tls + } + return "" +} + +func (x *NetworkEndpoint) GetEnforcement() string { + if x != nil { + return x.Enforcement + } + return "" +} + +func (x *NetworkEndpoint) GetAccess() string { + if x != nil { + return x.Access + } + return "" +} + +func (x *NetworkEndpoint) GetRules() []*L7Rule { + if x != nil { + return x.Rules + } + return nil +} + +func (x *NetworkEndpoint) GetAllowedIps() []string { + if x != nil { + return x.AllowedIps + } + return nil +} + +func (x *NetworkEndpoint) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *NetworkEndpoint) GetDenyRules() []*L7DenyRule { + if x != nil { + return x.DenyRules + } + return nil +} + +func (x *NetworkEndpoint) GetAllowEncodedSlash() bool { + if x != nil { + return x.AllowEncodedSlash + } + return false +} + +func (x *NetworkEndpoint) GetPersistedQueries() string { + if x != nil { + return x.PersistedQueries + } + return "" +} + +func (x *NetworkEndpoint) GetGraphqlPersistedQueries() map[string]*GraphqlOperation { + if x != nil { + return x.GraphqlPersistedQueries + } + return nil +} + +func (x *NetworkEndpoint) GetGraphqlMaxBodyBytes() uint32 { + if x != nil { + return x.GraphqlMaxBodyBytes + } + return 0 +} + +func (x *NetworkEndpoint) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *NetworkEndpoint) GetWebsocketCredentialRewrite() bool { + if x != nil { + return x.WebsocketCredentialRewrite + } + return false +} + +func (x *NetworkEndpoint) GetRequestBodyCredentialRewrite() bool { + if x != nil { + return x.RequestBodyCredentialRewrite + } + return false +} + +func (x *NetworkEndpoint) GetAdvisorProposed() bool { + if x != nil { + return x.AdvisorProposed + } + return false +} + +// Trusted GraphQL operation classification. +type GraphqlOperation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Operation type: "query", "mutation", or "subscription". + OperationType string `protobuf:"bytes,1,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // Operation name, if known. + OperationName string `protobuf:"bytes,2,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // Root field names selected by the operation. + Fields []string `protobuf:"bytes,3,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GraphqlOperation) Reset() { + *x = GraphqlOperation{} + mi := &file_sandbox_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GraphqlOperation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GraphqlOperation) ProtoMessage() {} + +func (x *GraphqlOperation) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[6] + 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 GraphqlOperation.ProtoReflect.Descriptor instead. +func (*GraphqlOperation) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{6} +} + +func (x *GraphqlOperation) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *GraphqlOperation) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *GraphqlOperation) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +// An L7 deny rule that blocks specific requests. +// Mirrors L7Allow — same fields, same matching semantics, inverted effect. +// Deny rules are evaluated after allow rules and take precedence. +type L7DenyRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HTTP method (REST): GET, POST, etc. or "*" for any. + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // URL path glob pattern (REST): "/repos/*/pulls/*/reviews", "**" for any. + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + // Query parameter matcher map (REST). + // Same semantics as L7Allow.query. + Query map[string]*L7QueryMatcher `protobuf:"bytes,4,rep,name=query,proto3" json:"query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // GraphQL operation name glob. "*" matches any operation name. + OperationName string `protobuf:"bytes,6,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // GraphQL root field globs. Deny rules match when any selected root field + // matches any configured glob. + Fields []string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7DenyRule) Reset() { + *x = L7DenyRule{} + mi := &file_sandbox_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7DenyRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7DenyRule) ProtoMessage() {} + +func (x *L7DenyRule) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[7] + 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 L7DenyRule.ProtoReflect.Descriptor instead. +func (*L7DenyRule) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{7} +} + +func (x *L7DenyRule) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7DenyRule) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7DenyRule) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *L7DenyRule) GetQuery() map[string]*L7QueryMatcher { + if x != nil { + return x.Query + } + return nil +} + +func (x *L7DenyRule) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *L7DenyRule) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *L7DenyRule) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +// An L7 policy rule (allow-only). +type L7Rule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allow *L7Allow `protobuf:"bytes,1,opt,name=allow,proto3" json:"allow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7Rule) Reset() { + *x = L7Rule{} + mi := &file_sandbox_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7Rule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7Rule) ProtoMessage() {} + +func (x *L7Rule) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[8] + 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 L7Rule.ProtoReflect.Descriptor instead. +func (*L7Rule) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{8} +} + +func (x *L7Rule) GetAllow() *L7Allow { + if x != nil { + return x.Allow + } + return nil +} + +// Allowed action definition for L7 rules. +type L7Allow struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HTTP method (REST): GET, POST, etc. or "*" for any. + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // URL path glob pattern (REST): "/repos/**", "**" for any. + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + // SQL command (SQL): SELECT, INSERT, etc. or "*" for any. + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + // Query parameter matcher map (REST). + // Key is the decoded query parameter name (case-sensitive). + // Value supports either a single glob (`glob`) or a list (`any`). + Query map[string]*L7QueryMatcher `protobuf:"bytes,4,rep,name=query,proto3" json:"query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // GraphQL operation type: "query", "mutation", "subscription", or "*" for any. + OperationType string `protobuf:"bytes,5,opt,name=operation_type,json=operationType,proto3" json:"operation_type,omitempty"` + // GraphQL operation name glob. "*" matches any operation name. + OperationName string `protobuf:"bytes,6,opt,name=operation_name,json=operationName,proto3" json:"operation_name,omitempty"` + // GraphQL root field globs. Allow rules match only when every selected root + // field matches one of the configured globs. Omit to match all fields. + Fields []string `protobuf:"bytes,7,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7Allow) Reset() { + *x = L7Allow{} + mi := &file_sandbox_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7Allow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7Allow) ProtoMessage() {} + +func (x *L7Allow) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[9] + 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 L7Allow.ProtoReflect.Descriptor instead. +func (*L7Allow) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{9} +} + +func (x *L7Allow) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *L7Allow) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *L7Allow) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *L7Allow) GetQuery() map[string]*L7QueryMatcher { + if x != nil { + return x.Query + } + return nil +} + +func (x *L7Allow) GetOperationType() string { + if x != nil { + return x.OperationType + } + return "" +} + +func (x *L7Allow) GetOperationName() string { + if x != nil { + return x.OperationName + } + return "" +} + +func (x *L7Allow) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + +// Query value matcher for one query parameter key. +type L7QueryMatcher struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Single glob pattern. + Glob string `protobuf:"bytes,1,opt,name=glob,proto3" json:"glob,omitempty"` + // Any-of glob patterns. + Any []string `protobuf:"bytes,2,rep,name=any,proto3" json:"any,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *L7QueryMatcher) Reset() { + *x = L7QueryMatcher{} + mi := &file_sandbox_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *L7QueryMatcher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*L7QueryMatcher) ProtoMessage() {} + +func (x *L7QueryMatcher) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[10] + 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 L7QueryMatcher.ProtoReflect.Descriptor instead. +func (*L7QueryMatcher) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{10} +} + +func (x *L7QueryMatcher) GetGlob() string { + if x != nil { + return x.Glob + } + return "" +} + +func (x *L7QueryMatcher) GetAny() []string { + if x != nil { + return x.Any + } + return nil +} + +// A binary identity for network policy matching. +type NetworkBinary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Deprecated: the harness concept has been removed. This field is ignored. + // + // Deprecated: Marked as deprecated in sandbox.proto. + Harness bool `protobuf:"varint,2,opt,name=harness,proto3" json:"harness,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkBinary) Reset() { + *x = NetworkBinary{} + mi := &file_sandbox_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkBinary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkBinary) ProtoMessage() {} + +func (x *NetworkBinary) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[11] + 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 NetworkBinary.ProtoReflect.Descriptor instead. +func (*NetworkBinary) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{11} +} + +func (x *NetworkBinary) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +// Deprecated: Marked as deprecated in sandbox.proto. +func (x *NetworkBinary) GetHarness() bool { + if x != nil { + return x.Harness + } + return false +} + +// Request to get sandbox settings by sandbox ID. +type GetSandboxConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox ID. + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxConfigRequest) Reset() { + *x = GetSandboxConfigRequest{} + mi := &file_sandbox_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxConfigRequest) ProtoMessage() {} + +func (x *GetSandboxConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[12] + 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 GetSandboxConfigRequest.ProtoReflect.Descriptor instead. +func (*GetSandboxConfigRequest) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{12} +} + +func (x *GetSandboxConfigRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +// Request to get gateway-global settings. +type GetGatewayConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetGatewayConfigRequest) Reset() { + *x = GetGatewayConfigRequest{} + mi := &file_sandbox_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetGatewayConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetGatewayConfigRequest) ProtoMessage() {} + +func (x *GetGatewayConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[13] + 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 GetGatewayConfigRequest.ProtoReflect.Descriptor instead. +func (*GetGatewayConfigRequest) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{13} +} + +// Response containing gateway-global settings. +type GetGatewayConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Gateway-global settings map excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty SettingValue. + Settings map[string]*SettingValue `protobuf:"bytes,1,rep,name=settings,proto3" json:"settings,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Monotonically increasing revision for gateway-global settings. + SettingsRevision uint64 `protobuf:"varint,2,opt,name=settings_revision,json=settingsRevision,proto3" json:"settings_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetGatewayConfigResponse) Reset() { + *x = GetGatewayConfigResponse{} + mi := &file_sandbox_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetGatewayConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetGatewayConfigResponse) ProtoMessage() {} + +func (x *GetGatewayConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[14] + 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 GetGatewayConfigResponse.ProtoReflect.Descriptor instead. +func (*GetGatewayConfigResponse) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{14} +} + +func (x *GetGatewayConfigResponse) GetSettings() map[string]*SettingValue { + if x != nil { + return x.Settings + } + return nil +} + +func (x *GetGatewayConfigResponse) GetSettingsRevision() uint64 { + if x != nil { + return x.SettingsRevision + } + return 0 +} + +// Type-aware setting value for sandbox/gateway settings. +type SettingValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *SettingValue_StringValue + // *SettingValue_BoolValue + // *SettingValue_IntValue + // *SettingValue_BytesValue + Value isSettingValue_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SettingValue) Reset() { + *x = SettingValue{} + mi := &file_sandbox_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SettingValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SettingValue) ProtoMessage() {} + +func (x *SettingValue) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[15] + 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 SettingValue.ProtoReflect.Descriptor instead. +func (*SettingValue) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{15} +} + +func (x *SettingValue) GetValue() isSettingValue_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *SettingValue) GetStringValue() string { + if x != nil { + if x, ok := x.Value.(*SettingValue_StringValue); ok { + return x.StringValue + } + } + return "" +} + +func (x *SettingValue) GetBoolValue() bool { + if x != nil { + if x, ok := x.Value.(*SettingValue_BoolValue); ok { + return x.BoolValue + } + } + return false +} + +func (x *SettingValue) GetIntValue() int64 { + if x != nil { + if x, ok := x.Value.(*SettingValue_IntValue); ok { + return x.IntValue + } + } + return 0 +} + +func (x *SettingValue) GetBytesValue() []byte { + if x != nil { + if x, ok := x.Value.(*SettingValue_BytesValue); ok { + return x.BytesValue + } + } + return nil +} + +type isSettingValue_Value interface { + isSettingValue_Value() +} + +type SettingValue_StringValue struct { + StringValue string `protobuf:"bytes,1,opt,name=string_value,json=stringValue,proto3,oneof"` +} + +type SettingValue_BoolValue struct { + BoolValue bool `protobuf:"varint,2,opt,name=bool_value,json=boolValue,proto3,oneof"` +} + +type SettingValue_IntValue struct { + IntValue int64 `protobuf:"varint,3,opt,name=int_value,json=intValue,proto3,oneof"` +} + +type SettingValue_BytesValue struct { + BytesValue []byte `protobuf:"bytes,4,opt,name=bytes_value,json=bytesValue,proto3,oneof"` +} + +func (*SettingValue_StringValue) isSettingValue_Value() {} + +func (*SettingValue_BoolValue) isSettingValue_Value() {} + +func (*SettingValue_IntValue) isSettingValue_Value() {} + +func (*SettingValue_BytesValue) isSettingValue_Value() {} + +// Effective setting value and the scope it was resolved from. +type EffectiveSetting struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value *SettingValue `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Scope SettingScope `protobuf:"varint,2,opt,name=scope,proto3,enum=openshell.sandbox.v1.SettingScope" json:"scope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EffectiveSetting) Reset() { + *x = EffectiveSetting{} + mi := &file_sandbox_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EffectiveSetting) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EffectiveSetting) ProtoMessage() {} + +func (x *EffectiveSetting) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[16] + 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 EffectiveSetting.ProtoReflect.Descriptor instead. +func (*EffectiveSetting) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{16} +} + +func (x *EffectiveSetting) GetValue() *SettingValue { + if x != nil { + return x.Value + } + return nil +} + +func (x *EffectiveSetting) GetScope() SettingScope { + if x != nil { + return x.Scope + } + return SettingScope_SETTING_SCOPE_UNSPECIFIED +} + +// Response containing effective sandbox settings and policy. +type GetSandboxConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The sandbox policy configuration. + Policy *SandboxPolicy `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` + // Current policy version (monotonically increasing per sandbox). + Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + // SHA-256 hash of the serialized policy payload. + PolicyHash string `protobuf:"bytes,3,opt,name=policy_hash,json=policyHash,proto3" json:"policy_hash,omitempty"` + // Effective settings resolved for this sandbox, excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty EffectiveSetting.value. + Settings map[string]*EffectiveSetting `protobuf:"bytes,4,rep,name=settings,proto3" json:"settings,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Fingerprint for effective config (policy + settings). Changes when any effective input changes. + ConfigRevision uint64 `protobuf:"varint,5,opt,name=config_revision,json=configRevision,proto3" json:"config_revision,omitempty"` + // Source of the policy payload for this response. + PolicySource PolicySource `protobuf:"varint,6,opt,name=policy_source,json=policySource,proto3,enum=openshell.sandbox.v1.PolicySource" json:"policy_source,omitempty"` + // When policy_source is GLOBAL, the version of the global policy revision. + // Zero when no global policy is active or when policy_source is SANDBOX. + GlobalPolicyVersion uint32 `protobuf:"varint,7,opt,name=global_policy_version,json=globalPolicyVersion,proto3" json:"global_policy_version,omitempty"` + // Fingerprint for provider credential inputs attached to this sandbox. + // Changes when attached provider names or attached provider records change. + ProviderEnvRevision uint64 `protobuf:"varint,8,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSandboxConfigResponse) Reset() { + *x = GetSandboxConfigResponse{} + mi := &file_sandbox_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSandboxConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSandboxConfigResponse) ProtoMessage() {} + +func (x *GetSandboxConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_sandbox_proto_msgTypes[17] + 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 GetSandboxConfigResponse.ProtoReflect.Descriptor instead. +func (*GetSandboxConfigResponse) Descriptor() ([]byte, []int) { + return file_sandbox_proto_rawDescGZIP(), []int{17} +} + +func (x *GetSandboxConfigResponse) GetPolicy() *SandboxPolicy { + if x != nil { + return x.Policy + } + return nil +} + +func (x *GetSandboxConfigResponse) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *GetSandboxConfigResponse) GetPolicyHash() string { + if x != nil { + return x.PolicyHash + } + return "" +} + +func (x *GetSandboxConfigResponse) GetSettings() map[string]*EffectiveSetting { + if x != nil { + return x.Settings + } + return nil +} + +func (x *GetSandboxConfigResponse) GetConfigRevision() uint64 { + if x != nil { + return x.ConfigRevision + } + return 0 +} + +func (x *GetSandboxConfigResponse) GetPolicySource() PolicySource { + if x != nil { + return x.PolicySource + } + return PolicySource_POLICY_SOURCE_UNSPECIFIED +} + +func (x *GetSandboxConfigResponse) GetGlobalPolicyVersion() uint32 { + if x != nil { + return x.GlobalPolicyVersion + } + return 0 +} + +func (x *GetSandboxConfigResponse) GetProviderEnvRevision() uint64 { + if x != nil { + return x.ProviderEnvRevision + } + return 0 +} + +var File_sandbox_proto protoreflect.FileDescriptor + +const file_sandbox_proto_rawDesc = "" + + "\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\"\xc4\x03\n" + + "\rSandboxPolicy\x12\x18\n" + + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + + "\n" + + "filesystem\x18\x02 \x01(\v2&.openshell.sandbox.v1.FilesystemPolicyR\n" + + "filesystem\x12@\n" + + "\blandlock\x18\x03 \x01(\v2$.openshell.sandbox.v1.LandlockPolicyR\blandlock\x12=\n" + + "\aprocess\x18\x04 \x01(\v2#.openshell.sandbox.v1.ProcessPolicyR\aprocess\x12c\n" + + "\x10network_policies\x18\x05 \x03(\v28.openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntryR\x0fnetworkPolicies\x1ak\n" + + "\x14NetworkPoliciesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\"w\n" + + "\x10FilesystemPolicy\x12'\n" + + "\x0finclude_workdir\x18\x01 \x01(\bR\x0eincludeWorkdir\x12\x1b\n" + + "\tread_only\x18\x02 \x03(\tR\breadOnly\x12\x1d\n" + + "\n" + + "read_write\x18\x03 \x03(\tR\treadWrite\"6\n" + + "\x0eLandlockPolicy\x12$\n" + + "\rcompatibility\x18\x01 \x01(\tR\rcompatibility\"Q\n" + + "\rProcessPolicy\x12\x1e\n" + + "\vrun_as_user\x18\x01 \x01(\tR\trunAsUser\x12 \n" + + "\frun_as_group\x18\x02 \x01(\tR\n" + + "runAsGroup\"\xad\x01\n" + + "\x11NetworkPolicyRule\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12C\n" + + "\tendpoints\x18\x02 \x03(\v2%.openshell.sandbox.v1.NetworkEndpointR\tendpoints\x12?\n" + + "\bbinaries\x18\x03 \x03(\v2#.openshell.sandbox.v1.NetworkBinaryR\bbinaries\"\x9b\a\n" + + "\x0fNetworkEndpoint\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12\x1a\n" + + "\bprotocol\x18\x03 \x01(\tR\bprotocol\x12\x10\n" + + "\x03tls\x18\x04 \x01(\tR\x03tls\x12 \n" + + "\venforcement\x18\x05 \x01(\tR\venforcement\x12\x16\n" + + "\x06access\x18\x06 \x01(\tR\x06access\x122\n" + + "\x05rules\x18\a \x03(\v2\x1c.openshell.sandbox.v1.L7RuleR\x05rules\x12\x1f\n" + + "\vallowed_ips\x18\b \x03(\tR\n" + + "allowedIps\x12\x14\n" + + "\x05ports\x18\t \x03(\rR\x05ports\x12?\n" + + "\n" + + "deny_rules\x18\n" + + " \x03(\v2 .openshell.sandbox.v1.L7DenyRuleR\tdenyRules\x12.\n" + + "\x13allow_encoded_slash\x18\v \x01(\bR\x11allowEncodedSlash\x12+\n" + + "\x11persisted_queries\x18\f \x01(\tR\x10persistedQueries\x12~\n" + + "\x19graphql_persisted_queries\x18\r \x03(\v2B.openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntryR\x17graphqlPersistedQueries\x123\n" + + "\x16graphql_max_body_bytes\x18\x0e \x01(\rR\x13graphqlMaxBodyBytes\x12\x12\n" + + "\x04path\x18\x0f \x01(\tR\x04path\x12@\n" + + "\x1cwebsocket_credential_rewrite\x18\x10 \x01(\bR\x1awebsocketCredentialRewrite\x12E\n" + + "\x1frequest_body_credential_rewrite\x18\x11 \x01(\bR\x1crequestBodyCredentialRewrite\x12)\n" + + "\x10advisor_proposed\x18\x12 \x01(\bR\x0fadvisorProposed\x1ar\n" + + "\x1cGraphqlPersistedQueriesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.GraphqlOperationR\x05value:\x028\x01\"x\n" + + "\x10GraphqlOperation\x12%\n" + + "\x0eoperation_type\x18\x01 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x02 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\x03 \x03(\tR\x06fields\"\xdb\x02\n" + + "\n" + + "L7DenyRule\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12A\n" + + "\x05query\x18\x04 \x03(\v2+.openshell.sandbox.v1.L7DenyRule.QueryEntryR\x05query\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x06 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\a \x03(\tR\x06fields\x1a^\n" + + "\n" + + "QueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12:\n" + + "\x05value\x18\x02 \x01(\v2$.openshell.sandbox.v1.L7QueryMatcherR\x05value:\x028\x01\"=\n" + + "\x06L7Rule\x123\n" + + "\x05allow\x18\x01 \x01(\v2\x1d.openshell.sandbox.v1.L7AllowR\x05allow\"\xd5\x02\n" + + "\aL7Allow\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12>\n" + + "\x05query\x18\x04 \x03(\v2(.openshell.sandbox.v1.L7Allow.QueryEntryR\x05query\x12%\n" + + "\x0eoperation_type\x18\x05 \x01(\tR\roperationType\x12%\n" + + "\x0eoperation_name\x18\x06 \x01(\tR\roperationName\x12\x16\n" + + "\x06fields\x18\a \x03(\tR\x06fields\x1a^\n" + + "\n" + + "QueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12:\n" + + "\x05value\x18\x02 \x01(\v2$.openshell.sandbox.v1.L7QueryMatcherR\x05value:\x028\x01\"6\n" + + "\x0eL7QueryMatcher\x12\x12\n" + + "\x04glob\x18\x01 \x01(\tR\x04glob\x12\x10\n" + + "\x03any\x18\x02 \x03(\tR\x03any\"A\n" + + "\rNetworkBinary\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\aharness\x18\x02 \x01(\bB\x02\x18\x01R\aharness\"8\n" + + "\x17GetSandboxConfigRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x19\n" + + "\x17GetGatewayConfigRequest\"\x82\x02\n" + + "\x18GetGatewayConfigResponse\x12X\n" + + "\bsettings\x18\x01 \x03(\v2<.openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntryR\bsettings\x12+\n" + + "\x11settings_revision\x18\x02 \x01(\x04R\x10settingsRevision\x1a_\n" + + "\rSettingsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x128\n" + + "\x05value\x18\x02 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value:\x028\x01\"\x9f\x01\n" + + "\fSettingValue\x12#\n" + + "\fstring_value\x18\x01 \x01(\tH\x00R\vstringValue\x12\x1f\n" + + "\n" + + "bool_value\x18\x02 \x01(\bH\x00R\tboolValue\x12\x1d\n" + + "\tint_value\x18\x03 \x01(\x03H\x00R\bintValue\x12!\n" + + "\vbytes_value\x18\x04 \x01(\fH\x00R\n" + + "bytesValueB\a\n" + + "\x05value\"\x86\x01\n" + + "\x10EffectiveSetting\x128\n" + + "\x05value\x18\x01 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value\x128\n" + + "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xab\x04\n" + + "\x18GetSandboxConfigResponse\x12;\n" + + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" + + "\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" + + "\vpolicy_hash\x18\x03 \x01(\tR\n" + + "policyHash\x12X\n" + + "\bsettings\x18\x04 \x03(\v2<.openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntryR\bsettings\x12'\n" + + "\x0fconfig_revision\x18\x05 \x01(\x04R\x0econfigRevision\x12G\n" + + "\rpolicy_source\x18\x06 \x01(\x0e2\".openshell.sandbox.v1.PolicySourceR\fpolicySource\x122\n" + + "\x15global_policy_version\x18\a \x01(\rR\x13globalPolicyVersion\x122\n" + + "\x15provider_env_revision\x18\b \x01(\x04R\x13providerEnvRevision\x1ac\n" + + "\rSettingsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01*b\n" + + "\fSettingScope\x12\x1d\n" + + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + + "\x14SETTING_SCOPE_GLOBAL\x10\x02*b\n" + + "\fPolicySource\x12\x1d\n" + + "\x19POLICY_SOURCE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15POLICY_SOURCE_SANDBOX\x10\x01\x12\x18\n" + + "\x14POLICY_SOURCE_GLOBAL\x10\x02b\x06proto3" + +var ( + file_sandbox_proto_rawDescOnce sync.Once + file_sandbox_proto_rawDescData []byte +) + +func file_sandbox_proto_rawDescGZIP() []byte { + file_sandbox_proto_rawDescOnce.Do(func() { + file_sandbox_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc))) + }) + return file_sandbox_proto_rawDescData +} + +var file_sandbox_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_sandbox_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_sandbox_proto_goTypes = []any{ + (SettingScope)(0), // 0: openshell.sandbox.v1.SettingScope + (PolicySource)(0), // 1: openshell.sandbox.v1.PolicySource + (*SandboxPolicy)(nil), // 2: openshell.sandbox.v1.SandboxPolicy + (*FilesystemPolicy)(nil), // 3: openshell.sandbox.v1.FilesystemPolicy + (*LandlockPolicy)(nil), // 4: openshell.sandbox.v1.LandlockPolicy + (*ProcessPolicy)(nil), // 5: openshell.sandbox.v1.ProcessPolicy + (*NetworkPolicyRule)(nil), // 6: openshell.sandbox.v1.NetworkPolicyRule + (*NetworkEndpoint)(nil), // 7: openshell.sandbox.v1.NetworkEndpoint + (*GraphqlOperation)(nil), // 8: openshell.sandbox.v1.GraphqlOperation + (*L7DenyRule)(nil), // 9: openshell.sandbox.v1.L7DenyRule + (*L7Rule)(nil), // 10: openshell.sandbox.v1.L7Rule + (*L7Allow)(nil), // 11: openshell.sandbox.v1.L7Allow + (*L7QueryMatcher)(nil), // 12: openshell.sandbox.v1.L7QueryMatcher + (*NetworkBinary)(nil), // 13: openshell.sandbox.v1.NetworkBinary + (*GetSandboxConfigRequest)(nil), // 14: openshell.sandbox.v1.GetSandboxConfigRequest + (*GetGatewayConfigRequest)(nil), // 15: openshell.sandbox.v1.GetGatewayConfigRequest + (*GetGatewayConfigResponse)(nil), // 16: openshell.sandbox.v1.GetGatewayConfigResponse + (*SettingValue)(nil), // 17: openshell.sandbox.v1.SettingValue + (*EffectiveSetting)(nil), // 18: openshell.sandbox.v1.EffectiveSetting + (*GetSandboxConfigResponse)(nil), // 19: openshell.sandbox.v1.GetSandboxConfigResponse + nil, // 20: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + nil, // 21: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + nil, // 22: openshell.sandbox.v1.L7DenyRule.QueryEntry + nil, // 23: openshell.sandbox.v1.L7Allow.QueryEntry + nil, // 24: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + nil, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry +} +var file_sandbox_proto_depIdxs = []int32{ + 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy + 4, // 1: openshell.sandbox.v1.SandboxPolicy.landlock:type_name -> openshell.sandbox.v1.LandlockPolicy + 5, // 2: openshell.sandbox.v1.SandboxPolicy.process:type_name -> openshell.sandbox.v1.ProcessPolicy + 20, // 3: openshell.sandbox.v1.SandboxPolicy.network_policies:type_name -> openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry + 7, // 4: openshell.sandbox.v1.NetworkPolicyRule.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 13, // 5: openshell.sandbox.v1.NetworkPolicyRule.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 10, // 6: openshell.sandbox.v1.NetworkEndpoint.rules:type_name -> openshell.sandbox.v1.L7Rule + 9, // 7: openshell.sandbox.v1.NetworkEndpoint.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 21, // 8: openshell.sandbox.v1.NetworkEndpoint.graphql_persisted_queries:type_name -> openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry + 22, // 9: openshell.sandbox.v1.L7DenyRule.query:type_name -> openshell.sandbox.v1.L7DenyRule.QueryEntry + 11, // 10: openshell.sandbox.v1.L7Rule.allow:type_name -> openshell.sandbox.v1.L7Allow + 23, // 11: openshell.sandbox.v1.L7Allow.query:type_name -> openshell.sandbox.v1.L7Allow.QueryEntry + 24, // 12: openshell.sandbox.v1.GetGatewayConfigResponse.settings:type_name -> openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry + 17, // 13: openshell.sandbox.v1.EffectiveSetting.value:type_name -> openshell.sandbox.v1.SettingValue + 0, // 14: openshell.sandbox.v1.EffectiveSetting.scope:type_name -> openshell.sandbox.v1.SettingScope + 2, // 15: openshell.sandbox.v1.GetSandboxConfigResponse.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 25, // 16: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry + 1, // 17: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource + 6, // 18: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 8, // 19: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 12, // 20: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 12, // 21: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 17, // 22: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 18, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 24, // [24:24] is the sub-list for method output_type + 24, // [24:24] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name +} + +func init() { file_sandbox_proto_init() } +func file_sandbox_proto_init() { + if File_sandbox_proto != nil { + return + } + file_sandbox_proto_msgTypes[15].OneofWrappers = []any{ + (*SettingValue_StringValue)(nil), + (*SettingValue_BoolValue)(nil), + (*SettingValue_IntValue)(nil), + (*SettingValue_BytesValue)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sandbox_proto_rawDesc), len(file_sandbox_proto_rawDesc)), + NumEnums: 2, + NumMessages: 24, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_sandbox_proto_goTypes, + DependencyIndexes: file_sandbox_proto_depIdxs, + EnumInfos: file_sandbox_proto_enumTypes, + MessageInfos: file_sandbox_proto_msgTypes, + }.Build() + File_sandbox_proto = out.File + file_sandbox_proto_goTypes = nil + file_sandbox_proto_depIdxs = nil +} diff --git a/tasks/go.toml b/tasks/go.toml new file mode 100644 index 0000000000..379ce8bbb6 --- /dev/null +++ b/tasks/go.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[tasks."go:proto"] +description = "Regenerate Go protobuf bindings" +dir = "sdk/go" +run = "mise run proto:gen" + +[tasks."go:proto:check"] +description = "Verify generated Go protobuf files are up to date" +dir = "sdk/go" +run = "mise run proto:check" + +[tasks."go:lint"] +description = "Run Go linter" +dir = "sdk/go" +run = "mise run lint" + +[tasks."go:build"] +description = "Build Go SDK packages" +dir = "sdk/go" +run = "mise run build" + +[tasks."go:test"] +description = "Run Go SDK tests" +dir = "sdk/go" +run = "mise run test"