From 6435850161211e755cb23c94c37fecf79e3d20e3 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:11:38 -0700 Subject: [PATCH 01/28] feat(gateway/sandbox): add global and sandbox runtime settings flow Closes #405 Refactor the sandbox policy polling channel into an effective settings response with config revision tracking, global policy source metadata, and merged global/sandbox key resolution. Add gateway-global and sandbox-scoped setting mutations with per-key mutual exclusion, global delete unlock semantics, and global policy override behavior. Extend the CLI with settings get/set/delete and --global policy flows, then document the new control-plane behavior in architecture and user docs. Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/README.md | 2 +- architecture/gateway.md | 2 +- architecture/sandbox.md | 18 +- crates/openshell-cli/src/main.rs | 203 +++++- crates/openshell-cli/src/run.rs | 255 +++++++- crates/openshell-sandbox/src/grpc_client.rs | 18 +- crates/openshell-sandbox/src/lib.rs | 56 +- crates/openshell-server/src/grpc.rs | 666 ++++++++++++++++++-- docs/sandboxes/policies.md | 25 + proto/openshell.proto | 27 +- proto/sandbox.proto | 36 ++ 11 files changed, 1193 insertions(+), 115 deletions(-) diff --git a/architecture/README.md b/architecture/README.md index d65b9b23..4a904247 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -234,7 +234,7 @@ The CLI is the primary way users interact with the platform. It provides command - **Gateway management** (`openshell gateway`): Deploy, stop, destroy, and inspect clusters. Supports both local and remote (SSH) targets. - **Sandbox management** (`openshell sandbox`): Create sandboxes (with optional file upload and provider auto-discovery), connect to sandboxes via SSH, and delete sandboxes. -- **Top-level commands**: `openshell status` (cluster health), `openshell logs` (sandbox logs), `openshell forward` (port forwarding), `openshell policy` (sandbox policy management). +- **Top-level commands**: `openshell status` (cluster health), `openshell logs` (sandbox logs), `openshell forward` (port forwarding), `openshell policy` (sandbox policy management), `openshell settings` (effective sandbox settings and global/sandbox key updates). - **Provider management** (`openshell provider`): Create, update, list, and delete external service credentials. - **Inference management** (`openshell cluster inference`): Configure cluster-level inference by specifying a provider and model. The gateway resolves endpoint and credential details from the named provider record. diff --git a/architecture/gateway.md b/architecture/gateway.md index ca541c7b..015f9fb2 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -231,7 +231,7 @@ These RPCs are called by sandbox pods at startup to bootstrap themselves. | RPC | Description | |-----|-------------| -| `GetSandboxPolicy` | Returns the `SandboxPolicy` from a sandbox's spec, looked up by sandbox ID. | +| `GetSandboxPolicy` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata, and effective settings. Global settings override sandbox-level values per key. | | `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. | #### Policy Recommendation (Network Rules) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index e5c831a8..1b4a21bf 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -347,11 +347,11 @@ sequenceDiagram The `run_policy_poll_loop()` function in `crates/openshell-sandbox/src/lib.rs` implements this loop: 1. **Connect once**: Create a `CachedOpenShellClient` that holds a persistent mTLS channel to the gateway. This avoids TLS renegotiation on every poll. -2. **Fetch initial version**: Call `poll_policy(sandbox_id)` to establish the baseline `current_version`. On failure, log a warning and retry on the next interval. +2. **Fetch initial config revision**: Call `poll_policy(sandbox_id)` to establish baseline `current_config_revision`. On failure, log a warning and retry on the next interval. 3. **Poll loop**: Sleep for the configured interval, then call `poll_policy()` again. -4. **Version comparison**: If `result.version <= current_version`, skip. The version is a monotonically increasing `u32` per sandbox. -5. **Reload attempt**: Call `opa_engine.reload_from_proto(&result.policy)`. This runs the full `from_proto()` pipeline on the new policy, then atomically swaps the inner engine. -6. **Status reporting**: On success, report `PolicyStatus::Loaded` to the gateway via `ReportPolicyStatus` RPC. On failure, report `PolicyStatus::Failed` with the error message. Status report failures are logged but do not affect the poll loop. +4. **Config comparison**: If `result.config_revision == current_config_revision`, skip. +5. **Reload attempt**: Call `opa_engine.reload_from_proto(policy)` when a policy payload is present. This runs the full `from_proto()` pipeline on the new policy, then atomically swaps the inner engine. +6. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still reload, but they do not write per-sandbox policy status history. ### `CachedOpenShellClient` @@ -365,24 +365,26 @@ pub struct CachedOpenShellClient { } pub struct PolicyPollResult { - pub policy: ProtoSandboxPolicy, + pub policy: Option, pub version: u32, pub policy_hash: String, + pub config_revision: u64, + pub policy_source: PolicySource, } ``` Methods: - **`connect(endpoint)`**: Establish an mTLS channel and return a new client. -- **`poll_policy(sandbox_id)`**: Call `GetSandboxPolicy` RPC and return a `PolicyPollResult` containing the policy, version, and hash. +- **`poll_policy(sandbox_id)`**: Call `GetSandboxPolicy` RPC and return a `PolicyPollResult` containing policy payload (optional), policy metadata, effective config revision, and policy source. - **`report_policy_status(sandbox_id, version, loaded, error_msg)`**: Call `ReportPolicyStatus` RPC with the appropriate `PolicyStatus` enum value (`Loaded` or `Failed`). - **`raw_client()`**: Return a clone of the underlying `OpenShellClient` for direct RPC calls (used by the log push task). ### Server-side policy versioning -The gateway assigns a monotonically increasing version number to each policy revision per sandbox. The `GetSandboxPolicyResponse` includes `version` and `policy_hash` fields. The `ReportPolicyStatus` RPC records which version the sandbox successfully loaded (or failed to load), enabling operators to query `GetSandboxPolicyStatus` for the current active version and load history. +The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxPolicyResponse` now also carries effective settings and a `config_revision` fingerprint that changes when effective policy/settings change (including global overrides). Proto messages involved: -- `GetSandboxPolicyResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash` +- `GetSandboxPolicyResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings`, `config_revision`, `policy_source` - `ReportPolicyStatusRequest` (`proto/openshell.proto`): `sandbox_id`, `version`, `status` (enum), `load_error` - `PolicyStatus` enum: `PENDING`, `LOADED`, `FAILED`, `SUPERSEDED` - `SandboxPolicyRevision` (`proto/openshell.proto`): Full revision metadata including `created_at_ms`, `loaded_at_ms` diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 84a323b5..7d90036d 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -164,6 +164,7 @@ const HELP_TEMPLATE: &str = "\ forward: Manage port forwarding to a sandbox logs: View sandbox logs policy: Manage sandbox policy + settings: Manage sandbox and global settings provider: Manage provider configuration \x1b[1mGATEWAY COMMANDS\x1b[0m @@ -249,9 +250,18 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m \x1b[1mEXAMPLES\x1b[0m $ openshell policy get my-sandbox $ openshell policy set my-sandbox --policy policy.yaml + $ openshell policy set --global --policy policy.yaml + $ openshell policy delete --global $ openshell policy list my-sandbox "; +const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m + $ openshell settings get my-sandbox + $ openshell settings set my-sandbox --key log_level --value debug + $ openshell settings set --global --key log_level --value warn + $ openshell settings delete --global --key log_level +"; + const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell provider create --name openai --type openai --credential OPENAI_API_KEY $ openshell provider create --name anthropic --type anthropic --from-existing @@ -397,6 +407,13 @@ enum Commands { command: Option, }, + /// Manage sandbox and gateway settings. + #[command(after_help = SETTINGS_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Settings { + #[command(subcommand)] + command: Option, + }, + /// Manage network rules for a sandbox. #[command(visible_alias = "rl", hide = true, help_template = SUBCOMMAND_HELP_TEMPLATE)] Rule { @@ -1324,7 +1341,7 @@ enum PolicyCommands { /// Update policy on a live sandbox. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Set { - /// Sandbox name (defaults to last-used sandbox). + /// Sandbox name (defaults to last-used sandbox when not using --global). #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, @@ -1332,6 +1349,14 @@ enum PolicyCommands { #[arg(long, value_hint = ValueHint::FilePath)] policy: String, + /// Apply as a gateway-global policy for all sandboxes. + #[arg(long)] + global: bool, + + /// Skip the confirmation prompt for global policy updates. + #[arg(long)] + yes: bool, + /// Wait for the sandbox to load the policy. #[arg(long)] wait: bool, @@ -1368,6 +1393,61 @@ enum PolicyCommands { #[arg(long, default_value_t = 20)] limit: u32, }, + + /// Delete the gateway-global policy lock, restoring sandbox-level policy control. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Delete the global policy setting. + #[arg(long)] + global: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum SettingsCommands { + /// Show effective settings for a sandbox. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Get { + /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] + name: Option, + }, + + /// Set a single setting key. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Set { + /// Sandbox name (defaults to last-used sandbox when not using --global). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] + name: Option, + + /// Setting key. + #[arg(long)] + key: String, + + /// Setting value (stored as string). + #[arg(long)] + value: String, + + /// Apply at gateway-global scope. + #[arg(long)] + global: bool, + + /// Skip the confirmation prompt for global setting updates. + #[arg(long)] + yes: bool, + }, + + /// Delete a single gateway-global setting key. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Setting key. + #[arg(long)] + key: String, + + /// Delete at gateway-global scope. + #[arg(long)] + global: bool, + }, } #[derive(Subcommand, Debug)] @@ -1730,12 +1810,26 @@ async fn main() -> Result<()> { PolicyCommands::Set { name, policy, + global, + yes, wait, timeout, } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls) + if global { + run::sandbox_policy_set_global( + &ctx.endpoint, + &policy, + yes, + wait, + timeout, + &tls, + ) .await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_policy_set(&ctx.endpoint, &name, &policy, wait, timeout, &tls) + .await?; + } } PolicyCommands::Get { name, rev, full } => { let name = resolve_sandbox_name(name, &ctx.name)?; @@ -1745,6 +1839,54 @@ async fn main() -> Result<()> { let name = resolve_sandbox_name(name, &ctx.name)?; run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; } + PolicyCommands::Delete { global } => { + if !global { + return Err(miette::miette!( + "sandbox policy delete is not supported; use --global to remove global policy lock" + )); + } + run::gateway_setting_delete(&ctx.endpoint, "policy", &tls).await?; + } + } + } + + // ----------------------------------------------------------- + // Settings commands + // ----------------------------------------------------------- + Some(Commands::Settings { + command: Some(settings_cmd), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_edge_auth(&mut tls, &ctx.name); + + match settings_cmd { + SettingsCommands::Get { name } => { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_settings_get(&ctx.endpoint, &name, &tls).await?; + } + SettingsCommands::Set { + name, + key, + value, + global, + yes, + } => { + if global { + run::gateway_setting_set(&ctx.endpoint, &key, &value, yes, &tls).await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?; + } + } + SettingsCommands::Delete { key, global } => { + if !global { + return Err(miette::miette!( + "sandbox settings cannot be deleted; use --global" + )); + } + run::gateway_setting_delete(&ctx.endpoint, &key, &tls).await?; + } } } @@ -2229,6 +2371,13 @@ async fn main() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Settings { command: None }) => { + Cli::command() + .find_subcommand_mut("settings") + .expect("settings subcommand exists") + .print_help() + .expect("Failed to print help"); + } Some(Commands::Provider { command: None }) => { Cli::command() .find_subcommand_mut("provider") @@ -2803,4 +2952,52 @@ mod tests { other => panic!("expected SshProxy, got: {other:?}"), } } + + #[test] + fn settings_set_global_parses_yes_flag() { + let cli = Cli::try_parse_from([ + "openshell", + "settings", + "set", + "--global", + "--key", + "log_level", + "--value", + "warn", + "--yes", + ]) + .expect("settings set --global should parse"); + + match cli.command { + Some(Commands::Settings { + command: + Some(SettingsCommands::Set { + global, + yes, + key, + value, + .. + }), + }) => { + assert!(global); + assert!(yes); + assert_eq!(key, "log_level"); + assert_eq!(value, "warn"); + } + other => panic!("expected settings set command, got: {other:?}"), + } + } + + #[test] + fn policy_delete_global_parses() { + let cli = Cli::try_parse_from(["openshell", "policy", "delete", "--global"]) + .expect("policy delete --global should parse"); + + match cli.command { + Some(Commands::Policy { + command: Some(PolicyCommands::Delete { global }), + }) => assert!(global), + other => panic!("expected policy delete command, got: {other:?}"), + } + } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 2f9dd2f7..480c1fa5 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -25,11 +25,12 @@ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetProviderRequest, - GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, - ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, - RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, - SetClusterInferenceRequest, UpdateProviderRequest, UpdateSandboxPolicyRequest, - WatchSandboxRequest, + GetSandboxLogsRequest, GetSandboxPolicyRequest, GetSandboxPolicyStatusRequest, + GetSandboxRequest, HealthRequest, ListProvidersRequest, ListSandboxPoliciesRequest, + ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, + SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, + SettingValue, UpdateProviderRequest, UpdateSandboxPolicyRequest, WatchSandboxRequest, + setting_value, }; use openshell_providers::{ ProviderRegistry, detect_provider_from_command, normalize_provider_type, @@ -3783,6 +3784,246 @@ fn parse_duration_to_ms(s: &str) -> Result { Ok(num * multiplier) } +fn confirm_global_setting_takeover(key: &str, yes: bool) -> Result<()> { + if yes { + return Ok(()); + } + + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(miette::miette!( + "global setting updates require confirmation; pass --yes in non-interactive mode" + )); + } + + let proceed = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!( + "Setting '{key}' globally will disable sandbox-level management for this key. Continue?" + )) + .default(false) + .interact() + .into_diagnostic()?; + + if !proceed { + return Err(miette::miette!("aborted by user")); + } + + Ok(()) +} + +fn format_setting_value(value: Option<&SettingValue>) -> String { + let Some(value) = value.and_then(|v| v.value.as_ref()) else { + return "".to_string(); + }; + match value { + setting_value::Value::StringValue(v) => v.clone(), + setting_value::Value::BoolValue(v) => v.to_string(), + setting_value::Value::IntValue(v) => v.to_string(), + setting_value::Value::BytesValue(v) => format!("", v.len()), + } +} + +pub async fn sandbox_policy_set_global( + server: &str, + policy_path: &str, + yes: bool, + wait: bool, + _timeout_secs: u64, + tls: &TlsOptions, +) -> Result<()> { + if wait { + return Err(miette::miette!( + "--wait is only supported for sandbox-scoped policy updates" + )); + } + + confirm_global_setting_takeover("policy", yes)?; + + let policy = load_sandbox_policy(Some(policy_path))? + .ok_or_else(|| miette::miette!("No policy loaded from {policy_path}"))?; + + let mut client = grpc_client(server, tls).await?; + let response = client + .update_sandbox_policy(UpdateSandboxPolicyRequest { + name: String::new(), + policy: Some(policy), + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: true, + }) + .await + .into_diagnostic()? + .into_inner(); + + eprintln!( + "{} Global policy configured (hash: {}, settings revision: {})", + "✓".green().bold(), + if response.policy_hash.len() >= 12 { + &response.policy_hash[..12] + } else { + &response.policy_hash + }, + response.settings_revision, + ); + Ok(()) +} + +pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; + + let response = client + .get_sandbox_policy(GetSandboxPolicyRequest { + sandbox_id: sandbox.id.clone(), + }) + .await + .into_diagnostic()? + .into_inner(); + + let policy_source = + if response.policy_source == openshell_core::proto::PolicySource::Global as i32 { + "global" + } else { + "sandbox" + }; + + println!("Sandbox: {}", name); + println!("Config Rev: {}", response.config_revision); + println!("Policy Source: {}", policy_source); + println!("Policy Hash: {}", response.policy_hash); + + if response.settings.is_empty() { + println!("Settings: (none)"); + return Ok(()); + } + + println!("Settings:"); + let mut keys: Vec<_> = response.settings.keys().cloned().collect(); + keys.sort(); + for key in keys { + if let Some(setting) = response.settings.get(&key) { + let scope = match SettingScope::try_from(setting.scope) { + Ok(SettingScope::Global) => "global", + Ok(SettingScope::Sandbox) => "sandbox", + _ => "unknown", + }; + println!( + " {} = {} ({})", + key, + format_setting_value(setting.value.as_ref()), + scope + ); + } + } + + Ok(()) +} + +pub async fn gateway_setting_set( + server: &str, + key: &str, + value: &str, + yes: bool, + tls: &TlsOptions, +) -> Result<()> { + confirm_global_setting_takeover(key, yes)?; + + let mut client = grpc_client(server, tls).await?; + let response = client + .update_sandbox_policy(UpdateSandboxPolicyRequest { + name: String::new(), + policy: None, + setting_key: key.to_string(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::StringValue(value.to_string())), + }), + delete_setting: false, + global: true, + }) + .await + .into_diagnostic()? + .into_inner(); + + println!( + "{} Set global setting {}={} (revision {})", + "✓".green().bold(), + key, + value, + response.settings_revision + ); + Ok(()) +} + +pub async fn sandbox_setting_set( + server: &str, + name: &str, + key: &str, + value: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .update_sandbox_policy(UpdateSandboxPolicyRequest { + name: name.to_string(), + policy: None, + setting_key: key.to_string(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::StringValue(value.to_string())), + }), + delete_setting: false, + global: false, + }) + .await + .into_diagnostic()? + .into_inner(); + + println!( + "{} Set sandbox setting {}={} for {} (revision {})", + "✓".green().bold(), + key, + value, + name, + response.settings_revision + ); + Ok(()) +} + +pub async fn gateway_setting_delete(server: &str, key: &str, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .update_sandbox_policy(UpdateSandboxPolicyRequest { + name: String::new(), + policy: None, + setting_key: key.to_string(), + setting_value: None, + delete_setting: true, + global: true, + }) + .await + .into_diagnostic()? + .into_inner(); + + if response.deleted { + println!( + "{} Deleted global setting {} (revision {})", + "✓".green().bold(), + key, + response.settings_revision + ); + } else { + println!("{} Global setting {} not found", "!".yellow(), key,); + } + Ok(()) +} + pub async fn sandbox_policy_set( server: &str, name: &str, @@ -3811,6 +4052,10 @@ pub async fn sandbox_policy_set( .update_sandbox_policy(UpdateSandboxPolicyRequest { name: name.to_string(), policy: Some(policy), + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, }) .await .into_diagnostic()?; diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index a1a0f75b..c684b928 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -10,7 +10,7 @@ use std::time::Duration; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxPolicyRequest, - GetSandboxProviderEnvironmentRequest, PolicyStatus, ReportPolicyStatusRequest, + GetSandboxProviderEnvironmentRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateSandboxPolicyRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; @@ -129,6 +129,10 @@ async fn sync_policy_with_client( .update_sandbox_policy(UpdateSandboxPolicyRequest { name: sandbox.to_string(), policy: Some(policy.clone()), + setting_key: String::new(), + setting_value: None, + delete_setting: false, + global: false, }) .await .into_diagnostic() @@ -211,9 +215,11 @@ pub struct CachedOpenShellClient { /// Policy poll result returned by [`CachedOpenShellClient::poll_policy`]. pub struct PolicyPollResult { - pub policy: ProtoSandboxPolicy, + pub policy: Option, pub version: u32, pub policy_hash: String, + pub config_revision: u64, + pub policy_source: PolicySource, } impl CachedOpenShellClient { @@ -241,14 +247,14 @@ impl CachedOpenShellClient { .into_diagnostic()?; let inner = response.into_inner(); - let policy = inner - .policy - .ok_or_else(|| miette::miette!("Server returned empty policy"))?; Ok(PolicyPollResult { - policy, + policy: inner.policy, version: inner.version, policy_hash: inner.policy_hash, + config_revision: inner.config_revision, + policy_source: PolicySource::try_from(inner.policy_source) + .unwrap_or(PolicySource::Unspecified), }) } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 754c3be0..6a40c052 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1309,15 +1309,19 @@ async fn run_policy_poll_loop( interval_secs: u64, ) -> Result<()> { use crate::grpc_client::CachedOpenShellClient; + use openshell_core::proto::PolicySource; let client = CachedOpenShellClient::connect(endpoint).await?; - let mut current_version: u32 = 0; + let mut current_config_revision: u64 = 0; - // Initialize current_version from the first poll. + // Initialize revision from the first poll. match client.poll_policy(sandbox_id).await { Ok(result) => { - current_version = result.version; - debug!(version = current_version, "Policy poll: initial version"); + current_config_revision = result.config_revision; + debug!( + config_revision = current_config_revision, + "Policy poll: initial config revision" + ); } Err(e) => { warn!(error = %e, "Policy poll: failed to fetch initial version, will retry"); @@ -1336,30 +1340,38 @@ async fn run_policy_poll_loop( } }; - if result.version <= current_version { + if result.config_revision == current_config_revision { continue; } info!( - old_version = current_version, - new_version = result.version, + old_config_revision = current_config_revision, + new_config_revision = result.config_revision, policy_hash = %result.policy_hash, - "Policy poll: new version detected, reloading" + "Policy poll: config change detected, reloading" ); - match opa_engine.reload_from_proto(&result.policy) { + let Some(policy) = result.policy.as_ref() else { + warn!("Policy poll: config changed but no policy payload present; skipping reload"); + current_config_revision = result.config_revision; + continue; + }; + + match opa_engine.reload_from_proto(policy) { Ok(()) => { - current_version = result.version; + current_config_revision = result.config_revision; info!( - version = current_version, + config_revision = current_config_revision, policy_hash = %result.policy_hash, "Policy reloaded successfully" ); - if let Err(e) = client - .report_policy_status(sandbox_id, result.version, true, "") - .await - { - warn!(error = %e, "Failed to report policy load success"); + if result.version > 0 && result.policy_source == PolicySource::Sandbox { + if let Err(e) = client + .report_policy_status(sandbox_id, result.version, true, "") + .await + { + warn!(error = %e, "Failed to report policy load success"); + } } } Err(e) => { @@ -1368,11 +1380,13 @@ async fn run_policy_poll_loop( error = %e, "Policy reload failed, keeping last-known-good policy" ); - if let Err(report_err) = client - .report_policy_status(sandbox_id, result.version, false, &e.to_string()) - .await - { - warn!(error = %report_err, "Failed to report policy load failure"); + if result.version > 0 && result.policy_source == PolicySource::Sandbox { + if let Err(report_err) = client + .report_policy_status(sandbox_id, result.version, false, &e.to_string()) + .await + { + warn!(error = %report_err, "Failed to report policy load failure"); + } } } } diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 740cdb80..932f4423 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -6,36 +6,39 @@ #![allow(clippy::ignored_unit_patterns)] // Tokio select! macro generates unit patterns use crate::persistence::{ - DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, generate_name, + DraftChunkRecord, ObjectId, ObjectName, ObjectType, PolicyRecord, Store, generate_name, }; use futures::future; +use openshell_core::proto::setting_value; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, ApproveDraftChunkResponse, ClearDraftChunksRequest, ClearDraftChunksResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, - ExecSandboxExit, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, + DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, EffectiveSetting, + ExecSandboxEvent, ExecSandboxExit, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetProviderRequest, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyRequest, GetSandboxPolicyResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxesRequest, - ListSandboxesResponse, PolicyChunk, PolicyStatus, Provider, ProviderResponse, + ListSandboxesResponse, PolicyChunk, PolicySource, PolicyStatus, Provider, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, - SandboxResponse, SandboxStreamEvent, ServiceStatus, SshSession, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse, - UpdateProviderRequest, UpdateSandboxPolicyRequest, UpdateSandboxPolicyResponse, - WatchSandboxRequest, open_shell_server::OpenShell, + SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, + SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateProviderRequest, UpdateSandboxPolicyRequest, + UpdateSandboxPolicyResponse, WatchSandboxRequest, open_shell_server::OpenShell, }; use openshell_core::proto::{ Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, }; use prost::Message; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; @@ -103,6 +106,32 @@ const MAX_PROVIDER_CREDENTIALS_ENTRIES: usize = 32; /// Maximum number of entries in the provider `config` map. const MAX_PROVIDER_CONFIG_ENTRIES: usize = 64; +/// Internal object type for durable gateway-global settings. +const GLOBAL_SETTINGS_OBJECT_TYPE: &str = "gateway_settings"; +/// Internal object id/name for the singleton global settings record. +const GLOBAL_SETTINGS_ID: &str = "global"; +const GLOBAL_SETTINGS_NAME: &str = "global"; +/// Internal object type for durable sandbox-scoped settings. +const SANDBOX_SETTINGS_OBJECT_TYPE: &str = "sandbox_settings"; +/// Reserved settings key used to store global policy payload. +const POLICY_SETTING_KEY: &str = "policy"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct StoredSettings { + revision: u64, + settings: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", content = "value")] +enum StoredSettingValue { + String(String), + Bool(bool), + Int(i64), + /// Hex-encoded binary payload. + Bytes(String), +} + /// Clamp a client-provided page `limit`. /// /// Returns `default` when `raw` is 0 (the protobuf zero-value convention), @@ -722,73 +751,103 @@ impl OpenShell for OpenShellService { .await .map_err(|e| Status::internal(format!("fetch policy history failed: {e}")))?; - if let Some(record) = latest { - let policy = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) + let mut policy_source = PolicySource::Sandbox; + let (mut policy, mut version, mut policy_hash) = if let Some(record) = latest { + let decoded = ProtoSandboxPolicy::decode(record.policy_payload.as_slice()) .map_err(|e| Status::internal(format!("decode policy failed: {e}")))?; debug!( sandbox_id = %sandbox_id, version = record.version, "GetSandboxPolicy served from policy history" ); - return Ok(Response::new(GetSandboxPolicyResponse { - policy: Some(policy), - version: u32::try_from(record.version).unwrap_or(0), - policy_hash: record.policy_hash, - })); - } + ( + Some(decoded), + u32::try_from(record.version).unwrap_or(0), + record.policy_hash, + ) + } else { + // Lazy backfill: no policy history exists yet. + let spec = sandbox + .spec + .ok_or_else(|| Status::internal("sandbox has no spec"))?; + + match spec.policy { + // If spec.policy is None, the sandbox was created without a policy. + // Return an empty policy payload so the sandbox can discover policy + // from disk or fall back to its restrictive default. + None => { + debug!( + sandbox_id = %sandbox_id, + "GetSandboxPolicy: no policy configured, returning empty response" + ); + (None, 0, String::new()) + } + Some(spec_policy) => { + let hash = deterministic_policy_hash(&spec_policy); + let payload = spec_policy.encode_to_vec(); + let policy_id = uuid::Uuid::new_v4().to_string(); + + // Best-effort backfill: if it fails (e.g., concurrent backfill race), we still + // return the policy from spec. + if let Err(e) = self + .state + .store + .put_policy_revision(&policy_id, &sandbox_id, 1, &payload, &hash) + .await + { + warn!( + sandbox_id = %sandbox_id, + error = %e, + "Failed to backfill policy version 1" + ); + } else if let Err(e) = self + .state + .store + .update_policy_status(&sandbox_id, 1, "loaded", None, None) + .await + { + warn!( + sandbox_id = %sandbox_id, + error = %e, + "Failed to mark backfilled policy as loaded" + ); + } - // Lazy backfill: no policy history exists yet. - let spec = sandbox - .spec - .ok_or_else(|| Status::internal("sandbox has no spec"))?; + info!( + sandbox_id = %sandbox_id, + "GetSandboxPolicy served from spec (backfilled version 1)" + ); - // If spec.policy is None, the sandbox was created without a policy. - // Return an empty response so the sandbox can discover policy from disk - // or fall back to its restrictive default. - let Some(policy) = spec.policy else { - debug!( - sandbox_id = %sandbox_id, - "GetSandboxPolicy: no policy configured, returning empty response" - ); - return Ok(Response::new(GetSandboxPolicyResponse { - policy: None, - version: 0, - policy_hash: String::new(), - })); + (Some(spec_policy), 1, hash) + } + } }; - // Create version 1 from spec.policy. - let payload = policy.encode_to_vec(); - let hash = deterministic_policy_hash(&policy); - let policy_id = uuid::Uuid::new_v4().to_string(); - - // Best-effort backfill: if it fails (e.g., concurrent backfill race), we still - // return the policy from spec. - if let Err(e) = self - .state - .store - .put_policy_revision(&policy_id, &sandbox_id, 1, &payload, &hash) - .await - { - warn!(sandbox_id = %sandbox_id, error = %e, "Failed to backfill policy version 1"); - } else if let Err(e) = self - .state - .store - .update_policy_status(&sandbox_id, 1, "loaded", None, None) - .await - { - warn!(sandbox_id = %sandbox_id, error = %e, "Failed to mark backfilled policy as loaded"); + let global_settings = load_global_settings(self.state.store.as_ref()).await?; + let sandbox_settings = + load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; + + if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { + policy = Some(global_policy.clone()); + policy_hash = deterministic_policy_hash(&global_policy); + policy_source = PolicySource::Global; + // Keep sandbox policy version for status APIs, but global policy + // updates are tracked via config_revision. + if version == 0 { + version = 1; + } } - info!( - sandbox_id = %sandbox_id, - "GetSandboxPolicy served from spec (backfilled version 1)" - ); + let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; + let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); Ok(Response::new(GetSandboxPolicyResponse { - policy: Some(policy), - version: 1, - policy_hash: hash, + policy, + version, + policy_hash, + settings, + config_revision, + policy_source: policy_source.into(), })) } @@ -986,12 +1045,92 @@ impl OpenShell for OpenShellService { request: Request, ) -> Result, Status> { let req = request.into_inner(); + let key = req.setting_key.trim(); + let has_policy = req.policy.is_some(); + let has_setting = !key.is_empty(); + + if has_policy && has_setting { + return Err(Status::invalid_argument( + "policy and setting_key cannot be set in the same request", + )); + } + if !has_policy && !has_setting { + return Err(Status::invalid_argument( + "either policy or setting_key must be provided", + )); + } + + if req.global { + if has_policy { + if req.delete_setting { + return Err(Status::invalid_argument( + "delete_setting cannot be combined with policy payload", + )); + } + let mut new_policy = req.policy.ok_or_else(|| { + Status::invalid_argument("policy is required for global policy update") + })?; + openshell_policy::ensure_sandbox_process_identity(&mut new_policy); + validate_policy_safety(&new_policy)?; + + let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; + let stored_value = + StoredSettingValue::Bytes(hex::encode(new_policy.encode_to_vec())); + let changed = upsert_setting_value( + &mut global_settings.settings, + POLICY_SETTING_KEY, + stored_value, + ); + if changed { + global_settings.revision = global_settings.revision.saturating_add(1); + save_global_settings(self.state.store.as_ref(), &global_settings).await?; + } + + return Ok(Response::new(UpdateSandboxPolicyResponse { + version: 0, + policy_hash: deterministic_policy_hash(&new_policy), + settings_revision: global_settings.revision, + deleted: false, + })); + } + + // Global setting mutation. + if key == POLICY_SETTING_KEY && !req.delete_setting { + return Err(Status::invalid_argument( + "reserved key 'policy' must be set via the policy field", + )); + } + + let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; + let deleted = if req.delete_setting { + global_settings.settings.remove(key).is_some() + } else { + let setting = req + .setting_value + .as_ref() + .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; + let stored = proto_setting_to_stored(setting)?; + upsert_setting_value(&mut global_settings.settings, key, stored) + }; + + if deleted { + global_settings.revision = global_settings.revision.saturating_add(1); + save_global_settings(self.state.store.as_ref(), &global_settings).await?; + } + + return Ok(Response::new(UpdateSandboxPolicyResponse { + version: 0, + policy_hash: String::new(), + settings_revision: global_settings.revision, + deleted: req.delete_setting && deleted, + })); + } + if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); + return Err(Status::invalid_argument( + "name is required for sandbox-scoped updates", + )); } - let mut new_policy = req - .policy - .ok_or_else(|| Status::invalid_argument("policy is required"))?; // Resolve sandbox by name. let sandbox = self @@ -1001,9 +1140,67 @@ impl OpenShell for OpenShellService { .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; - let sandbox_id = sandbox.id.clone(); + if has_setting { + if req.delete_setting { + return Err(Status::invalid_argument( + "sandbox-scoped setting delete is not supported; use global delete", + )); + } + if key == POLICY_SETTING_KEY { + return Err(Status::invalid_argument( + "reserved key 'policy' must be set via policy commands", + )); + } + + let global_settings = load_global_settings(self.state.store.as_ref()).await?; + if global_settings.settings.contains_key(key) { + return Err(Status::failed_precondition(format!( + "setting '{key}' is managed globally; delete the global setting before sandbox update" + ))); + } + + let setting = req + .setting_value + .as_ref() + .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; + let stored = proto_setting_to_stored(setting)?; + + let mut sandbox_settings = + load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; + let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored); + if changed { + sandbox_settings.revision = sandbox_settings.revision.saturating_add(1); + save_sandbox_settings( + self.state.store.as_ref(), + &sandbox_id, + &sandbox.name, + &sandbox_settings, + ) + .await?; + } + + return Ok(Response::new(UpdateSandboxPolicyResponse { + version: 0, + policy_hash: String::new(), + settings_revision: sandbox_settings.revision, + deleted: false, + })); + } + + // Sandbox-scoped policy update. + let mut new_policy = req + .policy + .ok_or_else(|| Status::invalid_argument("policy is required"))?; + + let global_settings = load_global_settings(self.state.store.as_ref()).await?; + if global_settings.settings.contains_key(POLICY_SETTING_KEY) { + return Err(Status::failed_precondition( + "policy is managed globally; delete global policy before sandbox policy update", + )); + } + // Get the baseline (version 1) policy for static field validation. let spec = sandbox .spec @@ -1062,6 +1259,8 @@ impl OpenShell for OpenShellService { return Ok(Response::new(UpdateSandboxPolicyResponse { version: u32::try_from(current.version).unwrap_or(0), policy_hash: hash, + settings_revision: 0, + deleted: false, })); } @@ -1094,6 +1293,8 @@ impl OpenShell for OpenShellService { Ok(Response::new(UpdateSandboxPolicyResponse { version: u32::try_from(next_version).unwrap_or(0), policy_hash: hash, + settings_revision: 0, + deleted: false, })) } @@ -2297,6 +2498,222 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hex::encode(hasher.finalize()) } +fn compute_config_revision( + policy: Option<&ProtoSandboxPolicy>, + settings: &HashMap, + policy_source: PolicySource, +) -> u64 { + let mut hasher = Sha256::new(); + hasher.update((policy_source as i32).to_le_bytes()); + if let Some(policy) = policy { + hasher.update(deterministic_policy_hash(policy).as_bytes()); + } + let mut entries: Vec<_> = settings.iter().collect(); + entries.sort_by_key(|(k, _)| k.as_str()); + for (key, setting) in entries { + hasher.update(key.as_bytes()); + hasher.update(setting.scope.to_le_bytes()); + if let Some(value) = setting.value.as_ref().and_then(|v| v.value.as_ref()) { + match value { + setting_value::Value::StringValue(v) => { + hasher.update([0]); + hasher.update(v.as_bytes()); + } + setting_value::Value::BoolValue(v) => { + hasher.update([1]); + hasher.update([u8::from(*v)]); + } + setting_value::Value::IntValue(v) => { + hasher.update([2]); + hasher.update(v.to_le_bytes()); + } + setting_value::Value::BytesValue(v) => { + hasher.update([3]); + hasher.update(v); + } + } + } + } + + let digest = hasher.finalize(); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + u64::from_le_bytes(bytes) +} + +fn proto_setting_to_stored(value: &SettingValue) -> Result { + let inner = value + .value + .as_ref() + .ok_or_else(|| Status::invalid_argument("setting_value.value is required"))?; + let stored = match inner { + setting_value::Value::StringValue(v) => StoredSettingValue::String(v.clone()), + setting_value::Value::BoolValue(v) => StoredSettingValue::Bool(*v), + setting_value::Value::IntValue(v) => StoredSettingValue::Int(*v), + setting_value::Value::BytesValue(v) => StoredSettingValue::Bytes(hex::encode(v)), + }; + Ok(stored) +} + +fn stored_setting_to_proto(value: &StoredSettingValue) -> Result { + let proto = match value { + StoredSettingValue::String(v) => SettingValue { + value: Some(setting_value::Value::StringValue(v.clone())), + }, + StoredSettingValue::Bool(v) => SettingValue { + value: Some(setting_value::Value::BoolValue(*v)), + }, + StoredSettingValue::Int(v) => SettingValue { + value: Some(setting_value::Value::IntValue(*v)), + }, + StoredSettingValue::Bytes(v) => { + let decoded = hex::decode(v) + .map_err(|e| Status::internal(format!("stored bytes decode failed: {e}")))?; + SettingValue { + value: Some(setting_value::Value::BytesValue(decoded)), + } + } + }; + Ok(proto) +} + +fn upsert_setting_value( + map: &mut BTreeMap, + key: &str, + value: StoredSettingValue, +) -> bool { + match map.get(key) { + Some(existing) if existing == &value => false, + _ => { + map.insert(key.to_string(), value); + true + } + } +} + +async fn load_global_settings(store: &Store) -> Result { + load_settings_record(store, GLOBAL_SETTINGS_OBJECT_TYPE, GLOBAL_SETTINGS_ID).await +} + +async fn save_global_settings(store: &Store, settings: &StoredSettings) -> Result<(), Status> { + save_settings_record( + store, + GLOBAL_SETTINGS_OBJECT_TYPE, + GLOBAL_SETTINGS_ID, + GLOBAL_SETTINGS_NAME, + settings, + ) + .await +} + +async fn load_sandbox_settings(store: &Store, sandbox_id: &str) -> Result { + load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, sandbox_id).await +} + +async fn save_sandbox_settings( + store: &Store, + sandbox_id: &str, + sandbox_name: &str, + settings: &StoredSettings, +) -> Result<(), Status> { + save_settings_record( + store, + SANDBOX_SETTINGS_OBJECT_TYPE, + sandbox_id, + sandbox_name, + settings, + ) + .await +} + +async fn load_settings_record( + store: &Store, + object_type: &str, + id: &str, +) -> Result { + let record = store + .get(object_type, id) + .await + .map_err(|e| Status::internal(format!("fetch settings failed: {e}")))?; + if let Some(record) = record { + serde_json::from_slice::(&record.payload) + .map_err(|e| Status::internal(format!("decode settings payload failed: {e}"))) + } else { + Ok(StoredSettings::default()) + } +} + +async fn save_settings_record( + store: &Store, + object_type: &str, + id: &str, + name: &str, + settings: &StoredSettings, +) -> Result<(), Status> { + let payload = serde_json::to_vec(settings) + .map_err(|e| Status::internal(format!("encode settings payload failed: {e}")))?; + store + .put(object_type, id, name, &payload) + .await + .map_err(|e| Status::internal(format!("persist settings failed: {e}")))?; + Ok(()) +} + +fn decode_policy_from_global_settings( + global: &StoredSettings, +) -> Result, Status> { + let Some(value) = global.settings.get(POLICY_SETTING_KEY) else { + return Ok(None); + }; + + let StoredSettingValue::Bytes(encoded) = value else { + return Err(Status::internal( + "global policy setting has invalid value type; expected bytes", + )); + }; + + let raw = hex::decode(encoded) + .map_err(|e| Status::internal(format!("global policy decode failed: {e}")))?; + let policy = ProtoSandboxPolicy::decode(raw.as_slice()) + .map_err(|e| Status::internal(format!("global policy protobuf decode failed: {e}")))?; + Ok(Some(policy)) +} + +fn merge_effective_settings( + global: &StoredSettings, + sandbox: &StoredSettings, +) -> Result, Status> { + let mut merged = HashMap::new(); + + for (key, value) in &sandbox.settings { + if key == POLICY_SETTING_KEY { + continue; + } + merged.insert( + key.clone(), + EffectiveSetting { + value: Some(stored_setting_to_proto(value)?), + scope: SettingScope::Sandbox.into(), + }, + ); + } + + for (key, value) in &global.settings { + if key == POLICY_SETTING_KEY { + continue; + } + merged.insert( + key.clone(), + EffectiveSetting { + value: Some(stored_setting_to_proto(value)?), + scope: SettingScope::Global.into(), + }, + ); + } + + Ok(merged) +} + /// Check if a log line's source matches the filter list. /// Empty source is treated as "gateway" for backward compatibility. fn source_matches(log_source: &str, filters: &[String]) -> bool { @@ -4648,4 +5065,121 @@ mod tests { assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("value")); } + + #[test] + fn merge_effective_settings_global_overrides_sandbox_key() { + let global = super::StoredSettings { + revision: 2, + settings: [ + ( + "log_level".to_string(), + super::StoredSettingValue::String("warn".to_string()), + ), + ( + "region".to_string(), + super::StoredSettingValue::String("us-west".to_string()), + ), + ] + .into_iter() + .collect(), + }; + let sandbox = super::StoredSettings { + revision: 1, + settings: [ + ( + "log_level".to_string(), + super::StoredSettingValue::String("debug".to_string()), + ), + ( + "feature_x".to_string(), + super::StoredSettingValue::Bool(true), + ), + ] + .into_iter() + .collect(), + }; + + let merged = super::merge_effective_settings(&global, &sandbox).unwrap(); + let log_level = merged.get("log_level").expect("log_level present"); + assert_eq!( + log_level.scope, + openshell_core::proto::SettingScope::Global as i32 + ); + assert_eq!( + log_level.value.as_ref().and_then(|v| v.value.as_ref()), + Some(&openshell_core::proto::setting_value::Value::StringValue( + "warn".to_string(), + )) + ); + + let feature_x = merged.get("feature_x").expect("feature_x present"); + assert_eq!( + feature_x.scope, + openshell_core::proto::SettingScope::Sandbox as i32 + ); + } + + #[test] + fn decode_policy_from_global_settings_round_trip() { + let policy = openshell_core::proto::SandboxPolicy { + version: 7, + ..Default::default() + }; + let encoded = hex::encode(policy.encode_to_vec()); + let global = super::StoredSettings { + revision: 1, + settings: [( + "policy".to_string(), + super::StoredSettingValue::Bytes(encoded), + )] + .into_iter() + .collect(), + }; + + let decoded = super::decode_policy_from_global_settings(&global) + .unwrap() + .expect("policy present"); + assert_eq!(decoded.version, 7); + } + + #[test] + fn config_revision_changes_when_effective_setting_changes() { + let policy = openshell_core::proto::SandboxPolicy::default(); + let mut settings = HashMap::new(); + settings.insert( + "mode".to_string(), + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "strict".to_string(), + )), + }), + scope: openshell_core::proto::SettingScope::Sandbox.into(), + }, + ); + + let rev_a = super::compute_config_revision( + Some(&policy), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + settings.insert( + "mode".to_string(), + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "relaxed".to_string(), + )), + }), + scope: openshell_core::proto::SettingScope::Sandbox.into(), + }, + ); + let rev_b = super::compute_config_revision( + Some(&policy), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + + assert_ne!(rev_a, rev_b); + } } diff --git a/docs/sandboxes/policies.md b/docs/sandboxes/policies.md index 3ee7b50d..191c9e79 100644 --- a/docs/sandboxes/policies.md +++ b/docs/sandboxes/policies.md @@ -147,6 +147,31 @@ The following steps outline the hot-reload policy update workflow. $ openshell policy list ``` +## Global Policy Override + +Use a global policy when you want one policy payload to apply to every sandbox. + +```console +$ openshell policy set --global --policy ./global-policy.yaml +``` + +When a global policy is configured: + +- The global payload is applied in full for all sandboxes. +- Sandbox-level policy updates are rejected until the global policy is removed. + +To restore sandbox-level policy control, delete the global policy setting: + +```console +$ openshell policy delete --global +``` + +You can inspect a sandbox's effective settings and policy source with: + +```console +$ openshell settings get +``` + ## Debug Denied Requests Check `openshell logs --tail --source sandbox` for the denied host, path, and binary. diff --git a/proto/openshell.proto b/proto/openshell.proto index ad93848d..048ce101 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -437,12 +437,27 @@ message GetSandboxProviderEnvironmentResponse { // Update sandbox policy request. message UpdateSandboxPolicyRequest { - // Sandbox name (canonical lookup key). + // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. + // Not required when `global=true`. string name = 1; - // The new policy to apply. Only network_policies and inference fields may - // differ from the create-time policy; static fields (filesystem, landlock, - // process) must match version 1 or the request is rejected. + // 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; } // Update sandbox policy response. @@ -451,6 +466,10 @@ message UpdateSandboxPolicyResponse { 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. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 01925fbe..51b6ac1e 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -115,6 +115,36 @@ message GetSandboxPolicyRequest { string sandbox_id = 1; } +// 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 GetSandboxPolicyResponse. +enum PolicySource { + POLICY_SOURCE_UNSPECIFIED = 0; + POLICY_SOURCE_SANDBOX = 1; + POLICY_SOURCE_GLOBAL = 2; +} + // Response containing sandbox policy. message GetSandboxPolicyResponse { // The sandbox policy configuration. @@ -123,4 +153,10 @@ message GetSandboxPolicyResponse { 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. + 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; } From 70c32f6db1086ddbbff76cdfc85b11a94e7089c3 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:57:30 -0700 Subject: [PATCH 02/28] feat(settings): wip sandbox settings channel and typed registry --- architecture/gateway-security.md | 2 +- architecture/gateway.md | 2 +- architecture/sandbox-providers.md | 2 +- architecture/sandbox.md | 16 +-- architecture/security-policy.md | 8 +- architecture/system-architecture.md | 2 +- crates/openshell-cli/src/main.rs | 54 +++++-- crates/openshell-cli/src/run.rs | 134 ++++++++++++++++-- .../tests/ensure_providers_integration.rs | 20 +-- .../openshell-cli/tests/mtls_integration.rs | 8 +- .../tests/provider_commands_integration.rs | 20 +-- .../sandbox_create_lifecycle_integration.rs | 22 +-- .../sandbox_name_fallback_integration.rs | 19 ++- crates/openshell-core/src/lib.rs | 1 + crates/openshell-core/src/settings.rs | 106 ++++++++++++++ crates/openshell-sandbox/src/grpc_client.rs | 23 +-- crates/openshell-sandbox/src/lib.rs | 4 +- crates/openshell-server/src/grpc.rs | 116 +++++++++++---- .../tests/auth_endpoint_integration.rs | 8 +- .../tests/edge_tunnel_auth.rs | 20 +-- .../tests/multiplex_integration.rs | 20 +-- .../tests/multiplex_tls_integration.rs | 20 +-- .../tests/ws_tunnel_integration.rs | 20 +-- crates/openshell-tui/src/lib.rs | 10 +- proto/openshell.proto | 6 +- proto/sandbox.proto | 10 +- 26 files changed, 498 insertions(+), 175 deletions(-) create mode 100644 crates/openshell-core/src/settings.rs diff --git a/architecture/gateway-security.md b/architecture/gateway-security.md index 14543640..f6598626 100644 --- a/architecture/gateway-security.md +++ b/architecture/gateway-security.md @@ -229,7 +229,7 @@ These are used to build a `tonic::transport::ClientTlsConfig` with: - `identity()` -- presents the shared client certificate for mTLS. The sandbox calls two RPCs over this authenticated channel: -- `GetSandboxPolicy` -- fetches the YAML policy that governs the sandbox's behavior. +- `GetSandboxSettings` -- fetches the YAML policy that governs the sandbox's behavior. - `GetSandboxProviderEnvironment` -- fetches provider credentials as environment variables. ## SSH Tunnel Authentication diff --git a/architecture/gateway.md b/architecture/gateway.md index 015f9fb2..66da28f7 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -231,7 +231,7 @@ These RPCs are called by sandbox pods at startup to bootstrap themselves. | RPC | Description | |-----|-------------| -| `GetSandboxPolicy` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata, and effective settings. Global settings override sandbox-level values per key. | +| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata, and effective settings. Global settings override sandbox-level values per key. | | `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. | #### Policy Recommendation (Network Rules) diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index dca36c59..16b7948b 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -241,7 +241,7 @@ variables (injected into the pod spec by the gateway's Kubernetes sandbox creati In `run_sandbox()` (`crates/openshell-sandbox/src/lib.rs`): -1. loads the sandbox policy via gRPC (`GetSandboxPolicy`), +1. loads the sandbox policy via gRPC (`GetSandboxSettings`), 2. fetches provider credentials via gRPC (`GetSandboxProviderEnvironment`), 3. if the fetch fails, continues with an empty map (graceful degradation with a warning). diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 1b4a21bf..e6698f00 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -321,12 +321,12 @@ sequenceDiagram participant GW as Gateway (gRPC) participant OPA as OPA Engine (Arc) - PL->>GW: GetSandboxPolicy(sandbox_id) + PL->>GW: GetSandboxSettings(sandbox_id) GW-->>PL: policy + version + hash PL->>PL: Store initial version loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 10) - PL->>GW: GetSandboxPolicy(sandbox_id) + PL->>GW: GetSandboxSettings(sandbox_id) GW-->>PL: policy + version + hash alt version > current_version PL->>OPA: reload_from_proto(policy) @@ -347,8 +347,8 @@ sequenceDiagram The `run_policy_poll_loop()` function in `crates/openshell-sandbox/src/lib.rs` implements this loop: 1. **Connect once**: Create a `CachedOpenShellClient` that holds a persistent mTLS channel to the gateway. This avoids TLS renegotiation on every poll. -2. **Fetch initial config revision**: Call `poll_policy(sandbox_id)` to establish baseline `current_config_revision`. On failure, log a warning and retry on the next interval. -3. **Poll loop**: Sleep for the configured interval, then call `poll_policy()` again. +2. **Fetch initial config revision**: Call `poll_settings(sandbox_id)` to establish baseline `current_config_revision`. On failure, log a warning and retry on the next interval. +3. **Poll loop**: Sleep for the configured interval, then call `poll_settings()` again. 4. **Config comparison**: If `result.config_revision == current_config_revision`, skip. 5. **Reload attempt**: Call `opa_engine.reload_from_proto(policy)` when a policy payload is present. This runs the full `from_proto()` pipeline on the new policy, then atomically swaps the inner engine. 6. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still reload, but they do not write per-sandbox policy status history. @@ -364,7 +364,7 @@ pub struct CachedOpenShellClient { client: OpenShellClient, } -pub struct PolicyPollResult { +pub struct SettingsPollResult { pub policy: Option, pub version: u32, pub policy_hash: String, @@ -375,16 +375,16 @@ pub struct PolicyPollResult { Methods: - **`connect(endpoint)`**: Establish an mTLS channel and return a new client. -- **`poll_policy(sandbox_id)`**: Call `GetSandboxPolicy` RPC and return a `PolicyPollResult` containing policy payload (optional), policy metadata, effective config revision, and policy source. +- **`poll_settings(sandbox_id)`**: Call `GetSandboxSettings` RPC and return a `SettingsPollResult` containing policy payload (optional), policy metadata, effective config revision, and policy source. - **`report_policy_status(sandbox_id, version, loaded, error_msg)`**: Call `ReportPolicyStatus` RPC with the appropriate `PolicyStatus` enum value (`Loaded` or `Failed`). - **`raw_client()`**: Return a clone of the underlying `OpenShellClient` for direct RPC calls (used by the log push task). ### Server-side policy versioning -The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxPolicyResponse` now also carries effective settings and a `config_revision` fingerprint that changes when effective policy/settings change (including global overrides). +The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxSettingsResponse` now also carries effective settings and a `config_revision` fingerprint that changes when effective policy/settings change (including global overrides). Proto messages involved: -- `GetSandboxPolicyResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings`, `config_revision`, `policy_source` +- `GetSandboxSettingsResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings`, `config_revision`, `policy_source` - `ReportPolicyStatusRequest` (`proto/openshell.proto`): `sandbox_id`, `version`, `status` (enum), `load_error` - `PolicyStatus` enum: `PENDING`, `LOADED`, `FAILED`, `SUPERSEDED` - `SandboxPolicyRevision` (`proto/openshell.proto`): Full revision metadata including `created_at_ms`, `loaded_at_ms` diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 4be33589..5573cdee 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -112,9 +112,9 @@ sequenceDiagram GW-->>CLI: UpdateSandboxPolicyResponse(version=N, hash) loop Every 30s (configurable) - SB->>GW: GetSandboxPolicy(sandbox_id) + SB->>GW: GetSandboxSettings(sandbox_id) GW->>DB: get_latest_policy(sandbox_id) - GW-->>SB: GetSandboxPolicyResponse(policy, version=N, hash) + GW-->>SB: GetSandboxSettingsResponse(policy, version=N, hash) end Note over SB: Detects version > current_version @@ -140,7 +140,7 @@ sequenceDiagram Each sandbox maintains an independent, monotonically increasing version counter for its policy revisions: -- **Version 1** is the policy from the sandbox's `spec.policy` at creation time. It is backfilled lazily on the first `GetSandboxPolicy` call if no explicit revision exists in the policy history table. See `crates/openshell-server/src/grpc.rs` -- `get_sandbox_policy()`. +- **Version 1** is the policy from the sandbox's `spec.policy` at creation time. It is backfilled lazily on the first `GetSandboxSettings` call if no explicit revision exists in the policy history table. See `crates/openshell-server/src/grpc.rs` -- `get_sandbox_settings()`. - Each `UpdateSandboxPolicy` call computes the next version as `latest_version + 1` and persists a new `PolicyRecord` with status `"pending"`. - When a new version is persisted, all older revisions still in `"pending"` status are marked `"superseded"` via `supersede_pending_policies()`. This handles rapid successive updates where the sandbox has not yet picked up an intermediate version. - The `Sandbox` protobuf object carries a `current_policy_version` field (see `proto/datamodel.proto`) that is updated when the sandbox reports a successful load. @@ -182,7 +182,7 @@ In gRPC mode, the sandbox spawns a background task that periodically polls the g The poll loop: 1. Connects a reusable gRPC client (`CachedOpenShellClient`) to avoid per-poll TLS handshake overhead. -2. Fetches the current policy via `GetSandboxPolicy`, which returns the latest version, its policy payload, and a SHA-256 hash. +2. Fetches the current policy via `GetSandboxSettings`, which returns the latest version, its policy payload, and a SHA-256 hash. 3. Compares the returned version against the locally tracked `current_version`. If the server version is not greater, the loop sleeps and retries. 4. On a new version, calls `OpaEngine::reload_from_proto()` which builds a complete new `regorus::Engine` through the same validated pipeline as the initial load (proto-to-JSON conversion, L7 validation, access preset expansion). 5. If the new engine builds successfully, it atomically replaces the inner `Mutex`. If it fails, the previous engine is untouched. diff --git a/architecture/system-architecture.md b/architecture/system-architecture.md index f0915c18..d63f3221 100644 --- a/architecture/system-architecture.md +++ b/architecture/system-architecture.md @@ -123,7 +123,7 @@ graph TB %% ============================================================ %% CONNECTIONS: Sandbox --> Gateway (control plane) %% ============================================================ - Supervisor -- "gRPC (mTLS):
GetSandboxPolicy,
GetProviderEnvironment,
GetInferenceBundle,
PushSandboxLogs" --> Gateway + Supervisor -- "gRPC (mTLS):
GetSandboxSettings,
GetProviderEnvironment,
GetInferenceBundle,
PushSandboxLogs" --> Gateway %% ============================================================ %% CONNECTIONS: Sandbox --> External (via proxy) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 7d90036d..34e32d0c 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -259,6 +259,8 @@ const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell settings get my-sandbox $ openshell settings set my-sandbox --key log_level --value debug $ openshell settings set --global --key log_level --value warn + $ openshell settings set --global --key dummy_bool --value yes + $ openshell settings set --global --key dummy_int --value 42 $ openshell settings delete --global --key log_level "; @@ -1400,6 +1402,10 @@ enum PolicyCommands { /// Delete the global policy setting. #[arg(long)] global: bool, + + /// Skip the confirmation prompt for global policy delete. + #[arg(long)] + yes: bool, }, } @@ -1424,7 +1430,7 @@ enum SettingsCommands { #[arg(long)] key: String, - /// Setting value (stored as string). + /// Setting value (string input; bool keys accept true/false/yes/no/1/0). #[arg(long)] value: String, @@ -1447,6 +1453,10 @@ enum SettingsCommands { /// Delete at gateway-global scope. #[arg(long)] global: bool, + + /// Skip the confirmation prompt for global setting delete. + #[arg(long)] + yes: bool, }, } @@ -1839,13 +1849,13 @@ async fn main() -> Result<()> { let name = resolve_sandbox_name(name, &ctx.name)?; run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; } - PolicyCommands::Delete { global } => { + PolicyCommands::Delete { global, yes } => { if !global { return Err(miette::miette!( "sandbox policy delete is not supported; use --global to remove global policy lock" )); } - run::gateway_setting_delete(&ctx.endpoint, "policy", &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &tls).await?; } } } @@ -1879,13 +1889,13 @@ async fn main() -> Result<()> { run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?; } } - SettingsCommands::Delete { key, global } => { + SettingsCommands::Delete { key, global, yes } => { if !global { return Err(miette::miette!( "sandbox settings cannot be deleted; use --global" )); } - run::gateway_setting_delete(&ctx.endpoint, &key, &tls).await?; + run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?; } } } @@ -2990,14 +3000,42 @@ mod tests { #[test] fn policy_delete_global_parses() { - let cli = Cli::try_parse_from(["openshell", "policy", "delete", "--global"]) + let cli = Cli::try_parse_from(["openshell", "policy", "delete", "--global", "--yes"]) .expect("policy delete --global should parse"); match cli.command { Some(Commands::Policy { - command: Some(PolicyCommands::Delete { global }), - }) => assert!(global), + command: Some(PolicyCommands::Delete { global, yes }), + }) => { + assert!(global); + assert!(yes); + } other => panic!("expected policy delete command, got: {other:?}"), } } + + #[test] + fn settings_delete_global_parses_yes_flag() { + let cli = Cli::try_parse_from([ + "openshell", + "settings", + "delete", + "--global", + "--key", + "log_level", + "--yes", + ]) + .expect("settings delete --global should parse"); + + match cli.command { + Some(Commands::Settings { + command: Some(SettingsCommands::Delete { key, global, yes }), + }) => { + assert_eq!(key, "log_level"); + assert!(global); + assert!(yes); + } + other => panic!("expected settings delete command, got: {other:?}"), + } + } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 480c1fa5..53e6a94d 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -25,13 +25,14 @@ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetProviderRequest, - GetSandboxLogsRequest, GetSandboxPolicyRequest, GetSandboxPolicyStatusRequest, - GetSandboxRequest, HealthRequest, ListProvidersRequest, ListSandboxPoliciesRequest, + GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, + GetSandboxSettingsRequest, HealthRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue, UpdateProviderRequest, UpdateSandboxPolicyRequest, WatchSandboxRequest, setting_value, }; +use openshell_core::settings::{self, SettingValueKind}; use openshell_providers::{ ProviderRegistry, detect_provider_from_command, normalize_provider_type, }; @@ -3810,6 +3811,68 @@ fn confirm_global_setting_takeover(key: &str, yes: bool) -> Result<()> { Ok(()) } +fn confirm_global_setting_delete(key: &str, yes: bool) -> Result<()> { + if yes { + return Ok(()); + } + + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(miette::miette!( + "global setting deletes require confirmation; pass --yes in non-interactive mode" + )); + } + + let proceed = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!( + "Deleting global setting '{key}' re-enables sandbox-level management for this key. Continue?" + )) + .default(false) + .interact() + .into_diagnostic()?; + + if !proceed { + return Err(miette::miette!("aborted by user")); + } + + Ok(()) +} + +fn parse_cli_setting_value(key: &str, raw_value: &str) -> Result { + let setting = settings::setting_for_key(key).ok_or_else(|| { + miette::miette!( + "unknown setting key '{}'. Allowed keys: {}", + key, + settings::registered_keys_csv() + ) + })?; + + let value = match setting.kind { + SettingValueKind::String => setting_value::Value::StringValue(raw_value.to_string()), + SettingValueKind::Int => { + let parsed = raw_value.trim().parse::().map_err(|_| { + miette::miette!( + "invalid int value '{}' for key '{}'; expected base-10 integer", + raw_value, + key + ) + })?; + setting_value::Value::IntValue(parsed) + } + SettingValueKind::Bool => { + let parsed = settings::parse_bool_like(raw_value).ok_or_else(|| { + miette::miette!( + "invalid bool value '{}' for key '{}'; expected one of: true,false,yes,no,1,0", + raw_value, + key + ) + })?; + setting_value::Value::BoolValue(parsed) + } + }; + + Ok(SettingValue { value: Some(value) }) +} + fn format_setting_value(value: Option<&SettingValue>) -> String { let Some(value) = value.and_then(|v| v.value.as_ref()) else { return "".to_string(); @@ -3881,7 +3944,7 @@ pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> .ok_or_else(|| miette::miette!("sandbox not found"))?; let response = client - .get_sandbox_policy(GetSandboxPolicyRequest { + .get_sandbox_settings(GetSandboxSettingsRequest { sandbox_id: sandbox.id.clone(), }) .await @@ -3934,6 +3997,7 @@ pub async fn gateway_setting_set( yes: bool, tls: &TlsOptions, ) -> Result<()> { + let setting_value = parse_cli_setting_value(key, value)?; confirm_global_setting_takeover(key, yes)?; let mut client = grpc_client(server, tls).await?; @@ -3942,9 +4006,7 @@ pub async fn gateway_setting_set( name: String::new(), policy: None, setting_key: key.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::StringValue(value.to_string())), - }), + setting_value: Some(setting_value), delete_setting: false, global: true, }) @@ -3969,15 +4031,15 @@ pub async fn sandbox_setting_set( value: &str, tls: &TlsOptions, ) -> Result<()> { + let setting_value = parse_cli_setting_value(key, value)?; + let mut client = grpc_client(server, tls).await?; let response = client .update_sandbox_policy(UpdateSandboxPolicyRequest { name: name.to_string(), policy: None, setting_key: key.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::StringValue(value.to_string())), - }), + setting_value: Some(setting_value), delete_setting: false, global: false, }) @@ -3996,7 +4058,14 @@ pub async fn sandbox_setting_set( Ok(()) } -pub async fn gateway_setting_delete(server: &str, key: &str, tls: &TlsOptions) -> Result<()> { +pub async fn gateway_setting_delete( + server: &str, + key: &str, + yes: bool, + tls: &TlsOptions, +) -> Result<()> { + confirm_global_setting_delete(key, yes)?; + let mut client = grpc_client(server, tls).await?; let response = client .update_sandbox_policy(UpdateSandboxPolicyRequest { @@ -4651,8 +4720,9 @@ mod tests { GatewayControlTarget, TlsOptions, format_gateway_select_header, format_gateway_select_items, gateway_auth_label, gateway_select_with, gateway_type_label, git_sync_files, http_health_check, image_requests_gpu, inferred_provider_type, - parse_credential_pairs, provisioning_timeout_message, ready_false_condition_message, - resolve_gateway_control_target_from, sandbox_should_persist, source_requests_gpu, + parse_cli_setting_value, parse_credential_pairs, provisioning_timeout_message, + ready_false_condition_message, resolve_gateway_control_target_from, sandbox_should_persist, + source_requests_gpu, }; use crate::TEST_ENV_LOCK; use hyper::StatusCode; @@ -4772,6 +4842,46 @@ mod tests { )); } + #[test] + fn parse_cli_setting_value_parses_bool_aliases() { + let yes_value = parse_cli_setting_value("dummy_bool", "yes").expect("parse yes"); + assert_eq!( + yes_value.value, + Some(openshell_core::proto::setting_value::Value::BoolValue(true)) + ); + + let zero_value = parse_cli_setting_value("dummy_bool", "0").expect("parse 0"); + assert_eq!( + zero_value.value, + Some(openshell_core::proto::setting_value::Value::BoolValue( + false + )) + ); + } + + #[test] + fn parse_cli_setting_value_parses_int_key() { + let int_value = parse_cli_setting_value("dummy_int", "42").expect("parse int"); + assert_eq!( + int_value.value, + Some(openshell_core::proto::setting_value::Value::IntValue(42)) + ); + } + + #[test] + fn parse_cli_setting_value_rejects_invalid_bool() { + let err = + parse_cli_setting_value("dummy_bool", "maybe").expect_err("invalid bool should fail"); + assert!(err.to_string().contains("invalid bool value")); + } + + #[test] + fn parse_cli_setting_value_rejects_unknown_key() { + let err = + parse_cli_setting_value("unknown_key", "value").expect_err("unknown key should fail"); + assert!(err.to_string().contains("unknown setting key")); + } + #[test] fn inferred_provider_type_returns_type_for_known_command() { let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]); diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 659edffd..64e87add 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -11,12 +11,12 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -153,11 +153,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 8b238da9..2969d892 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -108,12 +108,12 @@ impl OpenShell for TestOpenShell { )) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Ok(Response::new( - openshell_core::proto::GetSandboxPolicyResponse::default(), + openshell_core::proto::GetSandboxSettingsResponse::default(), )) } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index af7e80a3..7194d526 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -7,12 +7,12 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -107,11 +107,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 9fcfeced..3bccc8e4 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -8,13 +8,13 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - PlatformEvent, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, - SandboxPhase, SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, - WatchSandboxRequest, sandbox_stream_event, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, PlatformEvent, + ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, SandboxPhase, + SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + sandbox_stream_event, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -156,11 +156,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 3fce5d8d..e5338d5c 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -8,12 +8,11 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - ProviderResponse, Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, - UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, Sandbox, + SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -132,11 +131,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 9cf0d620..c785b074 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod forward; pub mod inference; pub mod paths; pub mod proto; +pub mod settings; pub use config::{Config, TlsConfig}; pub use error::{Error, Result}; diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs new file mode 100644 index 00000000..cf68d2c0 --- /dev/null +++ b/crates/openshell-core/src/settings.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Registry for sandbox runtime settings keys and value kinds. + +/// Supported value kinds for registered sandbox settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingValueKind { + String, + Int, + Bool, +} + +impl SettingValueKind { + /// Human-readable value kind used in error messages. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::String => "string", + Self::Int => "int", + Self::Bool => "bool", + } + } +} + +/// Static descriptor for one registered sandbox setting key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RegisteredSetting { + pub key: &'static str, + pub kind: SettingValueKind, +} + +/// Static registry of currently-supported runtime settings. +/// +/// `policy` is intentionally excluded because it is a reserved key handled by +/// dedicated policy commands and payloads. +pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ + RegisteredSetting { + key: "log_level", + kind: SettingValueKind::String, + }, + RegisteredSetting { + key: "dummy_int", + kind: SettingValueKind::Int, + }, + RegisteredSetting { + key: "dummy_bool", + kind: SettingValueKind::Bool, + }, +]; + +/// Resolve a setting descriptor from the registry by key. +#[must_use] +pub fn setting_for_key(key: &str) -> Option<&'static RegisteredSetting> { + REGISTERED_SETTINGS.iter().find(|entry| entry.key == key) +} + +/// Return comma-separated registered keys for CLI/API diagnostics. +#[must_use] +pub fn registered_keys_csv() -> String { + REGISTERED_SETTINGS + .iter() + .map(|entry| entry.key) + .collect::>() + .join(", ") +} + +/// Parse common bool-like string values. +#[must_use] +pub fn parse_bool_like(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "y" | "on" => Some(true), + "0" | "false" | "no" | "n" | "off" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{SettingValueKind, parse_bool_like, setting_for_key}; + + #[test] + fn setting_for_key_returns_registered_entry() { + let setting = setting_for_key("dummy_bool").expect("dummy_bool should be registered"); + assert_eq!(setting.kind, SettingValueKind::Bool); + } + + #[test] + fn parse_bool_like_accepts_expected_spellings() { + for raw in ["1", "true", "yes", "on", "Y"] { + assert_eq!(parse_bool_like(raw), Some(true), "expected true for {raw}"); + } + for raw in ["0", "false", "no", "off", "N"] { + assert_eq!( + parse_bool_like(raw), + Some(false), + "expected false for {raw}" + ); + } + } + + #[test] + fn parse_bool_like_rejects_unrecognized_values() { + assert_eq!(parse_bool_like("maybe"), None); + } +} diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index c684b928..23b360b5 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -9,10 +9,11 @@ use std::time::Duration; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ - DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxPolicyRequest, - GetSandboxProviderEnvironmentRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest, - SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateSandboxPolicyRequest, - inference_client::InferenceClient, open_shell_client::OpenShellClient, + DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxSettingsRequest, PolicySource, PolicyStatus, + ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, + UpdateSandboxPolicyRequest, inference_client::InferenceClient, + open_shell_client::OpenShellClient, }; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; @@ -101,7 +102,7 @@ async fn fetch_policy_with_client( sandbox_id: &str, ) -> Result> { let response = client - .get_sandbox_policy(GetSandboxPolicyRequest { + .get_sandbox_settings(GetSandboxSettingsRequest { sandbox_id: sandbox_id.to_string(), }) .await @@ -213,8 +214,8 @@ pub struct CachedOpenShellClient { client: OpenShellClient, } -/// Policy poll result returned by [`CachedOpenShellClient::poll_policy`]. -pub struct PolicyPollResult { +/// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. +pub struct SettingsPollResult { pub policy: Option, pub version: u32, pub policy_hash: String, @@ -235,12 +236,12 @@ impl CachedOpenShellClient { self.client.clone() } - /// Poll for the current sandbox policy version. - pub async fn poll_policy(&self, sandbox_id: &str) -> Result { + /// Poll for current effective sandbox settings and policy metadata. + pub async fn poll_settings(&self, sandbox_id: &str) -> Result { let response = self .client .clone() - .get_sandbox_policy(GetSandboxPolicyRequest { + .get_sandbox_settings(GetSandboxSettingsRequest { sandbox_id: sandbox_id.to_string(), }) .await @@ -248,7 +249,7 @@ impl CachedOpenShellClient { let inner = response.into_inner(); - Ok(PolicyPollResult { + Ok(SettingsPollResult { policy: inner.policy, version: inner.version, policy_hash: inner.policy_hash, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 6a40c052..0436e018 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1315,7 +1315,7 @@ async fn run_policy_poll_loop( let mut current_config_revision: u64 = 0; // Initialize revision from the first poll. - match client.poll_policy(sandbox_id).await { + match client.poll_settings(sandbox_id).await { Ok(result) => { current_config_revision = result.config_revision; debug!( @@ -1332,7 +1332,7 @@ async fn run_policy_poll_loop( loop { tokio::time::sleep(interval).await; - let result = match client.poll_policy(sandbox_id).await { + let result = match client.poll_settings(sandbox_id).await { Ok(r) => r, Err(e) => { debug!(error = %e, "Policy poll: server unreachable, will retry"); diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 932f4423..a9386dde 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -18,16 +18,17 @@ use openshell_core::proto::{ DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, EffectiveSetting, ExecSandboxEvent, ExecSandboxExit, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetProviderRequest, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + GetProviderRequest, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxesRequest, - ListSandboxesResponse, PolicyChunk, PolicySource, PolicyStatus, Provider, ProviderResponse, - PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, - RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, - SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, + GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, + ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, + ListSandboxPoliciesResponse, ListSandboxesRequest, ListSandboxesResponse, PolicyChunk, + PolicySource, PolicyStatus, Provider, ProviderResponse, PushSandboxLogsRequest, + PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, + ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, SandboxResponse, + SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateProviderRequest, UpdateSandboxPolicyRequest, UpdateSandboxPolicyResponse, WatchSandboxRequest, open_shell_server::OpenShell, @@ -35,6 +36,7 @@ use openshell_core::proto::{ use openshell_core::proto::{ Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, }; +use openshell_core::settings::{self, SettingValueKind}; use prost::Message; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -729,10 +731,10 @@ impl OpenShell for OpenShellService { Ok(Response::new(DeleteProviderResponse { deleted })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let sandbox_id = request.into_inner().sandbox_id; let sandbox = self @@ -758,7 +760,7 @@ impl OpenShell for OpenShellService { debug!( sandbox_id = %sandbox_id, version = record.version, - "GetSandboxPolicy served from policy history" + "GetSandboxSettings served from policy history" ); ( Some(decoded), @@ -778,7 +780,7 @@ impl OpenShell for OpenShellService { None => { debug!( sandbox_id = %sandbox_id, - "GetSandboxPolicy: no policy configured, returning empty response" + "GetSandboxSettings: no policy configured, returning empty response" ); (None, 0, String::new()) } @@ -815,7 +817,7 @@ impl OpenShell for OpenShellService { info!( sandbox_id = %sandbox_id, - "GetSandboxPolicy served from spec (backfilled version 1)" + "GetSandboxSettings served from spec (backfilled version 1)" ); (Some(spec_policy), 1, hash) @@ -841,7 +843,7 @@ impl OpenShell for OpenShellService { let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); - Ok(Response::new(GetSandboxPolicyResponse { + Ok(Response::new(GetSandboxSettingsResponse { policy, version, policy_hash, @@ -1100,6 +1102,9 @@ impl OpenShell for OpenShellService { "reserved key 'policy' must be set via the policy field", )); } + if key != POLICY_SETTING_KEY { + validate_registered_setting_key(key)?; + } let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; let deleted = if req.delete_setting { @@ -1109,7 +1114,7 @@ impl OpenShell for OpenShellService { .setting_value .as_ref() .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; - let stored = proto_setting_to_stored(setting)?; + let stored = proto_setting_to_stored(key, setting)?; upsert_setting_value(&mut global_settings.settings, key, stored) }; @@ -1165,7 +1170,7 @@ impl OpenShell for OpenShellService { .setting_value .as_ref() .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; - let stored = proto_setting_to_stored(setting)?; + let stored = proto_setting_to_stored(key, setting)?; let mut sandbox_settings = load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; @@ -2541,16 +2546,43 @@ fn compute_config_revision( u64::from_le_bytes(bytes) } -fn proto_setting_to_stored(value: &SettingValue) -> Result { +fn validate_registered_setting_key(key: &str) -> Result { + settings::setting_for_key(key) + .map(|entry| entry.kind) + .ok_or_else(|| { + Status::invalid_argument(format!( + "unknown setting key '{key}'. Allowed keys: {}", + settings::registered_keys_csv() + )) + }) +} + +fn proto_setting_to_stored(key: &str, value: &SettingValue) -> Result { + let expected = validate_registered_setting_key(key)?; let inner = value .value .as_ref() .ok_or_else(|| Status::invalid_argument("setting_value.value is required"))?; - let stored = match inner { - setting_value::Value::StringValue(v) => StoredSettingValue::String(v.clone()), - setting_value::Value::BoolValue(v) => StoredSettingValue::Bool(*v), - setting_value::Value::IntValue(v) => StoredSettingValue::Int(*v), - setting_value::Value::BytesValue(v) => StoredSettingValue::Bytes(hex::encode(v)), + let stored = match (expected, inner) { + (SettingValueKind::String, setting_value::Value::StringValue(v)) => { + StoredSettingValue::String(v.clone()) + } + (SettingValueKind::Bool, setting_value::Value::BoolValue(v)) => { + StoredSettingValue::Bool(*v) + } + (SettingValueKind::Int, setting_value::Value::IntValue(v)) => StoredSettingValue::Int(*v), + (_, setting_value::Value::BytesValue(_)) => { + return Err(Status::invalid_argument(format!( + "setting '{key}' expects {} value; bytes are not supported for this key", + expected.as_str() + ))); + } + (expected_kind, _) => { + return Err(Status::invalid_argument(format!( + "setting '{key}' expects {} value", + expected_kind.as_str() + ))); + } }; Ok(stored) } @@ -5182,4 +5214,40 @@ mod tests { assert_ne!(rev_a, rev_b); } + + #[test] + fn proto_setting_to_stored_rejects_unknown_key() { + let value = openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "hello".to_string(), + )), + }; + + let err = super::proto_setting_to_stored("unknown_key", &value).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("unknown setting key")); + } + + #[test] + fn proto_setting_to_stored_rejects_type_mismatch() { + let value = openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "true".to_string(), + )), + }; + + let err = super::proto_setting_to_stored("dummy_bool", &value).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("expects bool value")); + } + + #[test] + fn proto_setting_to_stored_accepts_bool_for_registered_bool_key() { + let value = openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::BoolValue(true)), + }; + + let stored = super::proto_setting_to_stored("dummy_bool", &value).unwrap(); + assert_eq!(stored, super::StoredSettingValue::Bool(true)); + } } diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index ad65bbc4..054952f2 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -435,13 +435,13 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { )) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _: tonic::Request, - ) -> Result, tonic::Status> + _: tonic::Request, + ) -> Result, tonic::Status> { Ok(tonic::Response::new( - openshell_core::proto::GetSandboxPolicyResponse::default(), + openshell_core::proto::GetSandboxSettingsResponse::default(), )) } diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index e8c4974b..5460ee59 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -37,12 +37,12 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -111,11 +111,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index e07f2ca0..ce795f1d 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -11,12 +11,12 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -69,11 +69,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 5faaf103..c48555ba 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -13,12 +13,12 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -82,11 +82,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index ce99c719..23929547 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -40,12 +40,12 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, - ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, + ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -105,11 +105,11 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxSettingsResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index e0b36f94..f904c980 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -632,7 +632,7 @@ async fn handle_sandbox_delete(app: &mut App) { /// Fetch sandbox details (policy + providers) when entering the sandbox screen. /// -/// Uses `GetSandbox` for metadata/providers, then `GetSandboxPolicy` for the +/// Uses `GetSandbox` for metadata/providers, then `GetSandboxSettings` for the /// current live policy (which may have been updated since creation). async fn fetch_sandbox_detail(app: &mut App) { let sandbox_name = match app.selected_sandbox_name() { @@ -673,11 +673,11 @@ async fn fetch_sandbox_detail(app: &mut App) { // Step 2: Fetch the current live policy (includes updates since creation). if let Some(id) = sandbox_id { - let policy_req = openshell_core::proto::GetSandboxPolicyRequest { sandbox_id: id }; + let policy_req = openshell_core::proto::GetSandboxSettingsRequest { sandbox_id: id }; match tokio::time::timeout( Duration::from_secs(5), - app.client.get_sandbox_policy(policy_req), + app.client.get_sandbox_settings(policy_req), ) .await { @@ -1883,11 +1883,11 @@ async fn refresh_sandbox_policy(app: &mut App) { None => return, }; - let policy_req = openshell_core::proto::GetSandboxPolicyRequest { sandbox_id }; + let policy_req = openshell_core::proto::GetSandboxSettingsRequest { sandbox_id }; match tokio::time::timeout( Duration::from_secs(5), - app.client.get_sandbox_policy(policy_req), + app.client.get_sandbox_settings(policy_req), ) .await { diff --git a/proto/openshell.proto b/proto/openshell.proto index 048ce101..d8702421 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -52,9 +52,9 @@ service OpenShell { // Delete a provider by name. rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); - // Get sandbox policy by id (called by sandbox entrypoint and poll loop). - rpc GetSandboxPolicy(openshell.sandbox.v1.GetSandboxPolicyRequest) - returns (openshell.sandbox.v1.GetSandboxPolicyResponse); + // Get sandbox settings by id (called by sandbox entrypoint and poll loop). + rpc GetSandboxSettings(openshell.sandbox.v1.GetSandboxSettingsRequest) + returns (openshell.sandbox.v1.GetSandboxSettingsResponse); // Update sandbox policy on a live sandbox. rpc UpdateSandboxPolicy(UpdateSandboxPolicyRequest) diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 51b6ac1e..9a3e7c8f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -109,8 +109,8 @@ message NetworkBinary { bool harness = 2 [deprecated = true]; } -// Request to get sandbox policy by sandbox ID. -message GetSandboxPolicyRequest { +// Request to get sandbox settings by sandbox ID. +message GetSandboxSettingsRequest { // The sandbox ID. string sandbox_id = 1; } @@ -138,15 +138,15 @@ message EffectiveSetting { SettingScope scope = 2; } -// Source used for the policy payload in GetSandboxPolicyResponse. +// Source used for the policy payload in GetSandboxSettingsResponse. enum PolicySource { POLICY_SOURCE_UNSPECIFIED = 0; POLICY_SOURCE_SANDBOX = 1; POLICY_SOURCE_GLOBAL = 2; } -// Response containing sandbox policy. -message GetSandboxPolicyResponse { +// Response containing effective sandbox settings and policy. +message GetSandboxSettingsResponse { // The sandbox policy configuration. SandboxPolicy policy = 1; // Current policy version (monotonically increasing per sandbox). From e9ee7e7748d62f0f5a1af13d47608e3ee7ece3ad Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:14:45 -0700 Subject: [PATCH 03/28] feat(settings): wip global settings get and full key materialization --- crates/openshell-cli/src/main.rs | 38 +++++- crates/openshell-cli/src/run.rs | 43 +++++-- .../tests/ensure_providers_integration.rs | 10 +- .../openshell-cli/tests/mtls_integration.rs | 9 ++ .../tests/provider_commands_integration.rs | 10 +- .../sandbox_create_lifecycle_integration.rs | 10 +- .../sandbox_name_fallback_integration.rs | 10 +- crates/openshell-server/src/grpc.rs | 117 +++++++++++++++--- .../tests/auth_endpoint_integration.rs | 10 ++ .../tests/edge_tunnel_auth.rs | 10 +- .../tests/multiplex_integration.rs | 10 +- .../tests/multiplex_tls_integration.rs | 10 +- .../tests/ws_tunnel_integration.rs | 10 +- proto/openshell.proto | 4 + proto/sandbox.proto | 13 ++ 15 files changed, 276 insertions(+), 38 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 34e32d0c..66d4ce7c 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -257,6 +257,7 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell settings get my-sandbox + $ openshell settings get --global $ openshell settings set my-sandbox --key log_level --value debug $ openshell settings set --global --key log_level --value warn $ openshell settings set --global --key dummy_bool --value yes @@ -1411,12 +1412,16 @@ enum PolicyCommands { #[derive(Subcommand, Debug)] enum SettingsCommands { - /// Show effective settings for a sandbox. + /// Show effective settings for a sandbox or gateway-global scope. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { /// Sandbox name (defaults to last-used sandbox). #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, + + /// Show gateway-global settings. + #[arg(long)] + global: bool, }, /// Set a single setting key. @@ -1871,9 +1876,18 @@ async fn main() -> Result<()> { apply_edge_auth(&mut tls, &ctx.name); match settings_cmd { - SettingsCommands::Get { name } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_settings_get(&ctx.endpoint, &name, &tls).await?; + SettingsCommands::Get { name, global } => { + if global { + if name.is_some() { + return Err(miette::miette!( + "settings get --global does not accept a sandbox name" + )); + } + run::gateway_settings_get(&ctx.endpoint, &tls).await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_settings_get(&ctx.endpoint, &name, &tls).await?; + } } SettingsCommands::Set { name, @@ -2998,6 +3012,22 @@ mod tests { } } + #[test] + fn settings_get_global_parses() { + let cli = Cli::try_parse_from(["openshell", "settings", "get", "--global"]) + .expect("settings get --global should parse"); + + match cli.command { + Some(Commands::Settings { + command: Some(SettingsCommands::Get { name, global }), + }) => { + assert!(global); + assert!(name.is_none()); + } + other => panic!("expected settings get command, got: {other:?}"), + } + } + #[test] fn policy_delete_global_parses() { let cli = Cli::try_parse_from(["openshell", "policy", "delete", "--global", "--yes"]) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 53e6a94d..df195ae7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -24,13 +24,13 @@ use openshell_bootstrap::{ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetProviderRequest, - GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, - GetSandboxSettingsRequest, HealthRequest, ListProvidersRequest, ListSandboxPoliciesRequest, - ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, - SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, - SettingValue, UpdateProviderRequest, UpdateSandboxPolicyRequest, WatchSandboxRequest, - setting_value, + GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, + GetGatewaySettingsRequest, GetProviderRequest, GetSandboxLogsRequest, + GetSandboxPolicyStatusRequest, GetSandboxRequest, GetSandboxSettingsRequest, HealthRequest, + ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, + RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, + SetClusterInferenceRequest, SettingScope, SettingValue, UpdateProviderRequest, + UpdateSandboxPolicyRequest, WatchSandboxRequest, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_providers::{ @@ -3976,7 +3976,7 @@ pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> let scope = match SettingScope::try_from(setting.scope) { Ok(SettingScope::Global) => "global", Ok(SettingScope::Sandbox) => "sandbox", - _ => "unknown", + _ => "unset", }; println!( " {} = {} ({})", @@ -3990,6 +3990,33 @@ pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> Ok(()) } +pub async fn gateway_settings_get(server: &str, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_gateway_settings(GetGatewaySettingsRequest {}) + .await + .into_diagnostic()? + .into_inner(); + + println!("Scope: global"); + println!("Settings Rev: {}", response.settings_revision); + + if response.settings.is_empty() { + println!("Settings: (none)"); + return Ok(()); + } + + println!("Settings:"); + let mut keys: Vec<_> = response.settings.keys().cloned().collect(); + keys.sort(); + for key in keys { + if let Some(setting) = response.settings.get(&key) { + println!(" {} = {}", key, format_setting_value(Some(setting))); + } + } + Ok(()) +} + pub async fn gateway_setting_set( server: &str, key: &str, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 64e87add..3cfc6ea2 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -11,7 +11,8 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, @@ -160,6 +161,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 2969d892..9fa4d930 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -117,6 +117,15 @@ impl OpenShell for TestOpenShell { )) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::GetGatewaySettingsResponse::default(), + )) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 7194d526..5cffcf22 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -7,7 +7,8 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, @@ -114,6 +115,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 3bccc8e4..fe4be99c 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -8,7 +8,8 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, PlatformEvent, @@ -163,6 +164,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index e5338d5c..8daace40 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -8,7 +8,8 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, Sandbox, @@ -138,6 +139,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index a9386dde..c7693f5e 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -18,17 +18,17 @@ use openshell_core::proto::{ DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, EffectiveSetting, ExecSandboxEvent, ExecSandboxExit, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetProviderRequest, GetSandboxLogsRequest, GetSandboxLogsResponse, - GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, - GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, - ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, - ListSandboxPoliciesResponse, ListSandboxesRequest, ListSandboxesResponse, PolicyChunk, - PolicySource, PolicyStatus, Provider, ProviderResponse, PushSandboxLogsRequest, - PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, - ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, - RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, SandboxResponse, - SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, + GetGatewaySettingsRequest, GetGatewaySettingsResponse, GetProviderRequest, + GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, + GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, + GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, + GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, + ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, + ListSandboxesRequest, ListSandboxesResponse, PolicyChunk, PolicySource, PolicyStatus, Provider, + ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, + RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, + SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, UndoDraftChunkResponse, UpdateProviderRequest, UpdateSandboxPolicyRequest, UpdateSandboxPolicyResponse, WatchSandboxRequest, open_shell_server::OpenShell, @@ -853,6 +853,18 @@ impl OpenShell for OpenShellService { })) } + async fn get_gateway_settings( + &self, + _request: Request, + ) -> Result, Status> { + let global_settings = load_global_settings(self.state.store.as_ref()).await?; + let settings = materialize_global_settings(&global_settings)?; + Ok(Response::new(GetGatewaySettingsResponse { + settings, + settings_revision: global_settings.revision, + })) + } + async fn get_sandbox_provider_environment( &self, request: Request, @@ -2717,6 +2729,16 @@ fn merge_effective_settings( ) -> Result, Status> { let mut merged = HashMap::new(); + for registered in settings::REGISTERED_SETTINGS { + merged.insert( + registered.key.to_string(), + EffectiveSetting { + value: None, + scope: SettingScope::Unspecified.into(), + }, + ); + } + for (key, value) in &sandbox.settings { if key == POLICY_SETTING_KEY { continue; @@ -2746,6 +2768,24 @@ fn merge_effective_settings( Ok(merged) } +fn materialize_global_settings( + global: &StoredSettings, +) -> Result, Status> { + let mut materialized = HashMap::new(); + for registered in settings::REGISTERED_SETTINGS { + materialized.insert(registered.key.to_string(), SettingValue { value: None }); + } + + for (key, value) in &global.settings { + if key == POLICY_SETTING_KEY { + continue; + } + materialized.insert(key.clone(), stored_setting_to_proto(value)?); + } + + Ok(materialized) +} + /// Check if a log line's source matches the filter list. /// Empty source is treated as "gateway" for backward compatibility. fn source_matches(log_source: &str, filters: &[String]) -> bool { @@ -5107,10 +5147,7 @@ mod tests { "log_level".to_string(), super::StoredSettingValue::String("warn".to_string()), ), - ( - "region".to_string(), - super::StoredSettingValue::String("us-west".to_string()), - ), + ("dummy_int".to_string(), super::StoredSettingValue::Int(7)), ] .into_iter() .collect(), @@ -5123,7 +5160,7 @@ mod tests { super::StoredSettingValue::String("debug".to_string()), ), ( - "feature_x".to_string(), + "dummy_bool".to_string(), super::StoredSettingValue::Bool(true), ), ] @@ -5144,11 +5181,55 @@ mod tests { )) ); - let feature_x = merged.get("feature_x").expect("feature_x present"); + let dummy_bool = merged.get("dummy_bool").expect("dummy_bool present"); assert_eq!( - feature_x.scope, + dummy_bool.scope, openshell_core::proto::SettingScope::Sandbox as i32 ); + + let dummy_int = merged.get("dummy_int").expect("dummy_int present"); + assert_eq!( + dummy_int.scope, + openshell_core::proto::SettingScope::Global as i32 + ); + } + + #[test] + fn merge_effective_settings_includes_unset_registered_keys() { + let global = super::StoredSettings::default(); + let sandbox = super::StoredSettings::default(); + + let merged = super::merge_effective_settings(&global, &sandbox).unwrap(); + for registered in openshell_core::settings::REGISTERED_SETTINGS { + let setting = merged + .get(registered.key) + .unwrap_or_else(|| panic!("missing registered key {}", registered.key)); + assert!( + setting.value.is_none(), + "expected unset value for {}", + registered.key + ); + assert_eq!( + setting.scope, + openshell_core::proto::SettingScope::Unspecified as i32 + ); + } + } + + #[test] + fn materialize_global_settings_includes_unset_registered_keys() { + let global = super::StoredSettings::default(); + let materialized = super::materialize_global_settings(&global).unwrap(); + for registered in openshell_core::settings::REGISTERED_SETTINGS { + let setting = materialized + .get(registered.key) + .unwrap_or_else(|| panic!("missing registered key {}", registered.key)); + assert!( + setting.value.is_none(), + "expected unset value for {}", + registered.key + ); + } } #[test] diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index 054952f2..3b186274 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -445,6 +445,16 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { )) } + async fn get_gateway_settings( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Ok(tonic::Response::new( + openshell_core::proto::GetGatewaySettingsResponse::default(), + )) + } + async fn get_sandbox_provider_environment( &self, _: tonic::Request, diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index 5460ee59..c80fc936 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -37,7 +37,8 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, @@ -118,6 +119,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index ce795f1d..4f98b301 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -11,7 +11,8 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, @@ -76,6 +77,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index c48555ba..6d9a5e2f 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -13,7 +13,8 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, @@ -89,6 +90,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index 23929547..bd46b86d 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -40,7 +40,8 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetProviderRequest, GetSandboxProviderEnvironmentRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, + GetProviderRequest, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, @@ -112,6 +113,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(GetSandboxSettingsResponse::default())) } + async fn get_gateway_settings( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewaySettingsResponse::default())) + } + async fn get_sandbox_provider_environment( &self, _request: tonic::Request, diff --git a/proto/openshell.proto b/proto/openshell.proto index d8702421..d8007f86 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -56,6 +56,10 @@ service OpenShell { rpc GetSandboxSettings(openshell.sandbox.v1.GetSandboxSettingsRequest) returns (openshell.sandbox.v1.GetSandboxSettingsResponse); + // Get gateway-global settings. + rpc GetGatewaySettings(openshell.sandbox.v1.GetGatewaySettingsRequest) + returns (openshell.sandbox.v1.GetGatewaySettingsResponse); + // Update sandbox policy on a live sandbox. rpc UpdateSandboxPolicy(UpdateSandboxPolicyRequest) returns (UpdateSandboxPolicyResponse); diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 9a3e7c8f..bcd798eb 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -115,6 +115,18 @@ message GetSandboxSettingsRequest { string sandbox_id = 1; } +// Request to get gateway-global settings. +message GetGatewaySettingsRequest {} + +// Response containing gateway-global settings. +message GetGatewaySettingsResponse { + // 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; @@ -154,6 +166,7 @@ message GetSandboxSettingsResponse { // 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; From b4bb0a238937f0a031869b5b4fc8f8cc0b1ceefa Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:22:31 -0700 Subject: [PATCH 04/28] fix(settings): use prefixed ID for sandbox settings to avoid object store collision --- crates/openshell-server/src/grpc.rs | 14 +- e2e/rust/tests/settings_management.rs | 290 ++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 e2e/rust/tests/settings_management.rs diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index c7693f5e..b399ef2a 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -2650,8 +2650,18 @@ async fn save_global_settings(store: &Store, settings: &StoredSettings) -> Resul .await } +/// Derive a distinct settings record ID from a sandbox UUID. +/// +/// The generic `objects` table uses `id` as the primary key. Sandbox objects +/// already occupy the row keyed by the raw sandbox UUID, so settings records +/// must use a different ID to avoid a silent no-op upsert (the `ON CONFLICT` +/// clause is scoped by `object_type`). +fn sandbox_settings_id(sandbox_id: &str) -> String { + format!("settings:{sandbox_id}") +} + async fn load_sandbox_settings(store: &Store, sandbox_id: &str) -> Result { - load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, sandbox_id).await + load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, &sandbox_settings_id(sandbox_id)).await } async fn save_sandbox_settings( @@ -2663,7 +2673,7 @@ async fn save_sandbox_settings( save_settings_record( store, SANDBOX_SETTINGS_OBJECT_TYPE, - sandbox_id, + &sandbox_settings_id(sandbox_id), sandbox_name, settings, ) diff --git a/e2e/rust/tests/settings_management.rs b/e2e/rust/tests/settings_management.rs new file mode 100644 index 00000000..baef7c03 --- /dev/null +++ b/e2e/rust/tests/settings_management.rs @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E tests for sandbox/global settings CLI workflows. +//! +//! Covers: +//! - Sandbox settings start as `` +//! - Sandbox setting set + read +//! - Global override blocks sandbox writes for that key +//! - Global get + global delete +//! - Sandbox-level control resumes after global delete + +use std::process::Stdio; +use std::sync::Mutex; +use std::time::Duration; + +use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::sandbox::SandboxGuard; +use tokio::time::{Instant, sleep}; + +const TEST_KEY: &str = "dummy_bool"; +static SETTINGS_E2E_LOCK: Mutex<()> = Mutex::new(()); + +struct CliResult { + clean_output: String, + success: bool, + exit_code: Option, +} + +/// Best-effort global setting cleanup that runs even on test panic. +struct GlobalSettingCleanup { + key: &'static str, +} + +impl GlobalSettingCleanup { + fn new(key: &'static str) -> Self { + Self { key } + } +} + +impl Drop for GlobalSettingCleanup { + fn drop(&mut self) { + let _ = std::process::Command::new(openshell_bin()) + .args([ + "settings", + "delete", + "--global", + "--key", + self.key, + "--yes", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +async fn run_cli(args: &[&str]) -> CliResult { + let mut cmd = openshell_cmd(); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.expect("spawn openshell command"); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + CliResult { + clean_output: strip_ansi(&combined), + success: output.status.success(), + exit_code: output.status.code(), + } +} + +fn assert_setting_line(output: &str, key: &str, expected: &str) { + let needle = format!("{key} = {expected}"); + assert!( + output.contains(&needle), + "expected setting line '{needle}' in output:\n{output}" + ); +} + +fn assert_setting_line_with_scope(output: &str, key: &str, expected: &str, scope: &str) { + let needle = format!("{key} = {expected} ({scope})"); + assert!( + output.contains(&needle), + "expected setting line '{needle}' in output:\n{output}" + ); +} + +/// Poll `settings get` until the expected value and scope appear for a key. +async fn wait_for_setting_value( + sandbox_name: &str, + key: &str, + expected_value: &str, + expected_scope: &str, + timeout_duration: Duration, +) { + let needle = format!("{key} = {expected_value} ({expected_scope})"); + let start = Instant::now(); + loop { + let result = run_cli(&["settings", "get", sandbox_name]).await; + if result.success && result.clean_output.contains(&needle) { + return; + } + if start.elapsed() > timeout_duration { + panic!( + "timed out after {:?} waiting for setting line '{needle}' in output:\n{}", + timeout_duration, result.clean_output + ); + } + sleep(Duration::from_secs(1)).await; + } +} + +#[tokio::test] +async fn settings_global_override_round_trip() { + let _settings_lock = SETTINGS_E2E_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _global_cleanup = GlobalSettingCleanup::new(TEST_KEY); + + let cleanup_before = run_cli(&[ + "settings", + "delete", + "--global", + "--key", + TEST_KEY, + "--yes", + ]) + .await; + assert!( + cleanup_before.success, + "initial global setting cleanup should succeed (exit {:?}):\n{}", + cleanup_before.exit_code, + cleanup_before.clean_output + ); + + let mut guard = + SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") + .await + .expect("create keep sandbox"); + + let initial = run_cli(&["settings", "get", &guard.name]).await; + assert!( + initial.success, + "settings get should succeed (exit {:?}):\n{}", + initial.exit_code, + initial.clean_output + ); + assert_setting_line_with_scope(&initial.clean_output, TEST_KEY, "", "unset"); + + let set_sandbox = run_cli(&[ + "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "true", + ]) + .await; + assert!( + set_sandbox.success, + "sandbox setting set should succeed (exit {:?}):\n{}", + set_sandbox.exit_code, + set_sandbox.clean_output + ); + + // Wait for the gateway to reflect the setting change. The setting is stored + // server-side, so `settings get` reads it immediately. Poll to ensure the + // config_revision has been updated (visible in the output). + wait_for_setting_value(&guard.name, TEST_KEY, "true", "sandbox", Duration::from_secs(10)) + .await; + + let after_sandbox_set = run_cli(&["settings", "get", &guard.name]).await; + assert!( + after_sandbox_set.success, + "settings get after sandbox set should succeed (exit {:?}):\n{}", + after_sandbox_set.exit_code, + after_sandbox_set.clean_output + ); + assert_setting_line_with_scope(&after_sandbox_set.clean_output, TEST_KEY, "true", "sandbox"); + + let sandbox_delete_attempt = run_cli(&["settings", "delete", "--key", TEST_KEY]).await; + assert!( + !sandbox_delete_attempt.success, + "sandbox setting delete without --global should fail:\n{}", + sandbox_delete_attempt.clean_output + ); + assert!( + sandbox_delete_attempt + .clean_output + .contains("sandbox settings cannot be deleted; use --global"), + "expected sandbox delete guidance in output:\n{}", + sandbox_delete_attempt.clean_output + ); + + let set_global = run_cli(&[ + "settings", "set", "--global", "--key", TEST_KEY, "--value", "false", "--yes", + ]) + .await; + assert!( + set_global.success, + "global setting set should succeed (exit {:?}):\n{}", + set_global.exit_code, + set_global.clean_output + ); + assert!( + set_global + .clean_output + .contains("Set global setting dummy_bool=false"), + "expected global set output:\n{}", + set_global.clean_output + ); + + let blocked_sandbox_set = run_cli(&[ + "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "true", + ]) + .await; + assert!( + !blocked_sandbox_set.success, + "sandbox setting should fail while key is global-managed:\n{}", + blocked_sandbox_set.clean_output + ); + assert!( + blocked_sandbox_set.clean_output.contains("is managed"), + "expected 'managed globally' error:\n{}", + blocked_sandbox_set.clean_output + ); + + let global_get = run_cli(&["settings", "get", "--global"]).await; + assert!( + global_get.success, + "global settings get should succeed (exit {:?}):\n{}", + global_get.exit_code, + global_get.clean_output + ); + assert_setting_line(&global_get.clean_output, TEST_KEY, "false"); + + let delete_global = run_cli(&[ + "settings", + "delete", + "--global", + "--key", + TEST_KEY, + "--yes", + ]) + .await; + assert!( + delete_global.success, + "global settings delete should succeed (exit {:?}):\n{}", + delete_global.exit_code, + delete_global.clean_output + ); + assert!( + delete_global + .clean_output + .contains("Deleted global setting dummy_bool"), + "expected global delete confirmation in output:\n{}", + delete_global.clean_output + ); + + let global_after_delete = run_cli(&["settings", "get", "--global"]).await; + assert!( + global_after_delete.success, + "global settings get after delete should succeed (exit {:?}):\n{}", + global_after_delete.exit_code, + global_after_delete.clean_output + ); + assert_setting_line(&global_after_delete.clean_output, TEST_KEY, ""); + + let sandbox_set_after_delete = run_cli(&[ + "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "false", + ]) + .await; + assert!( + sandbox_set_after_delete.success, + "sandbox setting set after global delete should succeed (exit {:?}):\n{}", + sandbox_set_after_delete.exit_code, + sandbox_set_after_delete.clean_output + ); + + let sandbox_after_delete = run_cli(&["settings", "get", &guard.name]).await; + assert!( + sandbox_after_delete.success, + "settings get after global delete should succeed (exit {:?}):\n{}", + sandbox_after_delete.exit_code, + sandbox_after_delete.clean_output + ); + assert_setting_line_with_scope(&sandbox_after_delete.clean_output, TEST_KEY, "false", "sandbox"); + + guard.cleanup().await; +} From 28f77ef554150496572e8e2996f80afc0641c644 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:49:30 -0700 Subject: [PATCH 05/28] feat(tui): add global settings tab with typed editing and HITL confirmation --- crates/openshell-tui/src/app.rs | 264 ++++++++++++++++- crates/openshell-tui/src/event.rs | 15 + crates/openshell-tui/src/lib.rs | 148 ++++++++++ crates/openshell-tui/src/ui/dashboard.rs | 14 +- .../openshell-tui/src/ui/global_settings.rs | 272 ++++++++++++++++++ crates/openshell-tui/src/ui/mod.rs | 26 ++ crates/openshell-tui/src/ui/providers.rs | 7 +- 7 files changed, 737 insertions(+), 9 deletions(-) create mode 100644 crates/openshell-tui/src/ui/global_settings.rs diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 9d7f86f3..a10f7058 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -6,6 +6,8 @@ use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use openshell_core::proto::open_shell_client::OpenShellClient; +use openshell_core::proto::setting_value; +use openshell_core::settings::{self, SettingValueKind}; use tonic::transport::Channel; // --------------------------------------------------------------------------- @@ -84,6 +86,61 @@ impl LogSourceFilter { } } +// --------------------------------------------------------------------------- +// Middle pane tab (Providers vs Global Settings) +// --------------------------------------------------------------------------- + +/// Which tab is active in the middle pane of the dashboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MiddlePaneTab { + Providers, + GlobalSettings, +} + +impl MiddlePaneTab { + pub fn next(self) -> Self { + match self { + Self::Providers => Self::GlobalSettings, + Self::GlobalSettings => Self::Providers, + } + } +} + +// --------------------------------------------------------------------------- +// Global settings model +// --------------------------------------------------------------------------- + +/// A single global setting entry for display in the TUI. +#[derive(Debug, Clone)] +pub struct GlobalSettingEntry { + pub key: String, + pub kind: SettingValueKind, + pub value: Option, +} + +impl GlobalSettingEntry { + pub fn display_value(&self) -> String { + match &self.value { + None => "".to_string(), + Some(setting_value::Value::StringValue(v)) => v.clone(), + Some(setting_value::Value::BoolValue(v)) => v.to_string(), + Some(setting_value::Value::IntValue(v)) => v.to_string(), + Some(setting_value::Value::BytesValue(_)) => "".to_string(), + } + } +} + +/// Editing state for a global setting. +#[derive(Debug, Clone)] +pub struct SettingEditState { + /// Index into `global_settings` being edited. + pub index: usize, + /// Text buffer for string/int types. + pub input: String, + /// Validation error to display. + pub error: Option, +} + // --------------------------------------------------------------------------- // Gateway entry // --------------------------------------------------------------------------- @@ -304,6 +361,19 @@ pub struct App { pub provider_selected: usize, pub provider_count: usize, + // Middle pane tab (providers vs global settings) + pub middle_pane_tab: MiddlePaneTab, + + // Global settings + pub global_settings: Vec, + pub global_settings_selected: usize, + pub global_settings_revision: u64, + pub setting_edit: Option, + pub confirm_setting_set: Option, + pub confirm_setting_delete: Option, + pub pending_setting_set: bool, + pub pending_setting_delete: bool, + // Provider CRUD pub create_provider_form: Option, pub provider_detail: Option, @@ -413,6 +483,15 @@ impl App { gateways: Vec::new(), gateway_selected: 0, pending_gateway_switch: None, + middle_pane_tab: MiddlePaneTab::Providers, + global_settings: Vec::new(), + global_settings_selected: 0, + global_settings_revision: 0, + setting_edit: None, + confirm_setting_set: None, + confirm_setting_delete: None, + pending_setting_set: false, + pending_setting_delete: false, provider_names: Vec::new(), provider_types: Vec::new(), provider_cred_keys: Vec::new(), @@ -478,6 +557,31 @@ impl App { // Filtered log helpers // ------------------------------------------------------------------ + /// Apply fetched global settings from the `GetGatewaySettings` response. + pub fn apply_global_settings( + &mut self, + settings: HashMap, + revision: u64, + ) { + self.global_settings_revision = revision; + self.global_settings = settings::REGISTERED_SETTINGS + .iter() + .map(|reg| { + let value = settings.get(reg.key).and_then(|sv| sv.value.clone()); + GlobalSettingEntry { + key: reg.key.to_string(), + kind: reg.kind, + value, + } + }) + .collect(); + if self.global_settings_selected >= self.global_settings.len() + && !self.global_settings.is_empty() + { + self.global_settings_selected = self.global_settings.len() - 1; + } + } + /// Return log lines matching the current source filter. pub fn filtered_log_lines(&self) -> Vec<&LogLine> { self.sandbox_log_lines @@ -515,6 +619,20 @@ impl App { } // Modals intercept all keys when open. + // Confirmation modals take priority over the edit overlay since the + // edit state remains set while the confirm dialog is shown. + if self.confirm_setting_set.is_some() { + self.handle_setting_confirm_set_key(key); + return; + } + if self.confirm_setting_delete.is_some() { + self.handle_setting_confirm_delete_key(key); + return; + } + if self.setting_edit.is_some() { + self.handle_setting_edit_key(key); + return; + } if self.create_form.is_some() { self.handle_create_form_key(key); return; @@ -541,7 +659,13 @@ impl App { fn handle_normal_key(&mut self, key: KeyEvent) { match self.focus { Focus::Gateways => self.handle_gateways_key(key), - Focus::Providers => self.handle_providers_key(key), + Focus::Providers => { + if self.middle_pane_tab == MiddlePaneTab::GlobalSettings { + self.handle_global_settings_key(key); + } else { + self.handle_providers_key(key); + } + } Focus::Sandboxes => self.handle_sandboxes_key(key), Focus::SandboxPolicy => self.handle_policy_key(key), Focus::SandboxLogs => self.handle_logs_key(key), @@ -631,6 +755,144 @@ impl App { self.confirm_provider_delete = true; } } + KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right => { + self.middle_pane_tab = self.middle_pane_tab.next(); + } + _ => {} + } + } + + fn handle_global_settings_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.running = false, + KeyCode::Tab => self.focus = Focus::Sandboxes, + KeyCode::BackTab => self.focus = Focus::Gateways, + KeyCode::Char(':') => { + self.input_mode = InputMode::Command; + self.command_input.clear(); + } + KeyCode::Char('j') | KeyCode::Down => { + if !self.global_settings.is_empty() { + self.global_settings_selected = + (self.global_settings_selected + 1).min(self.global_settings.len() - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.global_settings_selected = self.global_settings_selected.saturating_sub(1); + } + KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right => { + self.middle_pane_tab = self.middle_pane_tab.next(); + } + KeyCode::Enter => { + // Open edit for the selected setting. + if let Some(entry) = self.global_settings.get(self.global_settings_selected) { + if entry.kind == SettingValueKind::Bool { + // Toggle bool inline and go straight to confirmation. + let new_val = match &entry.value { + Some(setting_value::Value::BoolValue(v)) => !v, + _ => true, + }; + self.setting_edit = Some(SettingEditState { + index: self.global_settings_selected, + input: new_val.to_string(), + error: None, + }); + self.confirm_setting_set = Some(self.global_settings_selected); + } else { + // Open text editor. + let current = entry.display_value(); + let input = if current == "" { + String::new() + } else { + current + }; + self.setting_edit = Some(SettingEditState { + index: self.global_settings_selected, + input, + error: None, + }); + } + } + } + KeyCode::Char('d') => { + // Delete the selected global setting (only if it has a value). + if let Some(entry) = self.global_settings.get(self.global_settings_selected) + && entry.value.is_some() + { + self.confirm_setting_delete = Some(self.global_settings_selected); + } + } + _ => {} + } + } + + fn handle_setting_edit_key(&mut self, key: KeyEvent) { + let Some(ref mut edit) = self.setting_edit else { + return; + }; + match key.code { + KeyCode::Esc => { + self.setting_edit = None; + } + KeyCode::Enter => { + // Validate then open confirmation. + let idx = edit.index; + if let Some(entry) = self.global_settings.get(idx) { + let raw = edit.input.trim(); + match entry.kind { + SettingValueKind::Int => { + if raw.parse::().is_err() { + edit.error = Some("expected integer".to_string()); + return; + } + } + SettingValueKind::Bool => { + if settings::parse_bool_like(raw).is_none() { + edit.error = Some("expected true/false/yes/no/1/0".to_string()); + return; + } + } + SettingValueKind::String => {} + } + } + edit.error = None; + self.confirm_setting_set = Some(idx); + } + KeyCode::Backspace => { + edit.input.pop(); + edit.error = None; + } + KeyCode::Char(c) => { + edit.input.push(c); + edit.error = None; + } + _ => {} + } + } + + fn handle_setting_confirm_set_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.pending_setting_set = true; + self.confirm_setting_set = None; + } + KeyCode::Esc | KeyCode::Char('n') => { + self.confirm_setting_set = None; + self.setting_edit = None; + } + _ => {} + } + } + + fn handle_setting_confirm_delete_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.pending_setting_delete = true; + self.confirm_setting_delete = None; + } + KeyCode::Esc | KeyCode::Char('n') => { + self.confirm_setting_delete = None; + } _ => {} } } diff --git a/crates/openshell-tui/src/event.rs b/crates/openshell-tui/src/event.rs index dc575605..f553c9f0 100644 --- a/crates/openshell-tui/src/event.rs +++ b/crates/openshell-tui/src/event.rs @@ -32,6 +32,21 @@ pub enum Event { ProviderDeleteResult(Result), /// Draft action result: `Ok(status_message)` or `Err(error_message)`. DraftActionResult(Result), + /// Global settings fetched: `Ok((settings, revision))` or `Err(message)`. + #[allow(dead_code)] + GlobalSettingsFetched( + Result< + ( + std::collections::HashMap, + u64, + ), + String, + >, + ), + /// Global setting set result: `Ok(revision)` or `Err(message)`. + GlobalSettingSetResult(Result), + /// Global setting delete result: `Ok(revision)` or `Err(message)`. + GlobalSettingDeleteResult(Result), } pub struct EventHandler { diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index f904c980..3d380d71 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -112,6 +112,15 @@ pub async fn run( app.pending_provider_delete = false; spawn_delete_provider(&app, events.sender()); } + // --- Global settings CRUD --- + if app.pending_setting_set { + app.pending_setting_set = false; + spawn_set_global_setting(&app, events.sender()); + } + if app.pending_setting_delete { + app.pending_setting_delete = false; + spawn_delete_global_setting(&app, events.sender()); + } if app.pending_sandbox_detail { app.pending_sandbox_detail = false; fetch_sandbox_detail(&mut app).await; @@ -222,6 +231,37 @@ pub async fn run( refresh_draft_chunks(&mut app).await; refresh_sandbox_draft_counts(&mut app).await; } + Some(Event::GlobalSettingsFetched(result)) => match result { + Ok((settings, revision)) => { + app.apply_global_settings(settings, revision); + } + Err(msg) => { + tracing::warn!("failed to fetch global settings: {msg}"); + } + }, + Some(Event::GlobalSettingSetResult(result)) => { + app.setting_edit = None; + match result { + Ok(rev) => { + app.global_settings_revision = rev; + app.status_text = "Global setting updated.".to_string(); + } + Err(msg) => { + app.status_text = format!("set setting failed: {msg}"); + } + } + refresh_global_settings(&mut app).await; + } + Some(Event::GlobalSettingDeleteResult(result)) => match result { + Ok(rev) => { + app.global_settings_revision = rev; + app.status_text = "Global setting deleted.".to_string(); + refresh_global_settings(&mut app).await; + } + Err(msg) => { + app.status_text = format!("delete setting failed: {msg}"); + } + }, Some(Event::Mouse(mouse)) => match mouse.kind { MouseEventKind::ScrollUp if app.focus == Focus::SandboxLogs => { app.scroll_logs(-3); @@ -1756,6 +1796,7 @@ fn mask_secret(value: &str) -> String { async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_providers(app).await; + refresh_global_settings(app).await; refresh_sandboxes(app).await; } @@ -1794,6 +1835,113 @@ async fn refresh_providers(app: &mut App) { } } +async fn refresh_global_settings(app: &mut App) { + let req = openshell_core::proto::GetGatewaySettingsRequest {}; + let result = + tokio::time::timeout(Duration::from_secs(5), app.client.get_gateway_settings(req)).await; + match result { + Ok(Err(e)) => { + tracing::warn!("failed to fetch global settings: {}", e.message()); + } + Err(_) => { + tracing::warn!("get gateway settings timed out"); + } + Ok(Ok(resp)) => { + let inner = resp.into_inner(); + app.apply_global_settings(inner.settings, inner.settings_revision); + } + } +} + +fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { + let Some(ref edit) = app.setting_edit else { + return; + }; + let Some(entry) = app.global_settings.get(edit.index) else { + return; + }; + + let key = entry.key.clone(); + let raw = edit.input.trim().to_string(); + let kind = entry.kind; + let mut client = app.client.clone(); + + tokio::spawn(async move { + // Build the typed SettingValue from the validated input. + use openshell_core::proto::{SettingValue, UpdateSandboxPolicyRequest, setting_value}; + + let value = match kind { + openshell_core::settings::SettingValueKind::Bool => { + let parsed = openshell_core::settings::parse_bool_like(&raw).unwrap_or(false); + setting_value::Value::BoolValue(parsed) + } + openshell_core::settings::SettingValueKind::Int => { + let parsed = raw.parse::().unwrap_or(0); + setting_value::Value::IntValue(parsed) + } + openshell_core::settings::SettingValueKind::String => { + setting_value::Value::StringValue(raw) + } + }; + + let req = UpdateSandboxPolicyRequest { + name: String::new(), + policy: None, + setting_key: key, + setting_value: Some(SettingValue { value: Some(value) }), + delete_setting: false, + global: true, + }; + + let result = + tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + + let event = match result { + Ok(Ok(resp)) => Event::GlobalSettingSetResult(Ok(resp.into_inner().settings_revision)), + Ok(Err(e)) => Event::GlobalSettingSetResult(Err(e.message().to_string())), + Err(_) => Event::GlobalSettingSetResult(Err("timeout".to_string())), + }; + let _ = tx.send(event); + }); +} + +fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { + let idx = app + .confirm_setting_delete + .unwrap_or(app.global_settings_selected); + let Some(entry) = app.global_settings.get(idx) else { + return; + }; + + let key = entry.key.clone(); + let mut client = app.client.clone(); + + tokio::spawn(async move { + use openshell_core::proto::UpdateSandboxPolicyRequest; + + let req = UpdateSandboxPolicyRequest { + name: String::new(), + policy: None, + setting_key: key, + setting_value: None, + delete_setting: true, + global: true, + }; + + let result = + tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + + let event = match result { + Ok(Ok(resp)) => { + Event::GlobalSettingDeleteResult(Ok(resp.into_inner().settings_revision)) + } + Ok(Err(e)) => Event::GlobalSettingDeleteResult(Err(e.message().to_string())), + Err(_) => Event::GlobalSettingDeleteResult(Err("timeout".to_string())), + }; + let _ = tx.send(event); + }); +} + async fn refresh_health(app: &mut App) { let req = openshell_core::proto::HealthRequest {}; let result = tokio::time::timeout(Duration::from_secs(5), app.client.health(req)).await; diff --git a/crates/openshell-tui/src/ui/dashboard.rs b/crates/openshell-tui/src/ui/dashboard.rs index 801be17f..5de1d0ae 100644 --- a/crates/openshell-tui/src/ui/dashboard.rs +++ b/crates/openshell-tui/src/ui/dashboard.rs @@ -6,7 +6,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; -use crate::app::{App, Focus}; +use crate::app::{App, Focus, MiddlePaneTab}; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let chunks = Layout::default() @@ -19,7 +19,17 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { .split(area); draw_gateway_list(frame, app, chunks[0]); - super::providers::draw(frame, app, chunks[1], app.focus == Focus::Providers); + + let mid_focused = app.focus == Focus::Providers; + match app.middle_pane_tab { + MiddlePaneTab::Providers => { + super::providers::draw(frame, app, chunks[1], mid_focused); + } + MiddlePaneTab::GlobalSettings => { + super::global_settings::draw(frame, app, chunks[1], mid_focused); + } + } + super::sandboxes::draw(frame, app, chunks[2], app.focus == Focus::Sandboxes); } diff --git a/crates/openshell-tui/src/ui/global_settings.rs b/crates/openshell-tui/src/ui/global_settings.rs new file mode 100644 index 00000000..e67d2620 --- /dev/null +++ b/crates/openshell-tui/src/ui/global_settings.rs @@ -0,0 +1,272 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table}; + +use crate::app::{App, MiddlePaneTab}; + +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; + + let header = Row::new(vec![ + Cell::from(Span::styled(" KEY", t.muted)), + Cell::from(Span::styled("TYPE", t.muted)), + Cell::from(Span::styled("VALUE", t.muted)), + ]) + .bottom_margin(1); + + let rows: Vec> = app + .global_settings + .iter() + .enumerate() + .map(|(i, entry)| { + let selected = focused && i == app.global_settings_selected; + let key_cell = if selected { + Cell::from(Line::from(vec![ + Span::styled("> ", t.accent), + Span::styled(&entry.key, t.text), + ])) + } else { + Cell::from(Line::from(vec![ + Span::raw(" "), + Span::styled(&entry.key, t.text), + ])) + }; + + let type_label = entry.kind.as_str(); + let value_display = entry.display_value(); + let value_style = if entry.value.is_some() { + t.accent + } else { + t.muted + }; + + Row::new(vec![ + key_cell, + Cell::from(Span::styled(type_label, t.muted)), + Cell::from(Span::styled(value_display, value_style)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Percentage(35), + Constraint::Percentage(15), + Constraint::Percentage(50), + ]; + + let border_style = if focused { t.border_focused } else { t.border }; + + let title = draw_tab_title(app, focused); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + let table = Table::new(rows, widths).header(header).block(block); + frame.render_widget(table, area); + + if app.global_settings.is_empty() { + let inner = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(3), + }; + let msg = Paragraph::new(Span::styled(" Loading settings...", t.muted)); + frame.render_widget(msg, inner); + } + + // Draw edit overlay if active. + if focused { + if let Some(ref edit) = app.setting_edit + && app.confirm_setting_set.is_none() + { + draw_edit_overlay(frame, app, edit, area); + } + if let Some(idx) = app.confirm_setting_set { + draw_confirm_set(frame, app, idx, area); + } + if let Some(idx) = app.confirm_setting_delete { + draw_confirm_delete(frame, app, idx, area); + } + } +} + +/// Draw the tab title showing Providers | Global Settings. +pub fn draw_tab_title(app: &App, focused: bool) -> Line<'_> { + let t = &app.theme; + let prov_style = if app.middle_pane_tab == MiddlePaneTab::Providers { + if focused { t.heading } else { t.text } + } else { + t.muted + }; + let gs_style = if app.middle_pane_tab == MiddlePaneTab::GlobalSettings { + if focused { t.heading } else { t.text } + } else { + t.muted + }; + + Line::from(vec![ + Span::styled(" Providers", prov_style), + Span::styled(" | ", t.border), + Span::styled("Global Settings ", gs_style), + ]) +} + +fn draw_edit_overlay( + frame: &mut Frame<'_>, + app: &App, + edit: &crate::app::SettingEditState, + area: Rect, +) { + let t = &app.theme; + let Some(entry) = app.global_settings.get(edit.index) else { + return; + }; + + let title = format!(" Edit: {} ({}) ", entry.key, entry.kind.as_str()); + let mut lines = vec![ + Line::from(Span::styled(&title, t.heading)), + Line::from(""), + Line::from(vec![ + Span::styled("Value: ", t.muted), + Span::styled(&edit.input, t.text), + Span::styled("_", t.accent), + ]), + ]; + + if let Some(ref err) = edit.error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(err, t.status_err))); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("[Enter]", t.key_hint), + Span::styled(" Confirm ", t.muted), + Span::styled("[Esc]", t.key_hint), + Span::styled(" Cancel", t.muted), + ])); + + // content lines + 2 for border + let popup_height = (lines.len() + 2) as u16; + let popup = centered_rect(50, popup_height, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(t.border_focused) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn draw_confirm_set(frame: &mut Frame<'_>, app: &App, idx: usize, area: Rect) { + let t = &app.theme; + let Some(entry) = app.global_settings.get(idx) else { + return; + }; + let new_value = app.setting_edit.as_ref().map_or("-", |e| e.input.as_str()); + + // 7 content lines + 2 border rows = 9 outer height. + let popup = centered_rect(60, 9, area); + frame.render_widget(Clear, popup); + + let lines = vec![ + Line::from(Span::styled(" Confirm Global Setting Change ", t.heading)), + Line::from(""), + Line::from(vec![ + Span::styled("Set ", t.text), + Span::styled(&entry.key, t.accent), + Span::styled(" = ", t.text), + Span::styled(new_value, t.accent), + Span::styled(" globally?", t.text), + ]), + Line::from(""), + Line::from(Span::styled( + "This will apply to all sandboxes on this gateway.", + t.status_warn, + )), + Line::from(""), + Line::from(vec![ + Span::styled("[y]", t.key_hint), + Span::styled(" Confirm ", t.muted), + Span::styled("[n]", t.key_hint), + Span::styled(" Cancel", t.muted), + ]), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(t.border_focused) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn draw_confirm_delete(frame: &mut Frame<'_>, app: &App, idx: usize, area: Rect) { + let t = &app.theme; + let Some(entry) = app.global_settings.get(idx) else { + return; + }; + + let lines = vec![ + Line::from(Span::styled(" Delete Global Setting ", t.status_err)), + Line::from(""), + Line::from(vec![ + Span::styled("Delete global setting ", t.text), + Span::styled(&entry.key, t.accent), + Span::styled("?", t.text), + ]), + Line::from(""), + Line::from(Span::styled( + "This will unset the value for all sandboxes on this gateway.", + t.status_warn, + )), + Line::from(""), + Line::from(vec![ + Span::styled("[y]", t.key_hint), + Span::styled(" Delete ", t.muted), + Span::styled("[n]", t.key_hint), + Span::styled(" Cancel", t.muted), + ]), + ]; + + // content lines + 2 for border + let popup_height = (lines.len() + 2) as u16; + let popup = centered_rect(60, popup_height, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(t.status_err) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { + use ratatui::layout::{Direction, Layout}; + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height.min(100)) / 2), + Constraint::Length(height), + Constraint::Percentage((100 - height.min(100)) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vert[1])[1] +} diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 9c05466d..75d2834c 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod create_provider; pub(crate) mod create_sandbox; mod dashboard; +pub(crate) mod global_settings; pub(crate) mod providers; pub(crate) mod sandbox_detail; mod sandbox_draft; @@ -161,11 +162,36 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { let spans = match app.screen { Screen::Splash => unreachable!("splash handled before draw_nav_bar"), Screen::Dashboard => match app.focus { + Focus::Providers if app.middle_pane_tab == app::MiddlePaneTab::GlobalSettings => vec![ + Span::styled(" ", t.text), + Span::styled("[Tab]", t.key_hint), + Span::styled(" Switch Panel", t.text), + Span::styled(" ", t.text), + Span::styled("[h/l]", t.key_hint), + Span::styled(" Switch Tab", t.text), + Span::styled(" ", t.text), + Span::styled("[j/k]", t.key_hint), + Span::styled(" Navigate", t.text), + Span::styled(" ", t.text), + Span::styled("[Enter]", t.key_hint), + Span::styled(" Edit", t.text), + Span::styled(" ", t.text), + Span::styled("[d]", t.key_hint), + Span::styled(" Delete", t.text), + Span::styled(" | ", t.border), + Span::styled("[:]", t.muted), + Span::styled(" Command ", t.muted), + Span::styled("[q]", t.muted), + Span::styled(" Quit", t.muted), + ], Focus::Providers => vec![ Span::styled(" ", t.text), Span::styled("[Tab]", t.key_hint), Span::styled(" Switch Panel", t.text), Span::styled(" ", t.text), + Span::styled("[h/l]", t.key_hint), + Span::styled(" Switch Tab", t.text), + Span::styled(" ", t.text), Span::styled("[j/k]", t.key_hint), Span::styled(" Navigate", t.text), Span::styled(" ", t.text), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index 7a8e5e3a..4cd277af 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -64,12 +64,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { Span::styled("'? [y/n] ", t.status_err), ]) } else { - Line::from(vec![ - Span::styled(" Providers ", t.heading), - Span::styled("- ", t.border), - Span::styled(&app.gateway_name, t.muted), - Span::styled(" ", t.muted), - ]) + super::global_settings::draw_tab_title(app, focused) }; let block = Block::default() From e73124f7832d88b2342ae2c612a002817b1c3996 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:09:42 -0700 Subject: [PATCH 06/28] feat(settings): support sandbox-scoped setting delete when not globally managed --- crates/openshell-cli/src/main.rs | 23 ++++++++---- crates/openshell-cli/src/run.rs | 39 ++++++++++++++++++++ crates/openshell-server/src/grpc.rs | 40 +++++++++++++++++--- e2e/rust/tests/settings_management.rs | 53 +++++++++++++++++++++++---- 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 66d4ce7c..8ed57956 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1448,9 +1448,13 @@ enum SettingsCommands { yes: bool, }, - /// Delete a single gateway-global setting key. + /// Delete a setting key (sandbox-scoped or gateway-global). #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Delete { + /// Sandbox name (defaults to last-used sandbox when not using --global). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] + name: Option, + /// Setting key. #[arg(long)] key: String, @@ -1903,13 +1907,18 @@ async fn main() -> Result<()> { run::sandbox_setting_set(&ctx.endpoint, &name, &key, &value, &tls).await?; } } - SettingsCommands::Delete { key, global, yes } => { - if !global { - return Err(miette::miette!( - "sandbox settings cannot be deleted; use --global" - )); + SettingsCommands::Delete { + name, + key, + global, + yes, + } => { + if global { + run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_setting_delete(&ctx.endpoint, &name, &key, &tls).await?; } - run::gateway_setting_delete(&ctx.endpoint, &key, yes, &tls).await?; } } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index df195ae7..d53be04e 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4120,6 +4120,45 @@ pub async fn gateway_setting_delete( Ok(()) } +pub async fn sandbox_setting_delete( + server: &str, + name: &str, + key: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .update_sandbox_policy(UpdateSandboxPolicyRequest { + name: name.to_string(), + policy: None, + setting_key: key.to_string(), + setting_value: None, + delete_setting: true, + global: false, + }) + .await + .into_diagnostic()? + .into_inner(); + + if response.deleted { + println!( + "{} Deleted sandbox setting {} for {} (revision {})", + "✓".green().bold(), + key, + name, + response.settings_revision + ); + } else { + println!( + "{} Sandbox setting {} not found for {}", + "!".yellow(), + key, + name, + ); + } + Ok(()) +} + pub async fn sandbox_policy_set( server: &str, name: &str, diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index b399ef2a..3d53e3cd 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1160,11 +1160,6 @@ impl OpenShell for OpenShellService { let sandbox_id = sandbox.id.clone(); if has_setting { - if req.delete_setting { - return Err(Status::invalid_argument( - "sandbox-scoped setting delete is not supported; use global delete", - )); - } if key == POLICY_SETTING_KEY { return Err(Status::invalid_argument( "reserved key 'policy' must be set via policy commands", @@ -1172,7 +1167,40 @@ impl OpenShell for OpenShellService { } let global_settings = load_global_settings(self.state.store.as_ref()).await?; - if global_settings.settings.contains_key(key) { + let globally_managed = global_settings.settings.contains_key(key); + + if req.delete_setting { + // Sandbox-scoped delete: allowed only when the key is not + // globally managed. + if globally_managed { + return Err(Status::failed_precondition(format!( + "setting '{key}' is managed globally; delete the global setting first" + ))); + } + + let mut sandbox_settings = + load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; + let removed = sandbox_settings.settings.remove(key).is_some(); + if removed { + sandbox_settings.revision = sandbox_settings.revision.saturating_add(1); + save_sandbox_settings( + self.state.store.as_ref(), + &sandbox_id, + &sandbox.name, + &sandbox_settings, + ) + .await?; + } + + return Ok(Response::new(UpdateSandboxPolicyResponse { + version: 0, + policy_hash: String::new(), + settings_revision: sandbox_settings.revision, + deleted: removed, + })); + } + + if globally_managed { return Err(Status::failed_precondition(format!( "setting '{key}' is managed globally; delete the global setting before sandbox update" ))); diff --git a/e2e/rust/tests/settings_management.rs b/e2e/rust/tests/settings_management.rs index baef7c03..69cb7cf1 100644 --- a/e2e/rust/tests/settings_management.rs +++ b/e2e/rust/tests/settings_management.rs @@ -178,20 +178,46 @@ async fn settings_global_override_round_trip() { ); assert_setting_line_with_scope(&after_sandbox_set.clean_output, TEST_KEY, "true", "sandbox"); - let sandbox_delete_attempt = run_cli(&["settings", "delete", "--key", TEST_KEY]).await; + // Sandbox-scoped delete should succeed when not globally managed. + let sandbox_delete = run_cli(&[ + "settings", "delete", &guard.name, "--key", TEST_KEY, + ]) + .await; assert!( - !sandbox_delete_attempt.success, - "sandbox setting delete without --global should fail:\n{}", - sandbox_delete_attempt.clean_output + sandbox_delete.success, + "sandbox setting delete should succeed (exit {:?}):\n{}", + sandbox_delete.exit_code, + sandbox_delete.clean_output ); assert!( - sandbox_delete_attempt + sandbox_delete .clean_output - .contains("sandbox settings cannot be deleted; use --global"), - "expected sandbox delete guidance in output:\n{}", - sandbox_delete_attempt.clean_output + .contains("Deleted sandbox setting"), + "expected sandbox delete confirmation:\n{}", + sandbox_delete.clean_output + ); + + // After delete, the key should be unset again. + let after_sandbox_delete = run_cli(&["settings", "get", &guard.name]).await; + assert!( + after_sandbox_delete.success, + "settings get after sandbox delete should succeed:\n{}", + after_sandbox_delete.clean_output + ); + assert_setting_line_with_scope( + &after_sandbox_delete.clean_output, + TEST_KEY, + "", + "unset", ); + // Re-set at sandbox scope so we can test global override next. + let re_set = run_cli(&[ + "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "true", + ]) + .await; + assert!(re_set.success, "re-set should succeed:\n{}", re_set.clean_output); + let set_global = run_cli(&[ "settings", "set", "--global", "--key", TEST_KEY, "--value", "false", "--yes", ]) @@ -225,6 +251,17 @@ async fn settings_global_override_round_trip() { blocked_sandbox_set.clean_output ); + // Sandbox-scoped delete should also be blocked while globally managed. + let blocked_sandbox_delete = run_cli(&[ + "settings", "delete", &guard.name, "--key", TEST_KEY, + ]) + .await; + assert!( + !blocked_sandbox_delete.success, + "sandbox delete should fail while key is global-managed:\n{}", + blocked_sandbox_delete.clean_output + ); + let global_get = run_cli(&["settings", "get", "--global"]).await; assert!( global_get.success, From 6864636757dc938d6b6095cc61d3b3994a3814a7 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:48:39 -0700 Subject: [PATCH 07/28] feat(tui): add per-sandbox settings tab with scope indicators and edit/delete --- crates/openshell-tui/src/app.rs | 294 +++++++++++++++++- crates/openshell-tui/src/event.rs | 4 + crates/openshell-tui/src/lib.rs | 131 ++++++++ crates/openshell-tui/src/ui/mod.rs | 29 +- crates/openshell-tui/src/ui/sandbox_policy.rs | 15 +- .../openshell-tui/src/ui/sandbox_settings.rs | 278 +++++++++++++++++ 6 files changed, 744 insertions(+), 7 deletions(-) create mode 100644 crates/openshell-tui/src/ui/sandbox_settings.rs diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index a10f7058..c85886e3 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -130,10 +130,10 @@ impl GlobalSettingEntry { } } -/// Editing state for a global setting. +/// Editing state for a global or sandbox setting. #[derive(Debug, Clone)] pub struct SettingEditState { - /// Index into `global_settings` being edited. + /// Index into the settings list being edited. pub index: usize, /// Text buffer for string/int types. pub input: String, @@ -141,6 +141,74 @@ pub struct SettingEditState { pub error: Option, } +// --------------------------------------------------------------------------- +// Sandbox policy pane tab (Policy vs Settings) +// --------------------------------------------------------------------------- + +/// Which tab is active in the bottom pane of the sandbox screen (when +/// `Focus::SandboxPolicy`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SandboxPolicyTab { + Policy, + Settings, +} + +impl SandboxPolicyTab { + pub fn next(self) -> Self { + match self { + Self::Policy => Self::Settings, + Self::Settings => Self::Policy, + } + } +} + +// --------------------------------------------------------------------------- +// Sandbox setting entry (effective, with scope) +// --------------------------------------------------------------------------- + +/// A single effective setting for a sandbox, with scope indicator. +#[derive(Debug, Clone)] +pub struct SandboxSettingEntry { + pub key: String, + pub kind: SettingValueKind, + pub value: Option, + pub scope: SettingScope, +} + +/// The scope a sandbox setting was resolved from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingScope { + Unset, + Sandbox, + Global, +} + +impl SettingScope { + pub fn label(self) -> &'static str { + match self { + Self::Unset => "unset", + Self::Sandbox => "sandbox", + Self::Global => "global", + } + } +} + +impl SandboxSettingEntry { + pub fn display_value(&self) -> String { + match &self.value { + None => "".to_string(), + Some(setting_value::Value::StringValue(v)) => v.clone(), + Some(setting_value::Value::BoolValue(v)) => v.to_string(), + Some(setting_value::Value::IntValue(v)) => v.to_string(), + Some(setting_value::Value::BytesValue(_)) => "".to_string(), + } + } + + pub fn is_globally_managed(&self) -> bool { + self.scope == SettingScope::Global + } +} + // --------------------------------------------------------------------------- // Gateway entry // --------------------------------------------------------------------------- @@ -403,6 +471,16 @@ pub struct App { pub pending_sandbox_detail: bool, pub pending_shell_connect: bool, + // Sandbox policy pane tab + sandbox settings + pub sandbox_policy_tab: SandboxPolicyTab, + pub sandbox_settings: Vec, + pub sandbox_settings_selected: usize, + pub sandbox_setting_edit: Option, + pub sandbox_confirm_setting_set: Option, + pub sandbox_confirm_setting_delete: Option, + pub pending_sandbox_setting_set: bool, + pub pending_sandbox_setting_delete: bool, + // Sandbox policy viewer pub sandbox_policy: Option, pub sandbox_providers_list: Vec, @@ -520,6 +598,14 @@ impl App { pending_sandbox_delete: false, pending_sandbox_detail: false, pending_shell_connect: false, + sandbox_policy_tab: SandboxPolicyTab::Policy, + sandbox_settings: Vec::new(), + sandbox_settings_selected: 0, + sandbox_setting_edit: None, + sandbox_confirm_setting_set: None, + sandbox_confirm_setting_delete: None, + pending_sandbox_setting_set: false, + pending_sandbox_setting_delete: false, sandbox_policy: None, sandbox_providers_list: Vec::new(), policy_lines: Vec::new(), @@ -582,6 +668,41 @@ impl App { } } + /// Apply fetched sandbox settings from the `GetSandboxSettings` response. + pub fn apply_sandbox_settings( + &mut self, + settings: HashMap, + ) { + self.sandbox_settings = settings::REGISTERED_SETTINGS + .iter() + .map(|reg| { + let (value, scope) = settings + .get(reg.key) + .map(|es| { + let v = es.value.as_ref().and_then(|sv| sv.value.clone()); + let s = match es.scope { + 1 => SettingScope::Sandbox, + 2 => SettingScope::Global, + _ => SettingScope::Unset, + }; + (v, s) + }) + .unwrap_or((None, SettingScope::Unset)); + SandboxSettingEntry { + key: reg.key.to_string(), + kind: reg.kind, + value, + scope, + } + }) + .collect(); + if self.sandbox_settings_selected >= self.sandbox_settings.len() + && !self.sandbox_settings.is_empty() + { + self.sandbox_settings_selected = self.sandbox_settings.len() - 1; + } + } + /// Return log lines matching the current source filter. pub fn filtered_log_lines(&self) -> Vec<&LogLine> { self.sandbox_log_lines @@ -629,6 +750,18 @@ impl App { self.handle_setting_confirm_delete_key(key); return; } + if self.sandbox_confirm_setting_set.is_some() { + self.handle_sandbox_setting_confirm_set_key(key); + return; + } + if self.sandbox_confirm_setting_delete.is_some() { + self.handle_sandbox_setting_confirm_delete_key(key); + return; + } + if self.sandbox_setting_edit.is_some() { + self.handle_sandbox_setting_edit_key(key); + return; + } if self.setting_edit.is_some() { self.handle_setting_edit_key(key); return; @@ -947,10 +1080,17 @@ impl App { return; } + // Dispatch to sandbox settings handler when on the Settings tab. + if self.sandbox_policy_tab == SandboxPolicyTab::Settings { + self.handle_sandbox_settings_key(key); + return; + } + match key.code { KeyCode::Esc => { self.cancel_log_stream(); self.draft_detail_open = false; + self.sandbox_policy_tab = SandboxPolicyTab::Policy; self.screen = Screen::Dashboard; self.focus = Focus::Sandboxes; } @@ -989,6 +1129,156 @@ impl App { self.policy_scroll = 0; } KeyCode::Char('q') => self.running = false, + KeyCode::Char('h') | KeyCode::Right => { + self.sandbox_policy_tab = self.sandbox_policy_tab.next(); + } + _ => {} + } + } + + fn handle_sandbox_settings_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.running = false, + KeyCode::Esc => { + self.cancel_log_stream(); + self.sandbox_policy_tab = SandboxPolicyTab::Policy; + self.screen = Screen::Dashboard; + self.focus = Focus::Sandboxes; + } + KeyCode::Char('h') | KeyCode::Right => { + self.sandbox_policy_tab = self.sandbox_policy_tab.next(); + } + KeyCode::Char('l') => { + // In policy tab, 'l' opens logs. In settings tab, switch tab. + self.sandbox_policy_tab = self.sandbox_policy_tab.next(); + } + KeyCode::Char('j') | KeyCode::Down => { + if !self.sandbox_settings.is_empty() { + self.sandbox_settings_selected = + (self.sandbox_settings_selected + 1).min(self.sandbox_settings.len() - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.sandbox_settings_selected = self.sandbox_settings_selected.saturating_sub(1); + } + KeyCode::Enter => { + if let Some(entry) = self.sandbox_settings.get(self.sandbox_settings_selected) { + if entry.is_globally_managed() { + self.status_text = format!( + "'{}' is managed globally -- delete the global setting first", + entry.key + ); + return; + } + if entry.kind == SettingValueKind::Bool { + let new_val = match &entry.value { + Some(setting_value::Value::BoolValue(v)) => !v, + _ => true, + }; + self.sandbox_setting_edit = Some(SettingEditState { + index: self.sandbox_settings_selected, + input: new_val.to_string(), + error: None, + }); + self.sandbox_confirm_setting_set = Some(self.sandbox_settings_selected); + } else { + let current = entry.display_value(); + let input = if current == "" { + String::new() + } else { + current + }; + self.sandbox_setting_edit = Some(SettingEditState { + index: self.sandbox_settings_selected, + input, + error: None, + }); + } + } + } + KeyCode::Char('d') => { + if let Some(entry) = self.sandbox_settings.get(self.sandbox_settings_selected) { + if entry.is_globally_managed() { + self.status_text = format!( + "'{}' is managed globally -- delete the global setting first", + entry.key + ); + } else if entry.value.is_some() { + self.sandbox_confirm_setting_delete = + Some(self.sandbox_settings_selected); + } + } + } + _ => {} + } + } + + fn handle_sandbox_setting_edit_key(&mut self, key: KeyEvent) { + let Some(ref mut edit) = self.sandbox_setting_edit else { + return; + }; + match key.code { + KeyCode::Esc => { + self.sandbox_setting_edit = None; + } + KeyCode::Enter => { + let idx = edit.index; + if let Some(entry) = self.sandbox_settings.get(idx) { + let raw = edit.input.trim(); + match entry.kind { + SettingValueKind::Int => { + if raw.parse::().is_err() { + edit.error = Some("expected integer".to_string()); + return; + } + } + SettingValueKind::Bool => { + if settings::parse_bool_like(raw).is_none() { + edit.error = Some("expected true/false/yes/no/1/0".to_string()); + return; + } + } + SettingValueKind::String => {} + } + } + edit.error = None; + self.sandbox_confirm_setting_set = Some(edit.index); + } + KeyCode::Backspace => { + edit.input.pop(); + edit.error = None; + } + KeyCode::Char(c) => { + edit.input.push(c); + edit.error = None; + } + _ => {} + } + } + + fn handle_sandbox_setting_confirm_set_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.pending_sandbox_setting_set = true; + self.sandbox_confirm_setting_set = None; + } + KeyCode::Esc | KeyCode::Char('n') => { + self.sandbox_confirm_setting_set = None; + self.sandbox_setting_edit = None; + } + _ => {} + } + } + + fn handle_sandbox_setting_confirm_delete_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.pending_sandbox_setting_delete = true; + self.sandbox_confirm_setting_delete = None; + } + KeyCode::Esc | KeyCode::Char('n') => { + self.sandbox_confirm_setting_delete = None; + } _ => {} } } diff --git a/crates/openshell-tui/src/event.rs b/crates/openshell-tui/src/event.rs index f553c9f0..e73862eb 100644 --- a/crates/openshell-tui/src/event.rs +++ b/crates/openshell-tui/src/event.rs @@ -47,6 +47,10 @@ pub enum Event { GlobalSettingSetResult(Result), /// Global setting delete result: `Ok(revision)` or `Err(message)`. GlobalSettingDeleteResult(Result), + /// Sandbox setting set result: `Ok(revision)` or `Err(message)`. + SandboxSettingSetResult(Result), + /// Sandbox setting delete result: `Ok(revision)` or `Err(message)`. + SandboxSettingDeleteResult(Result), } pub struct EventHandler { diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 3d380d71..2a18d8d0 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -121,6 +121,15 @@ pub async fn run( app.pending_setting_delete = false; spawn_delete_global_setting(&app, events.sender()); } + // --- Sandbox settings CRUD --- + if app.pending_sandbox_setting_set { + app.pending_sandbox_setting_set = false; + spawn_set_sandbox_setting(&app, events.sender()); + } + if app.pending_sandbox_setting_delete { + app.pending_sandbox_setting_delete = false; + spawn_delete_sandbox_setting(&app, events.sender()); + } if app.pending_sandbox_detail { app.pending_sandbox_detail = false; fetch_sandbox_detail(&mut app).await; @@ -262,6 +271,30 @@ pub async fn run( app.status_text = format!("delete setting failed: {msg}"); } }, + Some(Event::SandboxSettingSetResult(result)) => { + app.sandbox_setting_edit = None; + match result { + Ok(_rev) => { + app.status_text = "Sandbox setting updated.".to_string(); + } + Err(msg) => { + app.status_text = format!("set sandbox setting failed: {msg}"); + } + } + // Re-fetch sandbox settings to reflect the change. + fetch_sandbox_detail(&mut app).await; + } + Some(Event::SandboxSettingDeleteResult(result)) => { + match result { + Ok(_rev) => { + app.status_text = "Sandbox setting deleted.".to_string(); + } + Err(msg) => { + app.status_text = format!("delete sandbox setting failed: {msg}"); + } + } + fetch_sandbox_detail(&mut app).await; + } Some(Event::Mouse(mouse)) => match mouse.kind { MouseEventKind::ScrollUp if app.focus == Focus::SandboxLogs => { app.scroll_logs(-3); @@ -730,6 +763,8 @@ async fn fetch_sandbox_detail(app: &mut App) { app.policy_lines = render_policy_lines(&policy, &app.theme); app.sandbox_policy = Some(policy); } + // Populate sandbox settings from the same response. + app.apply_sandbox_settings(inner.settings); } Ok(Err(e)) => { let msg = e.message().to_string(); @@ -1942,6 +1977,102 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { }); } +fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { + let Some(ref edit) = app.sandbox_setting_edit else { + return; + }; + let Some(entry) = app.sandbox_settings.get(edit.index) else { + return; + }; + let Some(sandbox_name) = app.selected_sandbox_name() else { + return; + }; + + let name = sandbox_name.to_string(); + let key = entry.key.clone(); + let raw = edit.input.trim().to_string(); + let kind = entry.kind; + let mut client = app.client.clone(); + + tokio::spawn(async move { + use openshell_core::proto::{SettingValue, UpdateSandboxPolicyRequest, setting_value}; + + let value = match kind { + openshell_core::settings::SettingValueKind::Bool => { + let parsed = openshell_core::settings::parse_bool_like(&raw).unwrap_or(false); + setting_value::Value::BoolValue(parsed) + } + openshell_core::settings::SettingValueKind::Int => { + let parsed = raw.parse::().unwrap_or(0); + setting_value::Value::IntValue(parsed) + } + openshell_core::settings::SettingValueKind::String => { + setting_value::Value::StringValue(raw) + } + }; + + let req = UpdateSandboxPolicyRequest { + name, + policy: None, + setting_key: key, + setting_value: Some(SettingValue { value: Some(value) }), + delete_setting: false, + global: false, + }; + + let result = + tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + + let event = match result { + Ok(Ok(resp)) => Event::SandboxSettingSetResult(Ok(resp.into_inner().settings_revision)), + Ok(Err(e)) => Event::SandboxSettingSetResult(Err(e.message().to_string())), + Err(_) => Event::SandboxSettingSetResult(Err("timeout".to_string())), + }; + let _ = tx.send(event); + }); +} + +fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { + let idx = app + .sandbox_confirm_setting_delete + .unwrap_or(app.sandbox_settings_selected); + let Some(entry) = app.sandbox_settings.get(idx) else { + return; + }; + let Some(sandbox_name) = app.selected_sandbox_name() else { + return; + }; + + let name = sandbox_name.to_string(); + let key = entry.key.clone(); + let mut client = app.client.clone(); + + tokio::spawn(async move { + use openshell_core::proto::UpdateSandboxPolicyRequest; + + let req = UpdateSandboxPolicyRequest { + name, + policy: None, + setting_key: key, + setting_value: None, + delete_setting: true, + global: false, + }; + + let result = + tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + + let event = match result { + Ok(Ok(resp)) => { + Event::SandboxSettingDeleteResult(Ok(resp.into_inner().settings_revision)) + } + Ok(Err(e)) => Event::SandboxSettingDeleteResult(Err(e.message().to_string())), + Err(_) => Event::SandboxSettingDeleteResult(Err("timeout".to_string())), + }; + let _ = tx.send(event); + }); +} + async fn refresh_health(app: &mut App) { let req = openshell_core::proto::HealthRequest {}; let result = tokio::time::timeout(Duration::from_secs(5), app.client.health(req)).await; diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 75d2834c..146d8d20 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod sandbox_detail; mod sandbox_draft; pub(crate) mod sandbox_logs; mod sandbox_policy; +pub(crate) mod sandbox_settings; pub(crate) mod sandboxes; mod splash; @@ -81,7 +82,10 @@ fn draw_sandbox_screen(frame: &mut Frame<'_>, app: &mut App, area: Rect) { match app.focus { Focus::SandboxLogs => sandbox_logs::draw(frame, app, chunks[1]), Focus::SandboxDraft => sandbox_draft::draw(frame, app, chunks[1]), - _ => sandbox_policy::draw(frame, app, chunks[1]), + _ => match app.sandbox_policy_tab { + app::SandboxPolicyTab::Settings => sandbox_settings::draw(frame, app, chunks[1]), + app::SandboxPolicyTab::Policy => sandbox_policy::draw(frame, app, chunks[1]), + }, } // Log detail popup renders over the full frame (not constrained to pane). @@ -380,8 +384,31 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { ]); spans } + _ if app.sandbox_policy_tab == app::SandboxPolicyTab::Settings => vec![ + Span::styled(" ", t.text), + Span::styled("[h/l]", t.key_hint), + Span::styled(" Switch Tab", t.text), + Span::styled(" ", t.text), + Span::styled("[j/k]", t.key_hint), + Span::styled(" Navigate", t.text), + Span::styled(" ", t.text), + Span::styled("[Enter]", t.key_hint), + Span::styled(" Edit", t.text), + Span::styled(" ", t.text), + Span::styled("[d]", t.key_hint), + Span::styled(" Delete", t.text), + Span::styled(" | ", t.border), + Span::styled("[Esc]", t.muted), + Span::styled(" Back", t.muted), + Span::styled(" ", t.text), + Span::styled("[q]", t.muted), + Span::styled(" Quit", t.muted), + ], _ => vec![ Span::styled(" ", t.text), + Span::styled("[h]", t.key_hint), + Span::styled(" Switch Tab", t.text), + Span::styled(" ", t.text), Span::styled("[j/k]", t.key_hint), Span::styled(" Scroll", t.text), Span::styled(" ", t.text), diff --git a/crates/openshell-tui/src/ui/sandbox_policy.rs b/crates/openshell-tui/src/ui/sandbox_policy.rs index 8e1a623d..d1f710ba 100644 --- a/crates/openshell-tui/src/ui/sandbox_policy.rs +++ b/crates/openshell-tui/src/ui/sandbox_policy.rs @@ -15,7 +15,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let t = &app.theme; let version = app.sandbox_policy.as_ref().map_or(0, |p| p.version); - let title = format!(" Policy (v{version}) "); + let tab_title = super::sandbox_settings::draw_policy_tab_title(app); + let version_hint = format!(" (v{version}) "); // Calculate inner dimensions (borders + padding). let inner_height = area.height.saturating_sub(2) as usize; @@ -23,7 +24,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { if app.policy_lines.is_empty() { let lines = vec![Line::from(Span::styled("Loading...", t.muted))]; let block = Block::default() - .title(Span::styled(title, t.heading)) + .title(tab_title) .borders(Borders::ALL) .border_style(t.border_focused) .padding(Padding::horizontal(1)); @@ -47,8 +48,14 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let scroll_info = format!(" [{pos}/{total}] "); let block = Block::default() - .title(Span::styled(title, t.heading)) - .title_bottom(Line::from(Span::styled(scroll_info, t.muted)).right_aligned()) + .title(tab_title) + .title_bottom( + Line::from(vec![ + Span::styled(version_hint, t.muted), + Span::styled(scroll_info, t.muted), + ]) + .right_aligned(), + ) .borders(Borders::ALL) .border_style(t.border_focused) .padding(Padding::horizontal(1)); diff --git a/crates/openshell-tui/src/ui/sandbox_settings.rs b/crates/openshell-tui/src/ui/sandbox_settings.rs new file mode 100644 index 00000000..9c5eca62 --- /dev/null +++ b/crates/openshell-tui/src/ui/sandbox_settings.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table}; + +use crate::app::{App, SandboxPolicyTab, SettingScope}; + +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { + let t = &app.theme; + + let header = Row::new(vec![ + Cell::from(Span::styled(" KEY", t.muted)), + Cell::from(Span::styled("TYPE", t.muted)), + Cell::from(Span::styled("VALUE", t.muted)), + Cell::from(Span::styled("SCOPE", t.muted)), + ]) + .bottom_margin(1); + + let rows: Vec> = app + .sandbox_settings + .iter() + .enumerate() + .map(|(i, entry)| { + let selected = i == app.sandbox_settings_selected; + let key_cell = if selected { + Cell::from(Line::from(vec![ + Span::styled("> ", t.accent), + Span::styled(&entry.key, t.text), + ])) + } else { + Cell::from(Line::from(vec![ + Span::raw(" "), + Span::styled(&entry.key, t.text), + ])) + }; + + let type_label = entry.kind.as_str(); + let value_display = entry.display_value(); + let value_style = if entry.value.is_some() { + if entry.is_globally_managed() { + t.status_warn + } else { + t.accent + } + } else { + t.muted + }; + + let scope_style = match entry.scope { + SettingScope::Global => t.status_warn, + SettingScope::Sandbox => t.accent, + SettingScope::Unset => t.muted, + }; + + Row::new(vec![ + key_cell, + Cell::from(Span::styled(type_label, t.muted)), + Cell::from(Span::styled(value_display, value_style)), + Cell::from(Span::styled(entry.scope.label(), scope_style)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Percentage(30), + Constraint::Percentage(10), + Constraint::Percentage(40), + Constraint::Percentage(20), + ]; + + let title = draw_policy_tab_title(app); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(t.border_focused) + .padding(Padding::horizontal(1)); + + let table = Table::new(rows, widths).header(header).block(block); + frame.render_widget(table, area); + + if app.sandbox_settings.is_empty() { + let inner = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(3), + }; + let msg = Paragraph::new(Span::styled(" Loading settings...", t.muted)); + frame.render_widget(msg, inner); + } + + // Overlays. + if let Some(ref edit) = app.sandbox_setting_edit + && app.sandbox_confirm_setting_set.is_none() + { + draw_edit_overlay(frame, app, edit, area); + } + if let Some(idx) = app.sandbox_confirm_setting_set { + draw_confirm_set(frame, app, idx, area); + } + if let Some(idx) = app.sandbox_confirm_setting_delete { + draw_confirm_delete(frame, app, idx, area); + } +} + +/// Draw the tab title for the sandbox bottom pane: Policy | Settings. +pub fn draw_policy_tab_title(app: &App) -> Line<'_> { + let t = &app.theme; + let pol_style = if app.sandbox_policy_tab == SandboxPolicyTab::Policy { + t.heading + } else { + t.muted + }; + let set_style = if app.sandbox_policy_tab == SandboxPolicyTab::Settings { + t.heading + } else { + t.muted + }; + + Line::from(vec![ + Span::styled(" Policy", pol_style), + Span::styled(" | ", t.border), + Span::styled("Settings ", set_style), + ]) +} + +fn draw_edit_overlay( + frame: &mut Frame<'_>, + app: &App, + edit: &crate::app::SettingEditState, + area: Rect, +) { + let t = &app.theme; + let Some(entry) = app.sandbox_settings.get(edit.index) else { + return; + }; + + let title = format!(" Edit: {} ({}) ", entry.key, entry.kind.as_str()); + let mut lines = vec![ + Line::from(Span::styled(&title, t.heading)), + Line::from(""), + Line::from(vec![ + Span::styled("Value: ", t.muted), + Span::styled(&edit.input, t.text), + Span::styled("_", t.accent), + ]), + ]; + + if let Some(ref err) = edit.error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(err, t.status_err))); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("[Enter]", t.key_hint), + Span::styled(" Confirm ", t.muted), + Span::styled("[Esc]", t.key_hint), + Span::styled(" Cancel", t.muted), + ])); + + let popup_height = (lines.len() + 2) as u16; + let popup = centered_rect(50, popup_height, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(t.border_focused) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn draw_confirm_set(frame: &mut Frame<'_>, app: &App, idx: usize, area: Rect) { + let t = &app.theme; + let Some(entry) = app.sandbox_settings.get(idx) else { + return; + }; + let new_value = app + .sandbox_setting_edit + .as_ref() + .map_or("-", |e| e.input.as_str()); + let sandbox_name = app.selected_sandbox_name().unwrap_or("-"); + + let lines = vec![ + Line::from(Span::styled(" Confirm Sandbox Setting Change ", t.heading)), + Line::from(""), + Line::from(vec![ + Span::styled("Set ", t.text), + Span::styled(&entry.key, t.accent), + Span::styled(" = ", t.text), + Span::styled(new_value, t.accent), + Span::styled(" for ", t.text), + Span::styled(sandbox_name, t.accent), + Span::styled("?", t.text), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("[y]", t.key_hint), + Span::styled(" Confirm ", t.muted), + Span::styled("[n]", t.key_hint), + Span::styled(" Cancel", t.muted), + ]), + ]; + + let popup_height = (lines.len() + 2) as u16; + let popup = centered_rect(60, popup_height, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(t.border_focused) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn draw_confirm_delete(frame: &mut Frame<'_>, app: &App, idx: usize, area: Rect) { + let t = &app.theme; + let Some(entry) = app.sandbox_settings.get(idx) else { + return; + }; + let sandbox_name = app.selected_sandbox_name().unwrap_or("-"); + + let lines = vec![ + Line::from(Span::styled(" Delete Sandbox Setting ", t.status_err)), + Line::from(""), + Line::from(vec![ + Span::styled("Delete setting ", t.text), + Span::styled(&entry.key, t.accent), + Span::styled(" for ", t.text), + Span::styled(sandbox_name, t.accent), + Span::styled("?", t.text), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("[y]", t.key_hint), + Span::styled(" Delete ", t.muted), + Span::styled("[n]", t.key_hint), + Span::styled(" Cancel", t.muted), + ]), + ]; + + let popup_height = (lines.len() + 2) as u16; + let popup = centered_rect(55, popup_height, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(t.status_err) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { + use ratatui::layout::{Direction, Layout}; + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height.min(100)) / 2), + Constraint::Length(height), + Constraint::Percentage((100 - height.min(100)) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vert[1])[1] +} From 0d66f3d705818b9a307b19811b07052ae1c8ca90 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:21:11 -0700 Subject: [PATCH 08/28] refactor(sandbox): improve poll loop logging to diff settings and conditionally reload policy --- crates/openshell-sandbox/src/grpc_client.rs | 3 + crates/openshell-sandbox/src/lib.rs | 113 +++++++++++++++----- 2 files changed, 88 insertions(+), 28 deletions(-) diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index 23b360b5..d4f8487e 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -221,6 +221,8 @@ pub struct SettingsPollResult { pub policy_hash: String, pub config_revision: u64, pub policy_source: PolicySource, + /// Effective settings keyed by name. + pub settings: std::collections::HashMap, } impl CachedOpenShellClient { @@ -256,6 +258,7 @@ impl CachedOpenShellClient { config_revision: inner.config_revision, policy_source: PolicySource::try_from(inner.policy_source) .unwrap_or(PolicySource::Unspecified), + settings: inner.settings, }) } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 0436e018..305fca0e 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1313,18 +1313,25 @@ async fn run_policy_poll_loop( let client = CachedOpenShellClient::connect(endpoint).await?; let mut current_config_revision: u64 = 0; + let mut current_policy_hash = String::new(); + let mut current_settings: std::collections::HashMap< + String, + openshell_core::proto::EffectiveSetting, + > = std::collections::HashMap::new(); // Initialize revision from the first poll. match client.poll_settings(sandbox_id).await { Ok(result) => { current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash.clone(); + current_settings = result.settings; debug!( config_revision = current_config_revision, - "Policy poll: initial config revision" + "Settings poll: initial config revision" ); } Err(e) => { - warn!(error = %e, "Policy poll: failed to fetch initial version, will retry"); + warn!(error = %e, "Settings poll: failed to fetch initial version, will retry"); } } @@ -1335,7 +1342,7 @@ async fn run_policy_poll_loop( let result = match client.poll_settings(sandbox_id).await { Ok(r) => r, Err(e) => { - debug!(error = %e, "Policy poll: server unreachable, will retry"); + debug!(error = %e, "Settings poll: server unreachable, will retry"); continue; } }; @@ -1344,39 +1351,46 @@ async fn run_policy_poll_loop( continue; } + let policy_changed = result.policy_hash != current_policy_hash; + + // Log which settings changed. + log_setting_changes(¤t_settings, &result.settings); + info!( old_config_revision = current_config_revision, new_config_revision = result.config_revision, - policy_hash = %result.policy_hash, - "Policy poll: config change detected, reloading" + policy_changed, + "Settings poll: config change detected" ); - let Some(policy) = result.policy.as_ref() else { - warn!("Policy poll: config changed but no policy payload present; skipping reload"); - current_config_revision = result.config_revision; - continue; - }; - - match opa_engine.reload_from_proto(policy) { - Ok(()) => { + // Only reload OPA when the policy payload actually changed. + if policy_changed { + let Some(policy) = result.policy.as_ref() else { + warn!("Settings poll: policy hash changed but no policy payload present; skipping reload"); current_config_revision = result.config_revision; - info!( - config_revision = current_config_revision, - policy_hash = %result.policy_hash, - "Policy reloaded successfully" - ); - if result.version > 0 && result.policy_source == PolicySource::Sandbox { - if let Err(e) = client - .report_policy_status(sandbox_id, result.version, true, "") - .await - { - warn!(error = %e, "Failed to report policy load success"); + current_policy_hash = result.policy_hash; + current_settings = result.settings; + continue; + }; + + match opa_engine.reload_from_proto(policy) { + Ok(()) => { + info!( + policy_hash = %result.policy_hash, + "Policy reloaded successfully" + ); + if result.version > 0 && result.policy_source == PolicySource::Sandbox { + if let Err(e) = client + .report_policy_status(sandbox_id, result.version, true, "") + .await + { + warn!(error = %e, "Failed to report policy load success"); + } } } - } - Err(e) => { - warn!( - version = result.version, + Err(e) => { + warn!( + version = result.version, error = %e, "Policy reload failed, keeping last-known-good policy" ); @@ -1389,7 +1403,50 @@ async fn run_policy_poll_loop( } } } + } } + + current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash; + current_settings = result.settings; + } +} + +/// Log individual setting changes between two snapshots. +fn log_setting_changes( + old: &std::collections::HashMap, + new: &std::collections::HashMap, +) { + for (key, new_es) in new { + let new_val = format_setting_value(new_es); + match old.get(key) { + Some(old_es) => { + let old_val = format_setting_value(old_es); + if old_val != new_val { + info!(key, old = %old_val, new = %new_val, "Setting changed"); + } + } + None => { + info!(key, value = %new_val, "Setting added"); + } + } + } + for key in old.keys() { + if !new.contains_key(key) { + info!(key, "Setting removed"); + } + } +} + +/// Format an `EffectiveSetting` value for log display. +fn format_setting_value(es: &openshell_core::proto::EffectiveSetting) -> String { + use openshell_core::proto::setting_value; + match es.value.as_ref().and_then(|sv| sv.value.as_ref()) { + None => "".to_string(), + Some(setting_value::Value::StringValue(v)) => v.clone(), + Some(setting_value::Value::BoolValue(v)) => v.to_string(), + Some(setting_value::Value::IntValue(v)) => v.to_string(), + Some(setting_value::Value::BytesValue(_)) => "".to_string(), } } From cd1d42c33763f0c960b64750ec5f70af3cac2eb7 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:02:20 -0700 Subject: [PATCH 09/28] update arch docs for new settings comms channel --- architecture/README.md | 7 +- architecture/gateway-settings.md | 398 ++++++++++++++++++++++++++++ architecture/gateway.md | 24 +- architecture/sandbox.md | 57 ++-- architecture/security-policy.md | 36 ++- architecture/system-architecture.md | 4 +- architecture/tui.md | 49 +++- 7 files changed, 524 insertions(+), 51 deletions(-) create mode 100644 architecture/gateway-settings.md diff --git a/architecture/README.md b/architecture/README.md index 4a904247..1b3b88c2 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -224,9 +224,11 @@ Sandbox behavior is governed by policies written in YAML and evaluated by an emb Inference routing to `inference.local` is configured separately at the cluster level and does not require network policy entries. The OPA engine evaluates only explicit network policies; `inference.local` connections bypass OPA entirely and are handled by the proxy's dedicated inference interception path. -Policies are not intended to be hand-edited by end users in normal operation. They are associated with sandboxes at creation time and fetched by the sandbox supervisor at startup via gRPC. For development and testing, policies can also be loaded from local files. +Policies are not intended to be hand-edited by end users in normal operation. They are associated with sandboxes at creation time and fetched by the sandbox supervisor at startup via gRPC. For development and testing, policies can also be loaded from local files. A gateway-global policy can override all sandbox policies via `openshell policy set --global`. -For more detail, see [Policy Language](security-policy.md). +In addition to policy, the gateway delivers runtime **settings** -- typed key-value pairs (e.g., `log_level`) that can be configured per-sandbox or globally. Settings and policy are delivered together through the `GetSandboxSettings` RPC and tracked by a single `config_revision` fingerprint. See [Gateway Settings Channel](gateway-settings.md) for details. + +For more detail on the policy language, see [Policy Language](security-policy.md). ### Command-Line Interface @@ -297,4 +299,5 @@ This opens an interactive SSH session into the sandbox, with all provider creden | [Policy Language](security-policy.md) | The YAML/Rego policy system that governs sandbox behavior. | | [Inference Routing](inference-routing.md) | Transparent interception and sandbox-local routing of AI inference API calls to configured backends. | | [System Architecture](system-architecture.md) | Top-level system architecture diagram with all deployable components and communication flows. | +| [Gateway Settings Channel](gateway-settings.md) | Runtime settings channel: two-tier key-value configuration, global policy override, settings registry, CLI/TUI commands. | | [TUI](tui.md) | Terminal user interface for sandbox interaction. | diff --git a/architecture/gateway-settings.md b/architecture/gateway-settings.md new file mode 100644 index 00000000..f1f421f5 --- /dev/null +++ b/architecture/gateway-settings.md @@ -0,0 +1,398 @@ +# Gateway Settings Channel + +## Overview + +The settings channel provides a two-tier key-value configuration system that the gateway delivers to sandboxes alongside policy. Settings are runtime-mutable name-value pairs (e.g., `log_level`, feature flags) that flow from the gateway to sandboxes through the existing `GetSandboxSettings` poll loop. The system supports two scopes -- sandbox-level and global -- with a deterministic merge strategy and per-key mutual exclusion to prevent conflicting ownership. + +## Architecture + +```mermaid +graph TD + CLI["CLI / TUI"] + GW["Gateway
(openshell-server)"] + DB["Store
(objects table)"] + SB["Sandbox
(poll loop)"] + + CLI -- "UpdateSandboxPolicy
(setting_key + value)" --> GW + CLI -- "GetSandboxSettings
GetGatewaySettings" --> GW + GW -- "load/save
gateway_settings
sandbox_settings" --> DB + GW -- "GetSandboxSettingsResponse
(policy + settings + config_revision)" --> SB + SB -- "diff settings
reload OPA on policy change" --> SB +``` + +## Settings Registry + +**File:** `crates/openshell-core/src/settings.rs` + +The `REGISTERED_SETTINGS` static array defines the allowed setting keys and their value types. The registry is the source of truth for both client-side validation (CLI, TUI) and server-side enforcement. + +```rust +pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ + RegisteredSetting { key: "log_level", kind: SettingValueKind::String }, + RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int }, + RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool }, +]; +``` + +| Type | Proto variant | Description | +|------|---------------|-------------| +| `String` | `SettingValue.string_value` | Arbitrary UTF-8 string | +| `Int` | `SettingValue.int_value` | 64-bit signed integer | +| `Bool` | `SettingValue.bool_value` | Boolean; CLI accepts `true/false/yes/no/1/0/on/off` via `parse_bool_like()` | + +The reserved key `policy` is excluded from the registry. It is handled by dedicated policy commands and stored as a hex-encoded protobuf `SandboxPolicy` in the global settings' `Bytes` variant. Attempts to set or delete the `policy` key through settings commands are rejected. + +Helper functions: +- `setting_for_key(key)` -- look up a `RegisteredSetting` by name, returns `None` for unknown keys +- `registered_keys_csv()` -- comma-separated list of valid keys for error messages +- `parse_bool_like(raw)` -- flexible bool parsing from CLI string input + +## Proto Layer + +**File:** `proto/sandbox.proto` + +### New Message Types + +| Message | Fields | Purpose | +|---------|--------|---------| +| `SettingValue` | `oneof value { string_value, bool_value, int_value, bytes_value }` | Type-aware setting value | +| `EffectiveSetting` | `SettingValue value`, `SettingScope scope` | A resolved setting with its controlling scope | +| `SettingScope` enum | `UNSPECIFIED`, `SANDBOX`, `GLOBAL` | Which tier controls the current value | +| `PolicySource` enum | `UNSPECIFIED`, `SANDBOX`, `GLOBAL` | Origin of the policy in a settings response | + +### New RPCs + +**File:** `proto/openshell.proto` + +| RPC | Request | Response | Called by | +|-----|---------|----------|-----------| +| `GetSandboxSettings` | `GetSandboxSettingsRequest { sandbox_id }` | `GetSandboxSettingsResponse { policy, version, policy_hash, settings, config_revision, policy_source }` | Sandbox poll loop, CLI `settings get` | +| `GetGatewaySettings` | `GetGatewaySettingsRequest {}` | `GetGatewaySettingsResponse { settings, settings_revision }` | CLI `settings get --global`, TUI dashboard | + +### Extended `UpdateSandboxPolicyRequest` + +The existing `UpdateSandboxPolicy` RPC now multiplexes policy and setting mutations through additional fields: + +| Field | Type | Description | +|-------|------|-------------| +| `setting_key` | `string` | Key to mutate (mutually exclusive with `policy` payload) | +| `setting_value` | `SettingValue` | Value to set (for upsert operations) | +| `delete_setting` | `bool` | Delete the key from the specified scope | +| `global` | `bool` | Target gateway-global scope instead of sandbox scope | + +Validation rules: +- `policy` and `setting_key` cannot both be present +- At least one of `policy` or `setting_key` must be present +- `delete_setting` cannot be combined with a `policy` payload +- The reserved `policy` key requires the `policy` field (not `setting_key`) for set operations +- `name` is required for sandbox-scoped updates but not for global updates + +## Server Implementation + +**File:** `crates/openshell-server/src/grpc.rs` + +### Storage Model + +Settings are persisted using the existing generic `objects` table with two new object types: + +| Object type string | Record ID | Record name | Purpose | +|--------------------|-----------|-------------|---------| +| `gateway_settings` | `"global"` | `"global"` | Singleton global settings | +| `sandbox_settings` | `"settings:{sandbox_uuid}"` | sandbox name | Per-sandbox settings | + +The sandbox settings ID is prefixed with `settings:` to avoid a primary key collision with the sandbox's own record in the `objects` table. The `sandbox_settings_id()` function computes this key. + +The payload is a JSON-encoded `StoredSettings` struct: + +```rust +struct StoredSettings { + revision: u64, // Monotonically increasing + settings: BTreeMap, // Sorted for determinism +} + +enum StoredSettingValue { + String(String), + Bool(bool), + Int(i64), + Bytes(String), // Hex-encoded binary (used for global policy) +} +``` + +### Two-Tier Resolution (`merge_effective_settings`) + +The `GetSandboxSettings` handler resolves the effective settings map by merging sandbox and global tiers: + +1. **Seed registered keys**: All keys from `REGISTERED_SETTINGS` are inserted with `scope: UNSPECIFIED` and `value: None`. This ensures registered keys always appear in the response even when unset. +2. **Apply sandbox values**: Sandbox-scoped settings overlay the registered defaults. Scope becomes `SANDBOX`. +3. **Apply global values**: Global settings override sandbox values. Scope becomes `GLOBAL`. +4. **Exclude reserved keys**: The `policy` key is excluded from the merged settings map (it is delivered as the top-level `policy` field in the response). + +```mermaid +flowchart LR + REG["REGISTERED_SETTINGS
(seed: scope=UNSPECIFIED)"] + SB["Sandbox settings
(scope=SANDBOX)"] + GL["Global settings
(scope=GLOBAL)"] + OUT["Effective settings map"] + + REG --> OUT + SB -->|"overlay"| OUT + GL -->|"override"| OUT +``` + +### Global Policy as a Setting + +The reserved `policy` key in global settings stores a protobuf-encoded `SandboxPolicy`. When present, `GetSandboxSettings` uses the global policy instead of the sandbox's own policy: + +1. `decode_policy_from_global_settings()` checks for the `policy` key in global settings +2. If present, the global policy replaces the sandbox policy in the response +3. `policy_source` is set to `GLOBAL` +4. The sandbox policy version counter is preserved for status APIs + +This allows operators to push a single policy that applies to all sandboxes via `openshell policy set --global --policy FILE`. + +### Config Revision (`compute_config_revision`) + +The `config_revision` field is a 64-bit fingerprint that changes whenever the effective configuration changes. The sandbox poll loop compares this value to detect changes without re-parsing the full response. + +Computation: +1. Hash `policy_source` as 4 little-endian bytes +2. Hash the deterministic policy hash (if policy present) +3. Sort settings entries by key +4. For each entry: hash key bytes, scope as 4 LE bytes, then a type tag byte + value bytes +5. Truncate the SHA-256 digest to 8 bytes and interpret as `u64` (little-endian) + +### Per-Key Mutual Exclusion + +Global and sandbox scopes cannot both control the same key simultaneously: + +| Operation | Global key exists | Behavior | +|-----------|-------------------|----------| +| Sandbox set | Yes | `FailedPrecondition`: "setting '{key}' is managed globally; delete the global setting before sandbox update" | +| Sandbox delete | Yes | `FailedPrecondition`: "setting '{key}' is managed globally; delete the global setting first" | +| Sandbox set | No | Allowed | +| Sandbox delete | No | Allowed | +| Global set | (any) | Always allowed (global overrides) | +| Global delete | (any) | Allowed; unlocks sandbox control for the key | + +This prevents conflicting values at different scopes. An operator must delete a global key before a sandbox-level value can be set for the same key. + +### Sandbox-Scoped Policy Update Interaction + +When a global policy is set, sandbox-scoped policy updates via `UpdateSandboxPolicy` are rejected with `FailedPrecondition`: + +``` +policy is managed globally; delete global policy before sandbox policy update +``` + +Deleting the global policy (`openshell policy delete --global`) removes the `policy` key from global settings and restores sandbox-level policy control. + +## Sandbox Implementation + +### Poll Loop Changes + +**File:** `crates/openshell-sandbox/src/lib.rs` (`run_policy_poll_loop`) + +The poll loop uses `GetSandboxSettings` (not a policy-specific RPC) and tracks `config_revision` as the change-detection signal: + +1. **Fetch initial state**: Call `poll_settings(sandbox_id)` to establish baseline `current_config_revision`, `current_policy_hash`, and `current_settings`. +2. **On each tick**: Compare `result.config_revision` against `current_config_revision`. If unchanged, skip. +3. **Determine what changed**: + - Compare `result.policy_hash` against `current_policy_hash` to detect policy changes + - Call `log_setting_changes()` to diff the settings map and log individual changes +4. **Conditional OPA reload**: Only call `opa_engine.reload_from_proto()` when `policy_hash` changes. Settings-only changes update the tracked state without touching the OPA engine. +5. **Status reporting**: Report policy load status only for sandbox-scoped revisions (`policy_source == SANDBOX` and `version > 0`). Global policy overrides trigger a reload but do not write per-sandbox policy status history. + +```mermaid +sequenceDiagram + participant PL as Poll Loop + participant GW as Gateway + participant OPA as OPA Engine + + PL->>GW: GetSandboxSettings(sandbox_id) + GW-->>PL: policy + settings + config_revision + + loop Every interval (default 10s) + PL->>GW: GetSandboxSettings(sandbox_id) + GW-->>PL: response + + alt config_revision unchanged + PL->>PL: Skip + else config_revision changed + PL->>PL: log_setting_changes(old, new) + alt policy_hash changed + PL->>OPA: reload_from_proto(policy) + PL->>GW: ReportPolicyStatus (if sandbox-scoped) + else settings-only change + PL->>PL: Update tracked state (no OPA reload) + end + end + end +``` + +### Per-Setting Diff Logging + +**File:** `crates/openshell-sandbox/src/lib.rs` (`log_setting_changes`) + +When `config_revision` changes, the sandbox logs each individual setting change: + +- **Changed**: `info!(key, old, new, "Setting changed")` -- logs old and new values +- **Added**: `info!(key, value, "Setting added")` -- new key not in previous snapshot +- **Removed**: `info!(key, "Setting removed")` -- key in previous snapshot but not in new + +Values are formatted by `format_setting_value()`: strings as-is, bools and ints as their string representation, bytes as ``, unset as ``. + +### `SettingsPollResult` + +**File:** `crates/openshell-sandbox/src/grpc_client.rs` + +```rust +pub struct SettingsPollResult { + pub policy: Option, + pub version: u32, + pub policy_hash: String, + pub config_revision: u64, + pub policy_source: PolicySource, + pub settings: HashMap, +} +``` + +The `poll_settings()` method maps the full `GetSandboxSettingsResponse` into this struct. The `settings` field carries the effective settings map for diff logging. + +## CLI Commands + +**File:** `crates/openshell-cli/src/main.rs` (`SettingsCommands`), `crates/openshell-cli/src/run.rs` + +### `settings get [name] [--global]` + +Display effective settings for a sandbox or the gateway-global scope. + +```bash +# Sandbox-scoped effective settings +openshell settings get my-sandbox + +# Gateway-global settings +openshell settings get --global +``` + +Sandbox output includes: sandbox name, config revision, policy source (sandbox/global), policy hash, and a table of settings with key, value, and scope (sandbox/global/unset). + +Global output includes: scope label, settings revision, and a table of settings with key and value. Registered keys without a configured value display as ``. + +### `settings set [name] --key K --value V [--global] [--yes]` + +Set a single setting key at sandbox or global scope. + +```bash +# Sandbox-scoped +openshell settings set my-sandbox --key log_level --value debug + +# Global (requires confirmation) +openshell settings set --global --key log_level --value warn +openshell settings set --global --key dummy_bool --value yes +openshell settings set --global --key dummy_int --value 42 + +# Skip confirmation +openshell settings set --global --key log_level --value info --yes +``` + +Value parsing is type-aware: bool keys accept `true/false/yes/no/1/0/on/off` via `parse_bool_like()`. Int keys parse as base-10 `i64`. String keys accept any value. + +### `settings delete [name] --key K [--global] [--yes]` + +Delete a setting key from the specified scope. + +```bash +# Global delete (unlocks sandbox control) +openshell settings delete --global --key log_level --yes +``` + +### `policy set --global --policy FILE [--yes]` + +Set a gateway-global policy that overrides all sandbox policies. + +```bash +openshell policy set --global --policy policy.yaml --yes +``` + +The `--wait` flag is not supported for global policy updates. + +### `policy delete --global [--yes]` + +Delete the gateway-global policy, restoring sandbox-level policy control. + +```bash +openshell policy delete --global --yes +``` + +### HITL Confirmation + +All `--global` mutations require human-in-the-loop confirmation via an interactive prompt. The `--yes` flag bypasses the prompt for scripted/CI usage. In non-interactive mode (no TTY), `--yes` is required -- otherwise the command fails with an error. + +The confirmation message varies: +- **Global setting set**: warns that this will override sandbox-level values for the key +- **Global setting delete**: warns that this re-enables sandbox-level management +- **Global policy set**: warns that this overrides all sandbox policies +- **Global policy delete**: warns that this restores sandbox-level control + +## TUI Integration + +**File:** `crates/openshell-tui/src/` + +### Dashboard: Global Settings Tab + +The dashboard's middle pane has a tabbed interface: **Providers** | **Global Settings**. Press `Tab` to switch. + +The Global Settings tab displays registered keys with their current values, fetched via `GetGatewaySettings`. Features: + +- **Navigate**: `j`/`k` or arrow keys to select a setting +- **Edit** (`Enter`): Opens a type-aware editor: + - Bool keys: toggle between true/false + - String/Int keys: text input field +- **Delete** (`d`): Remove the selected key's value +- **Confirmation modals**: Both edit and delete operations show a confirmation dialog before applying +- **Scope indicators**: Each key shows its current value or `` + +### Sandbox Screen: Settings Tab + +The sandbox detail view's bottom pane has a tabbed interface: **Policy** | **Settings**. Press `l` to switch tabs. + +The Settings tab shows effective settings for the selected sandbox, fetched as part of the `GetSandboxSettings` response. Features: + +- Same navigation and editing as the global settings tab +- **Scope indicators**: Each key shows `(sandbox)`, `(global)`, or `(unset)` to indicate the controlling tier +- Sandbox-scoped edits are blocked for globally-managed keys (server returns `FailedPrecondition`) + +### Data Refresh + +Settings are refreshed on each 2-second polling tick alongside the sandbox list and health status. The global settings revision is tracked to detect changes. Sandbox settings are refreshed when viewing a specific sandbox. + +## Data Flow: Setting a Global Key + +End-to-end trace for `openshell settings set --global --key log_level --value debug --yes`: + +1. **CLI** (`crates/openshell-cli/src/run.rs` -- `gateway_setting_set()`): + - `parse_cli_setting_value("log_level", "debug")` -- looks up `SettingValueKind::String` in the registry, wraps as `SettingValue { string_value: "debug" }` + - `confirm_global_setting_takeover()` -- skipped because `--yes` + - Sends `UpdateSandboxPolicyRequest { setting_key: "log_level", setting_value: Some(...), global: true }` + +2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_sandbox_policy()`): + - Detects `global=true`, `has_setting=true` + - `validate_registered_setting_key("log_level")` -- passes (key is in registry) + - `load_global_settings()` -- reads `gateway_settings` record from store + - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::String("debug")` + - `upsert_setting_value()` -- inserts into `BTreeMap`, returns `true` (changed) + - Increments `revision`, calls `save_global_settings()` + - Returns `UpdateSandboxPolicyResponse { settings_revision: N }` + +3. **Sandbox** (next poll tick in `run_policy_poll_loop()`): + - `poll_settings(sandbox_id)` returns new `config_revision` + - `log_setting_changes()` logs: `Setting changed key="log_level" old="" new="debug"` + - `policy_hash` unchanged -- no OPA reload + - Updates tracked `current_config_revision` and `current_settings` + +## Cross-References + +- [Gateway Architecture](gateway.md) -- Persistence layer, gRPC service, object types +- [Sandbox Architecture](sandbox.md) -- Poll loop, `CachedOpenShellClient`, OPA reload lifecycle +- [Policy Language](security-policy.md) -- Live policy updates, global policy CLI commands +- [TUI](tui.md) -- Settings tabs in dashboard and sandbox views diff --git a/architecture/gateway.md b/architecture/gateway.md index 66da28f7..ca569e74 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -82,7 +82,7 @@ Proto definitions consumed by the gateway: | `proto/openshell.proto` | `openshell.v1` | `OpenShell` service, sandbox/provider/SSH/watch messages | | `proto/inference.proto` | `openshell.inference.v1` | `Inference` service: `SetClusterInference`, `GetClusterInference`, `GetInferenceBundle` | | `proto/datamodel.proto` | `openshell.datamodel.v1` | `Sandbox`, `SandboxSpec`, `SandboxStatus`, `Provider`, `SandboxPhase` | -| `proto/sandbox.proto` | `openshell.sandbox.v1` | `SandboxPolicy`, `NetworkPolicyRule` | +| `proto/sandbox.proto` | `openshell.sandbox.v1` | `SandboxPolicy`, `NetworkPolicyRule`, `SettingValue`, `EffectiveSetting`, `SettingScope`, `PolicySource`, `GetSandboxSettingsRequest/Response`, `GetGatewaySettingsRequest/Response` | ## Startup Sequence @@ -225,13 +225,14 @@ Full CRUD for `Provider` objects, which store typed credentials (e.g., API keys | `UpdateProvider` | Updates an existing provider by name. Preserves the stored `id` and `name`; replaces `type`, `credentials`, and `config`. | | `DeleteProvider` | Deletes a provider by name. Returns `deleted: true/false`. | -#### Policy and Provider Environment Delivery +#### Policy, Settings, and Provider Environment Delivery -These RPCs are called by sandbox pods at startup to bootstrap themselves. +These RPCs are called by sandbox pods at startup and during runtime polling. | RPC | Description | |-----|-------------| -| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata, and effective settings. Global settings override sandbox-level values per key. | +| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata (version, hash, source), merged effective settings, and a `config_revision` fingerprint for change detection. Two-tier resolution: registered keys start unset, sandbox values overlay, global values override. The reserved `policy` key in global settings can override the sandbox's own policy. See [Gateway Settings Channel](gateway-settings.md). | +| `GetGatewaySettings` | Returns gateway-global settings only (excluding the reserved `policy` key). Returns registered keys with empty values when unconfigured, and a monotonic `settings_revision`. | | `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. | #### Policy Recommendation (Network Rules) @@ -457,12 +458,14 @@ Objects are identified by `(object_type, id)` with a unique constraint on `(obje ### Object Types -| Object type string | Proto message | Traits implemented | -|--------------------|---------------|-------------------| -| `"sandbox"` | `Sandbox` | `ObjectType`, `ObjectId`, `ObjectName` | -| `"provider"` | `Provider` | `ObjectType`, `ObjectId`, `ObjectName` | -| `"ssh_session"` | `SshSession` | `ObjectType`, `ObjectId`, `ObjectName` | -| `"inference_route"` | `InferenceRoute` | `ObjectType`, `ObjectId`, `ObjectName` | +| Object type string | Proto message / format | Traits implemented | Notes | +|--------------------|------------------------|-------------------|-------| +| `"sandbox"` | `Sandbox` | `ObjectType`, `ObjectId`, `ObjectName` | | +| `"provider"` | `Provider` | `ObjectType`, `ObjectId`, `ObjectName` | | +| `"ssh_session"` | `SshSession` | `ObjectType`, `ObjectId`, `ObjectName` | | +| `"inference_route"` | `InferenceRoute` | `ObjectType`, `ObjectId`, `ObjectName` | | +| `"gateway_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Singleton, id=`"global"` | +| `"sandbox_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Per-sandbox, id=`"settings:{sandbox_uuid}"` | ### Generic Protobuf Codec @@ -559,6 +562,7 @@ Updated by the sandbox watcher on every Applied event and by gRPC handlers durin ## Cross-References - [Sandbox Architecture](sandbox.md) -- sandbox-side policy enforcement, proxy, and isolation details +- [Gateway Settings Channel](gateway-settings.md) -- runtime settings channel, two-tier resolution, CLI/TUI commands - [Inference Routing](inference-routing.md) -- end-to-end inference interception flow, sandbox-side proxy logic, and route resolution - [Container Management](build-containers.md) -- how sandbox container images are built and configured - [Sandbox Connect](sandbox-connect.md) -- client-side SSH connection flow diff --git a/architecture/sandbox.md b/architecture/sandbox.md index e6698f00..2a953013 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -315,31 +315,38 @@ The gateway's `UpdateSandboxPolicy` RPC enforces this boundary: it rejects any u ### Poll loop +The poll loop tracks `config_revision` (a fingerprint of policy + settings + source) as the primary change-detection signal. It separately tracks `policy_hash` to determine whether an OPA reload is needed -- settings-only changes do not trigger OPA reloads. + ```mermaid sequenceDiagram - participant PL as Policy Poll Loop + participant PL as Settings Poll Loop participant GW as Gateway (gRPC) participant OPA as OPA Engine (Arc) PL->>GW: GetSandboxSettings(sandbox_id) - GW-->>PL: policy + version + hash - PL->>PL: Store initial version + GW-->>PL: policy + settings + config_revision + PL->>PL: Store initial config_revision, policy_hash, settings loop Every OPENSHELL_POLICY_POLL_INTERVAL_SECS (default 10) PL->>GW: GetSandboxSettings(sandbox_id) - GW-->>PL: policy + version + hash - alt version > current_version - PL->>OPA: reload_from_proto(policy) - alt Reload succeeds - OPA-->>PL: Ok - PL->>PL: Update current_version - PL->>GW: ReportPolicyStatus(version, LOADED) - else Reload fails (validation error) - OPA-->>PL: Err (old engine untouched) - PL->>GW: ReportPolicyStatus(version, FAILED, error_msg) + GW-->>PL: policy + settings + config_revision + alt config_revision unchanged + PL->>PL: Skip + else config_revision changed + PL->>PL: log_setting_changes(old_settings, new_settings) + alt policy_hash changed + PL->>OPA: reload_from_proto(policy) + alt Reload succeeds + OPA-->>PL: Ok + PL->>PL: Update tracked state + PL->>GW: ReportPolicyStatus(version, LOADED) + else Reload fails (validation error) + OPA-->>PL: Err (old engine untouched) + PL->>GW: ReportPolicyStatus(version, FAILED, error_msg) + end + else settings-only change + PL->>PL: Update tracked state (no OPA reload) end - else version <= current_version - PL->>PL: Skip (no update) end end ``` @@ -347,11 +354,13 @@ sequenceDiagram The `run_policy_poll_loop()` function in `crates/openshell-sandbox/src/lib.rs` implements this loop: 1. **Connect once**: Create a `CachedOpenShellClient` that holds a persistent mTLS channel to the gateway. This avoids TLS renegotiation on every poll. -2. **Fetch initial config revision**: Call `poll_settings(sandbox_id)` to establish baseline `current_config_revision`. On failure, log a warning and retry on the next interval. +2. **Fetch initial state**: Call `poll_settings(sandbox_id)` to establish baseline `current_config_revision`, `current_policy_hash`, and `current_settings` map. On failure, log a warning and retry on the next interval. 3. **Poll loop**: Sleep for the configured interval, then call `poll_settings()` again. 4. **Config comparison**: If `result.config_revision == current_config_revision`, skip. -5. **Reload attempt**: Call `opa_engine.reload_from_proto(policy)` when a policy payload is present. This runs the full `from_proto()` pipeline on the new policy, then atomically swaps the inner engine. -6. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still reload, but they do not write per-sandbox policy status history. +5. **Per-setting diff logging**: Call `log_setting_changes()` to diff old and new settings maps. Each individual change is logged with old and new values. +6. **Conditional OPA reload**: Only call `opa_engine.reload_from_proto(policy)` when `policy_hash` changes. Settings-only changes (e.g., `log_level` updated) update the tracked state without touching the OPA engine. +7. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still reload, but they do not write per-sandbox policy status history. +8. **Update tracked state**: After processing, update `current_config_revision`, `current_policy_hash`, and `current_settings` regardless of whether OPA was reloaded. ### `CachedOpenShellClient` @@ -370,25 +379,31 @@ pub struct SettingsPollResult { pub policy_hash: String, pub config_revision: u64, pub policy_source: PolicySource, + pub settings: HashMap, } ``` Methods: - **`connect(endpoint)`**: Establish an mTLS channel and return a new client. -- **`poll_settings(sandbox_id)`**: Call `GetSandboxSettings` RPC and return a `SettingsPollResult` containing policy payload (optional), policy metadata, effective config revision, and policy source. +- **`poll_settings(sandbox_id)`**: Call `GetSandboxSettings` RPC and return a `SettingsPollResult` containing policy payload (optional), policy metadata, effective config revision, policy source, and the effective settings map (for diff logging). - **`report_policy_status(sandbox_id, version, loaded, error_msg)`**: Call `ReportPolicyStatus` RPC with the appropriate `PolicyStatus` enum value (`Loaded` or `Failed`). - **`raw_client()`**: Return a clone of the underlying `OpenShellClient` for direct RPC calls (used by the log push task). ### Server-side policy versioning -The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxSettingsResponse` now also carries effective settings and a `config_revision` fingerprint that changes when effective policy/settings change (including global overrides). +The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxSettingsResponse` carries the full effective configuration: policy payload, effective settings map (with per-key scope indicators), a `config_revision` fingerprint that changes when any effective input changes (policy, settings, or source), and a `policy_source` field indicating whether the policy came from the sandbox's own history or from a global override. Proto messages involved: -- `GetSandboxSettingsResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings`, `config_revision`, `policy_source` +- `GetSandboxSettingsResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings` (map of `EffectiveSetting`), `config_revision`, `policy_source` +- `EffectiveSetting` (`proto/sandbox.proto`): `SettingValue value`, `SettingScope scope` +- `SettingScope` enum: `UNSPECIFIED`, `SANDBOX`, `GLOBAL` +- `PolicySource` enum: `UNSPECIFIED`, `SANDBOX`, `GLOBAL` - `ReportPolicyStatusRequest` (`proto/openshell.proto`): `sandbox_id`, `version`, `status` (enum), `load_error` - `PolicyStatus` enum: `PENDING`, `LOADED`, `FAILED`, `SUPERSEDED` - `SandboxPolicyRevision` (`proto/openshell.proto`): Full revision metadata including `created_at_ms`, `loaded_at_ms` +See [Gateway Settings Channel](gateway-settings.md) for full details on the settings resolution model, storage, and CLI/TUI commands. + ### Failure modes | Condition | Behavior | diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 5573cdee..6244edc4 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -206,34 +206,51 @@ Failure scenarios that trigger LKG behavior include: ### CLI Commands -The `nav policy` subcommand group manages live policy updates: +The `openshell policy` subcommand group manages live policy updates: ```bash # Push a new policy to a running sandbox -nav policy set --policy updated-policy.yaml +openshell policy set --policy updated-policy.yaml # Push and wait for the sandbox to load it (with 60s timeout) -nav policy set --policy updated-policy.yaml --wait +openshell policy set --policy updated-policy.yaml --wait # Push and wait with a custom timeout -nav policy set --policy updated-policy.yaml --wait --timeout 120 +openshell policy set --policy updated-policy.yaml --wait --timeout 120 + +# Set a gateway-global policy (overrides all sandbox policies) +openshell policy set --global --policy policy.yaml --yes + +# Delete the gateway-global policy (restores sandbox-level control) +openshell policy delete --global --yes # View the current active policy and its status -nav policy get +openshell policy get # Inspect a specific revision -nav policy get --rev 3 +openshell policy get --rev 3 # Print the full policy as YAML (round-trips with --policy input format) -nav policy get --full +openshell policy get --full # Combine: inspect a specific revision's full policy -nav policy get --rev 2 --full +openshell policy get --rev 2 --full # List policy revision history -nav policy list --limit 20 +openshell policy list --limit 20 ``` +#### Global Policy + +The `--global` flag on `policy set` and `policy delete` manages a gateway-wide policy override. When a global policy is set, all sandboxes receive it through `GetSandboxSettings` (with `policy_source: GLOBAL`) instead of their own per-sandbox policy. This is implemented via the reserved `policy` key in the gateway settings store. See [Gateway Settings Channel](gateway-settings.md#global-policy-as-a-setting) for implementation details. + +| Command | Behavior | +|---------|----------| +| `policy set --global --policy FILE` | Stores the policy as a hex-encoded protobuf in the global settings under the reserved `policy` key. All sandboxes pick it up on their next poll. | +| `policy delete --global` | Removes the `policy` key from global settings. Sandboxes revert to their per-sandbox policy on the next poll. | + +Both commands require interactive confirmation (or `--yes` to bypass). The `--wait` flag is not supported for global policy updates because there is no single sandbox to track status for. + #### `policy get` flags | Flag | Default | Description | @@ -1382,6 +1399,7 @@ An empty `sources`/`log_sources` list means no source filtering (all sources pas - [Sandbox Architecture](sandbox.md) -- Full sandbox lifecycle, enforcement mechanisms, and component interaction - [Gateway Architecture](gateway.md) -- How the gateway stores and delivers policies via gRPC +- [Gateway Settings Channel](gateway-settings.md) -- Runtime settings channel, global policy override, CLI/TUI settings commands - [Inference Routing](inference-routing.md) -- How `inference.local` requests are routed to model backends - [Overview](README.md) -- System-level context for how policies fit into the platform - [Plain HTTP Forward Proxy Plan](plans/plain-http-forward-proxy.md) -- Design document for the forward proxy feature diff --git a/architecture/system-architecture.md b/architecture/system-architecture.md index d63f3221..290d27c6 100644 --- a/architecture/system-architecture.md +++ b/architecture/system-architecture.md @@ -123,7 +123,7 @@ graph TB %% ============================================================ %% CONNECTIONS: Sandbox --> Gateway (control plane) %% ============================================================ - Supervisor -- "gRPC (mTLS):
GetSandboxSettings,
GetProviderEnvironment,
GetInferenceBundle,
PushSandboxLogs" --> Gateway + Supervisor -- "gRPC (mTLS):
GetSandboxSettings
(policy + settings),
GetProviderEnvironment,
GetInferenceBundle,
PushSandboxLogs" --> Gateway %% ============================================================ %% CONNECTIONS: Sandbox --> External (via proxy) @@ -197,4 +197,4 @@ graph TB 5. **Inference Routing**: Inference requests are handled inside the sandbox by the openshell-router (not through the gateway). The gateway provides route configuration and credentials via gRPC; the sandbox executes HTTP requests directly to inference backends. -6. **Sandbox to Gateway**: The sandbox supervisor uses gRPC (mTLS) to fetch policies, provider credentials, inference bundles, and to push logs back to the gateway. +6. **Sandbox to Gateway**: The sandbox supervisor uses gRPC (mTLS) to fetch policies and runtime settings (via `GetSandboxSettings`), provider credentials, inference bundles, and to push logs back to the gateway. The settings channel delivers typed key-value pairs alongside policy through a unified poll loop. diff --git a/architecture/tui.md b/architecture/tui.md index f54452c8..8dbcb504 100644 --- a/architecture/tui.md +++ b/architecture/tui.md @@ -48,15 +48,33 @@ The TUI divides the terminal into four horizontal regions: ### Dashboard (press `1`) -The Dashboard is the home screen. It shows your cluster at a glance: +The Dashboard is the home screen. It shows your cluster at a glance. -- **Cluster name** and **gateway endpoint** — which cluster you are connected to and how to reach it. -- **Health status** — a live indicator that polls the cluster every 2 seconds: +The dashboard is divided into a top info pane and a middle pane with two tabs: + +- **Top pane**: Cluster name, gateway endpoint, health status, sandbox count. +- **Middle pane**: Tabbed view toggled with `Tab`: + - **Providers** — provider configurations attached to the cluster. + - **Global Settings** — gateway-global runtime settings (fetched via `GetGatewaySettings`). + +**Health status** indicators: - `●` **Healthy** (green) — everything is running normally. - `◐` **Degraded** (yellow) — the cluster is up but something needs attention. - `○` **Unhealthy** (red) — the cluster is not operating correctly. - `…` — still connecting or status unknown. -- **Sandbox count** — how many sandboxes exist in the cluster. + +#### Global Settings Tab + +The Global Settings tab shows all registered setting keys with their current values. Keys without a configured value display as ``. + +| Key | Action | +|-----|--------| +| `j` / `↓` | Move selection down | +| `k` / `↑` | Move selection up | +| `Enter` | Edit the selected setting (type-aware: bool toggle, string/int text input) | +| `d` | Delete the selected setting's value | + +Both edit and delete operations display a confirmation modal before applying. Changes are sent to the gateway via the `UpdateSandboxPolicy` RPC with `global: true`. ### Sandboxes (press `2`) @@ -82,6 +100,21 @@ Use `j`/`k` or the arrow keys to move through the list. The selected row is high When there are no sandboxes, the view displays: *"No sandboxes found."* +When viewing a specific sandbox (by pressing `Enter` on a selected row), the bottom pane shows a tabbed view toggled with `l`: + +- **Policy** — the sandbox's current active policy, auto-refreshed on version change. +- **Settings** — effective runtime settings for the sandbox (fetched via `GetSandboxSettings`). + +#### Sandbox Settings Tab + +The Settings tab shows all registered setting keys with their effective values and scope indicators: + +- **(sandbox)** — value is set at sandbox scope +- **(global)** — value is set at gateway-global scope (overrides sandbox) +- **(unset)** — no value configured at any scope + +Navigation and editing use the same keys as the Global Settings tab (`j`/`k`, `Enter` to edit, `d` to delete). Sandbox-scoped edits to globally-managed keys are rejected by the server with a `FailedPrecondition` error. + ## Keyboard Controls The TUI has two input modes: **Normal** (default) and **Command** (activated by pressing `:`). @@ -112,10 +145,12 @@ Press `Esc` to cancel and return to Normal mode. `Backspace` deletes characters ## Data Refresh -The TUI automatically polls the cluster every **2 seconds**. Both cluster health and the sandbox list update on each tick, so the display stays current without manual refreshing. This uses the same gRPC calls as the CLI — no additional server-side setup is required. +The TUI automatically polls the cluster every **2 seconds**. Cluster health, the sandbox list, and global settings all update on each tick, so the display stays current without manual refreshing. This uses the same gRPC calls as the CLI — no additional server-side setup is required. When viewing a sandbox, the policy pane auto-refreshes when a new policy version is detected. The sandbox list response includes `current_policy_version` for each sandbox; on every tick the TUI compares this against the currently displayed policy version and re-fetches the full policy only when they differ. This avoids extra RPCs during normal operation while ensuring policy updates appear within the polling interval. The user's scroll position is preserved across auto-refreshes. +Global settings are refreshed via `GetGatewaySettings` and tracked by `settings_revision` to detect changes. Sandbox settings are fetched as part of the `GetSandboxSettings` response when viewing a specific sandbox. + ## Theme The TUI uses a dark terminal theme based on the NVIDIA brand palette: @@ -143,9 +178,9 @@ The forwarding implementation lives in `openshell-core::forward`, shared between ## What is Not Yet Available -The TUI is in its initial phase. The following features are planned but not yet implemented: +The TUI is in active development. The following features are planned but not yet implemented: -- **Inference and provider views** — browsing inference routes and provider configurations. +- **Inference views** — browsing inference routes and configuration. - **Help overlay** — the `?` key is shown in the nav bar but does not open a help screen yet. - **Command bar autocomplete** — the command bar accepts text but does not offer suggestions. - **Filtering and search** — no `/` search within views yet. From 87b097e1153a47dd69ff6f3f9f95f48226c303d2 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:15:52 -0700 Subject: [PATCH 10/28] fix(settings): add mutex to serialize settings mutations and prevent read-modify-write races --- crates/openshell-server/src/grpc.rs | 656 +++++++++++++++++++++++++++- crates/openshell-server/src/lib.rs | 7 + 2 files changed, 662 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 3d53e3cd..7eb396e1 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1075,6 +1075,10 @@ impl OpenShell for OpenShellService { } if req.global { + // Acquire the settings mutex for the entire global mutation to + // prevent read-modify-write races between concurrent requests. + let _settings_guard = self.state.settings_mutex.lock().await; + if has_policy { if req.delete_setting { return Err(Status::invalid_argument( @@ -1160,6 +1164,10 @@ impl OpenShell for OpenShellService { let sandbox_id = sandbox.id.clone(); if has_setting { + // Acquire the settings mutex to prevent races between the + // global-precedence check and the sandbox settings write. + let _settings_guard = self.state.settings_mutex.lock().await; + if key == POLICY_SETTING_KEY { return Err(Status::invalid_argument( "reserved key 'policy' must be set via policy commands", @@ -2689,7 +2697,12 @@ fn sandbox_settings_id(sandbox_id: &str) -> String { } async fn load_sandbox_settings(store: &Store, sandbox_id: &str) -> Result { - load_settings_record(store, SANDBOX_SETTINGS_OBJECT_TYPE, &sandbox_settings_id(sandbox_id)).await + load_settings_record( + store, + SANDBOX_SETTINGS_OBJECT_TYPE, + &sandbox_settings_id(sandbox_id), + ) + .await } async fn save_sandbox_settings( @@ -5369,4 +5382,645 @@ mod tests { let stored = super::proto_setting_to_stored("dummy_bool", &value).unwrap(); assert_eq!(stored, super::StoredSettingValue::Bool(true)); } + + // ---- merge_effective_settings: sandbox-scoped values ---- + + #[test] + fn merge_effective_settings_sandbox_scoped_value_has_sandbox_scope() { + let global = super::StoredSettings::default(); + let sandbox = super::StoredSettings { + revision: 1, + settings: [( + "log_level".to_string(), + super::StoredSettingValue::String("debug".to_string()), + )] + .into_iter() + .collect(), + }; + + let merged = super::merge_effective_settings(&global, &sandbox).unwrap(); + let log_level = merged.get("log_level").expect("log_level present"); + assert_eq!( + log_level.scope, + openshell_core::proto::SettingScope::Sandbox as i32, + "sandbox-set key should have SANDBOX scope" + ); + assert!( + log_level.value.is_some(), + "sandbox-set key should have a value" + ); + } + + #[test] + fn merge_effective_settings_unset_key_has_unspecified_scope_and_no_value() { + let global = super::StoredSettings::default(); + let sandbox = super::StoredSettings::default(); + + let merged = super::merge_effective_settings(&global, &sandbox).unwrap(); + for registered in openshell_core::settings::REGISTERED_SETTINGS { + let setting = merged.get(registered.key).unwrap(); + assert_eq!( + setting.scope, + openshell_core::proto::SettingScope::Unspecified as i32, + "unset key '{}' should have UNSPECIFIED scope", + registered.key, + ); + assert!( + setting.value.is_none(), + "unset key '{}' should have no value", + registered.key, + ); + } + } + + #[test] + fn merge_effective_settings_policy_key_is_excluded() { + let global = super::StoredSettings { + revision: 1, + settings: [( + "policy".to_string(), + super::StoredSettingValue::Bytes("deadbeef".to_string()), + )] + .into_iter() + .collect(), + }; + let sandbox = super::StoredSettings { + revision: 1, + settings: [( + "policy".to_string(), + super::StoredSettingValue::Bytes("cafebabe".to_string()), + )] + .into_iter() + .collect(), + }; + + let merged = super::merge_effective_settings(&global, &sandbox).unwrap(); + assert!( + !merged.contains_key("policy"), + "policy key must not appear in effective settings" + ); + } + + // ---- sandbox_settings_id prefix ---- + + #[test] + fn sandbox_settings_id_has_prefix_preventing_collision() { + let sandbox_id = "abc-123"; + let settings_id = super::sandbox_settings_id(sandbox_id); + assert!( + settings_id.starts_with("settings:"), + "settings ID should be prefixed" + ); + assert_ne!( + settings_id, sandbox_id, + "settings ID must differ from sandbox ID" + ); + } + + #[test] + fn sandbox_settings_id_different_sandboxes_produce_different_ids() { + let id_a = super::sandbox_settings_id("sandbox-1"); + let id_b = super::sandbox_settings_id("sandbox-2"); + assert_ne!(id_a, id_b); + } + + #[test] + fn sandbox_settings_id_embeds_sandbox_id() { + let sandbox_id = "some-uuid-value"; + let settings_id = super::sandbox_settings_id(sandbox_id); + assert!( + settings_id.contains(sandbox_id), + "settings ID should embed the original sandbox ID" + ); + } + + // ---- compute_config_revision ---- + + #[test] + fn config_revision_stable_when_nothing_changes() { + let policy = openshell_core::proto::SandboxPolicy::default(); + let mut settings = HashMap::new(); + settings.insert( + "log_level".to_string(), + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "info".to_string(), + )), + }), + scope: openshell_core::proto::SettingScope::Sandbox.into(), + }, + ); + + let rev_a = super::compute_config_revision( + Some(&policy), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + let rev_b = super::compute_config_revision( + Some(&policy), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + assert_eq!(rev_a, rev_b, "revision must be stable for identical inputs"); + } + + #[test] + fn config_revision_changes_when_policy_changes() { + let policy_a = openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }; + let policy_b = openshell_core::proto::SandboxPolicy { + version: 2, + ..Default::default() + }; + let settings = HashMap::new(); + + let rev_a = super::compute_config_revision( + Some(&policy_a), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + let rev_b = super::compute_config_revision( + Some(&policy_b), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + assert_ne!(rev_a, rev_b, "revision must change when policy changes"); + } + + #[test] + fn config_revision_changes_when_policy_source_changes() { + let policy = openshell_core::proto::SandboxPolicy::default(); + let settings = HashMap::new(); + + let rev_a = super::compute_config_revision( + Some(&policy), + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + let rev_b = super::compute_config_revision( + Some(&policy), + &settings, + openshell_core::proto::PolicySource::Global, + ); + assert_ne!( + rev_a, rev_b, + "revision must change when policy source changes" + ); + } + + #[test] + fn config_revision_without_policy_still_hashes_settings() { + let mut settings = HashMap::new(); + settings.insert( + "log_level".to_string(), + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "debug".to_string(), + )), + }), + scope: openshell_core::proto::SettingScope::Sandbox.into(), + }, + ); + + let rev_a = super::compute_config_revision( + None, + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + + settings.insert( + "log_level".to_string(), + openshell_core::proto::EffectiveSetting { + value: Some(openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "warn".to_string(), + )), + }), + scope: openshell_core::proto::SettingScope::Sandbox.into(), + }, + ); + + let rev_b = super::compute_config_revision( + None, + &settings, + openshell_core::proto::PolicySource::Sandbox, + ); + assert_ne!( + rev_a, rev_b, + "revision must change when settings differ, even without policy" + ); + } + + // ---- conflict guard: global overrides block sandbox mutations ---- + + #[tokio::test] + async fn conflict_guard_sandbox_set_blocked_when_global_exists() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + + // Persist a global setting for "log_level". + let mut global = super::StoredSettings::default(); + global.settings.insert( + "log_level".to_string(), + super::StoredSettingValue::String("warn".to_string()), + ); + global.revision = 1; + super::save_global_settings(&store, &global).await.unwrap(); + + // Attempt sandbox-scoped set: check the guard condition. + let loaded_global = super::load_global_settings(&store).await.unwrap(); + let globally_managed = loaded_global.settings.contains_key("log_level"); + assert!( + globally_managed, + "log_level should be globally managed after global set" + ); + // The handler would return FailedPrecondition here. + } + + #[tokio::test] + async fn conflict_guard_sandbox_delete_blocked_when_global_exists() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + + // Persist a global setting for "dummy_int". + let mut global = super::StoredSettings::default(); + global + .settings + .insert("dummy_int".to_string(), super::StoredSettingValue::Int(42)); + global.revision = 1; + super::save_global_settings(&store, &global).await.unwrap(); + + // Check the guard for sandbox-scoped delete. + let loaded_global = super::load_global_settings(&store).await.unwrap(); + assert!( + loaded_global.settings.contains_key("dummy_int"), + "dummy_int should be globally managed" + ); + // The handler would return FailedPrecondition for sandbox delete too. + } + + // ---- delete-unlock: sandbox set succeeds after global delete ---- + + #[tokio::test] + async fn delete_unlock_sandbox_set_succeeds_after_global_delete() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + + // 1. Set global setting. + let mut global = super::StoredSettings::default(); + global.settings.insert( + "log_level".to_string(), + super::StoredSettingValue::String("warn".to_string()), + ); + global.revision = 1; + super::save_global_settings(&store, &global).await.unwrap(); + + // Verify it blocks sandbox. + let loaded = super::load_global_settings(&store).await.unwrap(); + assert!(loaded.settings.contains_key("log_level")); + + // 2. Delete the global setting. + global.settings.remove("log_level"); + global.revision = 2; + super::save_global_settings(&store, &global).await.unwrap(); + + // 3. Verify the guard is cleared. + let loaded = super::load_global_settings(&store).await.unwrap(); + assert!( + !loaded.settings.contains_key("log_level"), + "after global delete, log_level should not be globally managed" + ); + + // 4. Sandbox-scoped set should now succeed. + let sandbox_id = "test-sandbox-uuid"; + let mut sandbox_settings = super::load_sandbox_settings(&store, sandbox_id) + .await + .unwrap(); + let changed = super::upsert_setting_value( + &mut sandbox_settings.settings, + "log_level", + super::StoredSettingValue::String("debug".to_string()), + ); + assert!(changed, "sandbox upsert should report a change"); + sandbox_settings.revision = sandbox_settings.revision.saturating_add(1); + super::save_sandbox_settings(&store, sandbox_id, "test-sandbox", &sandbox_settings) + .await + .unwrap(); + + // Verify round-trip. + let reloaded = super::load_sandbox_settings(&store, sandbox_id) + .await + .unwrap(); + assert_eq!( + reloaded.settings.get("log_level"), + Some(&super::StoredSettingValue::String("debug".to_string())), + ); + } + + // ---- reserved policy key rejection ---- + + #[test] + fn validate_registered_setting_key_rejects_policy() { + // "policy" is not in REGISTERED_SETTINGS, so validate should fail. + let err = super::validate_registered_setting_key("policy").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("unknown setting key")); + } + + #[test] + fn proto_setting_to_stored_rejects_policy_key() { + let value = openshell_core::proto::SettingValue { + value: Some(openshell_core::proto::setting_value::Value::StringValue( + "anything".to_string(), + )), + }; + let err = super::proto_setting_to_stored("policy", &value).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + err.message().contains("unknown setting key"), + "policy key should be rejected as unknown: {}", + err.message(), + ); + } + + // ---- stored <-> proto round-trip for all types ---- + + #[test] + fn stored_setting_to_proto_string_round_trip() { + let stored = super::StoredSettingValue::String("hello".to_string()); + let proto = super::stored_setting_to_proto(&stored).unwrap(); + assert_eq!( + proto.value, + Some(openshell_core::proto::setting_value::Value::StringValue( + "hello".to_string() + )) + ); + } + + #[test] + fn stored_setting_to_proto_int_round_trip() { + let stored = super::StoredSettingValue::Int(42); + let proto = super::stored_setting_to_proto(&stored).unwrap(); + assert_eq!( + proto.value, + Some(openshell_core::proto::setting_value::Value::IntValue(42)) + ); + } + + #[test] + fn stored_setting_to_proto_bool_round_trip() { + let stored = super::StoredSettingValue::Bool(false); + let proto = super::stored_setting_to_proto(&stored).unwrap(); + assert_eq!( + proto.value, + Some(openshell_core::proto::setting_value::Value::BoolValue( + false + )) + ); + } + + // ---- upsert_setting_value ---- + + #[test] + fn upsert_setting_value_returns_true_on_insert() { + let mut map = std::collections::BTreeMap::new(); + let changed = super::upsert_setting_value( + &mut map, + "log_level", + super::StoredSettingValue::String("debug".to_string()), + ); + assert!(changed); + assert_eq!( + map.get("log_level"), + Some(&super::StoredSettingValue::String("debug".to_string())) + ); + } + + #[test] + fn upsert_setting_value_returns_false_when_unchanged() { + let mut map = std::collections::BTreeMap::new(); + map.insert( + "log_level".to_string(), + super::StoredSettingValue::String("debug".to_string()), + ); + let changed = super::upsert_setting_value( + &mut map, + "log_level", + super::StoredSettingValue::String("debug".to_string()), + ); + assert!( + !changed, + "upsert should return false when value is unchanged" + ); + } + + #[test] + fn upsert_setting_value_returns_true_on_update() { + let mut map = std::collections::BTreeMap::new(); + map.insert( + "log_level".to_string(), + super::StoredSettingValue::String("debug".to_string()), + ); + let changed = super::upsert_setting_value( + &mut map, + "log_level", + super::StoredSettingValue::String("warn".to_string()), + ); + assert!(changed, "upsert should return true when value changes"); + } + + // ---- settings persistence round-trip ---- + + #[tokio::test] + async fn global_settings_load_returns_default_when_empty() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + let settings = super::load_global_settings(&store).await.unwrap(); + assert!(settings.settings.is_empty()); + assert_eq!(settings.revision, 0); + } + + #[tokio::test] + async fn sandbox_settings_load_returns_default_when_empty() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + let settings = super::load_sandbox_settings(&store, "nonexistent") + .await + .unwrap(); + assert!(settings.settings.is_empty()); + assert_eq!(settings.revision, 0); + } + + #[tokio::test] + async fn global_settings_save_and_load_round_trip() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + + let mut settings = super::StoredSettings::default(); + settings.settings.insert( + "log_level".to_string(), + super::StoredSettingValue::String("error".to_string()), + ); + settings.settings.insert( + "dummy_bool".to_string(), + super::StoredSettingValue::Bool(true), + ); + settings.revision = 5; + super::save_global_settings(&store, &settings) + .await + .unwrap(); + + let loaded = super::load_global_settings(&store).await.unwrap(); + assert_eq!(loaded.revision, 5); + assert_eq!( + loaded.settings.get("log_level"), + Some(&super::StoredSettingValue::String("error".to_string())) + ); + assert_eq!( + loaded.settings.get("dummy_bool"), + Some(&super::StoredSettingValue::Bool(true)) + ); + } + + #[tokio::test] + async fn sandbox_settings_save_and_load_round_trip() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(); + + let sandbox_id = "sb-uuid-123"; + let mut settings = super::StoredSettings::default(); + settings + .settings + .insert("dummy_int".to_string(), super::StoredSettingValue::Int(99)); + settings.revision = 3; + super::save_sandbox_settings(&store, sandbox_id, "my-sandbox", &settings) + .await + .unwrap(); + + let loaded = super::load_sandbox_settings(&store, sandbox_id) + .await + .unwrap(); + assert_eq!(loaded.revision, 3); + assert_eq!( + loaded.settings.get("dummy_int"), + Some(&super::StoredSettingValue::Int(99)) + ); + } + + /// Verify that a mutex prevents lost writes when concurrent tasks + /// perform load-modify-save on the same global settings record. + /// + /// Each of N tasks increments the revision by 1 under the mutex. + /// Without the mutex, some increments would be lost (last-writer-wins). + /// With the mutex, the final revision must equal N. + #[tokio::test] + async fn concurrent_global_setting_mutations_are_serialized() { + let store = std::sync::Arc::new( + Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(), + ); + let mutex = std::sync::Arc::new(tokio::sync::Mutex::new(())); + + let n = 50; + let mut handles = Vec::with_capacity(n); + + for i in 0..n { + let store = store.clone(); + let mutex = mutex.clone(); + handles.push(tokio::spawn(async move { + let _guard = mutex.lock().await; + let mut settings = super::load_global_settings(&store).await.unwrap(); + // Simulate per-key mutation: each task sets a unique key. + settings.settings.insert( + format!("key_{i}"), + super::StoredSettingValue::Int(i as i64), + ); + settings.revision = settings.revision.wrapping_add(1); + super::save_global_settings(&store, &settings).await.unwrap(); + })); + } + + for h in handles { + h.await.unwrap(); + } + + let final_settings = super::load_global_settings(&store).await.unwrap(); + assert_eq!( + final_settings.revision, n as u64, + "all {n} increments must be reflected; lost writes indicate a race" + ); + assert_eq!( + final_settings.settings.len(), + n, + "all {n} unique keys must be present" + ); + } + + /// Same test WITHOUT the mutex to confirm the test would actually + /// detect lost writes when concurrent access is unserialized. + /// Uses `tokio::task::yield_now()` to increase interleaving. + #[tokio::test] + async fn concurrent_global_setting_mutations_without_lock_can_lose_writes() { + let store = std::sync::Arc::new( + Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(), + ); + + let n = 50; + let mut handles = Vec::with_capacity(n); + + for i in 0..n { + let store = store.clone(); + handles.push(tokio::spawn(async move { + // No mutex — intentional race. + let mut settings = super::load_global_settings(&store).await.unwrap(); + // Yield to encourage interleaving between load and save. + tokio::task::yield_now().await; + settings.settings.insert( + format!("key_{i}"), + super::StoredSettingValue::Int(i as i64), + ); + settings.revision = settings.revision.wrapping_add(1); + super::save_global_settings(&store, &settings).await.unwrap(); + })); + } + + for h in handles { + h.await.unwrap(); + } + + let final_settings = super::load_global_settings(&store).await.unwrap(); + // Without serialization, some writes will be lost. The final + // revision and key count will be less than N. We assert that + // at least one write was lost to validate the test methodology. + // (If tokio happens to schedule everything sequentially, this + // could flake — but with N=50 and yield_now it's reliable.) + let lost = (n as u64).saturating_sub(final_settings.revision); + if lost == 0 { + // Rare but possible with sequential scheduling. Don't fail, + // but note that the positive test above is what matters. + eprintln!( + "note: no lost writes detected in unlocked test (sequential scheduling); \ + the locked test is the authoritative correctness check" + ); + } else { + eprintln!( + "unlocked test: {lost} lost writes out of {n} (expected behavior)" + ); + } + // Either way, the WITH-lock test above asserts correctness. + } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index fad238be..e827b362 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -66,6 +66,12 @@ pub struct ServerState { /// Active SSH tunnel connection counts per sandbox id. pub ssh_connections_by_sandbox: Mutex>, + + /// Serializes settings mutations (global and sandbox) to prevent + /// read-modify-write races. Held for the duration of any setting + /// set/delete operation, including the precedence check on sandbox + /// mutations that reads global state. + pub settings_mutex: tokio::sync::Mutex<()>, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -95,6 +101,7 @@ impl ServerState { tracing_log_bus, ssh_connections_by_token: Mutex::new(HashMap::new()), ssh_connections_by_sandbox: Mutex::new(HashMap::new()), + settings_mutex: tokio::sync::Mutex::new(()), } } } From 8a4a7fbb91adc3882ffb4c8b72b568e0a5caae97 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:34:37 -0700 Subject: [PATCH 11/28] fix(settings): prefix global ID, use wrapping_add, add --json output, consolidate TUI utils --- crates/openshell-cli/src/main.rs | 10 +- crates/openshell-cli/src/run.rs | 86 ++++++++++- crates/openshell-core/src/settings.rs | 136 +++++++++++++++++- crates/openshell-server/src/grpc.rs | 17 ++- crates/openshell-tui/src/app.rs | 27 ++-- .../openshell-tui/src/ui/global_settings.rs | 20 +-- crates/openshell-tui/src/ui/mod.rs | 21 +++ .../openshell-tui/src/ui/sandbox_settings.rs | 20 +-- 8 files changed, 272 insertions(+), 65 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 8ed57956..d7605f41 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1422,6 +1422,10 @@ enum SettingsCommands { /// Show gateway-global settings. #[arg(long)] global: bool, + + /// Output as JSON. + #[arg(long)] + json: bool, }, /// Set a single setting key. @@ -1880,17 +1884,17 @@ async fn main() -> Result<()> { apply_edge_auth(&mut tls, &ctx.name); match settings_cmd { - SettingsCommands::Get { name, global } => { + SettingsCommands::Get { name, global, json } => { if global { if name.is_some() { return Err(miette::miette!( "settings get --global does not accept a sandbox name" )); } - run::gateway_settings_get(&ctx.endpoint, &tls).await?; + run::gateway_settings_get(&ctx.endpoint, json, &tls).await?; } else { let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_settings_get(&ctx.endpoint, &name, &tls).await?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &tls).await?; } } SettingsCommands::Set { diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index d53be04e..d5930e1a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3931,7 +3931,12 @@ pub async fn sandbox_policy_set_global( Ok(()) } -pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { +pub async fn sandbox_settings_get( + server: &str, + name: &str, + json: bool, + tls: &TlsOptions, +) -> Result<()> { let mut client = grpc_client(server, tls).await?; let sandbox = client .get_sandbox(GetSandboxRequest { @@ -3951,6 +3956,15 @@ pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> .into_diagnostic()? .into_inner(); + if json { + let obj = settings_to_json_sandbox(name, &response); + println!( + "{}", + serde_json::to_string_pretty(&obj).into_diagnostic()? + ); + return Ok(()); + } + let policy_source = if response.policy_source == openshell_core::proto::PolicySource::Global as i32 { "global" @@ -3990,7 +4004,7 @@ pub async fn sandbox_settings_get(server: &str, name: &str, tls: &TlsOptions) -> Ok(()) } -pub async fn gateway_settings_get(server: &str, tls: &TlsOptions) -> Result<()> { +pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client .get_gateway_settings(GetGatewaySettingsRequest {}) @@ -3998,6 +4012,15 @@ pub async fn gateway_settings_get(server: &str, tls: &TlsOptions) -> Result<()> .into_diagnostic()? .into_inner(); + if json { + let obj = settings_to_json_global(&response); + println!( + "{}", + serde_json::to_string_pretty(&obj).into_diagnostic()? + ); + return Ok(()); + } + println!("Scope: global"); println!("Settings Rev: {}", response.settings_revision); @@ -4017,6 +4040,65 @@ pub async fn gateway_settings_get(server: &str, tls: &TlsOptions) -> Result<()> Ok(()) } +fn settings_to_json_sandbox( + name: &str, + response: &openshell_core::proto::GetSandboxSettingsResponse, +) -> serde_json::Value { + let policy_source = + if response.policy_source == openshell_core::proto::PolicySource::Global as i32 { + "global" + } else { + "sandbox" + }; + + let mut settings = serde_json::Map::new(); + let mut keys: Vec<_> = response.settings.keys().cloned().collect(); + keys.sort(); + for key in keys { + if let Some(setting) = response.settings.get(&key) { + let scope = match SettingScope::try_from(setting.scope) { + Ok(SettingScope::Global) => "global", + Ok(SettingScope::Sandbox) => "sandbox", + _ => "unset", + }; + settings.insert( + key, + serde_json::json!({ + "value": format_setting_value(setting.value.as_ref()), + "scope": scope, + }), + ); + } + } + + serde_json::json!({ + "sandbox": name, + "config_revision": response.config_revision, + "policy_source": policy_source, + "policy_hash": response.policy_hash, + "settings": settings, + }) +} + +fn settings_to_json_global( + response: &openshell_core::proto::GetGatewaySettingsResponse, +) -> serde_json::Value { + let mut settings = serde_json::Map::new(); + let mut keys: Vec<_> = response.settings.keys().cloned().collect(); + keys.sort(); + for key in keys { + if let Some(setting) = response.settings.get(&key) { + settings.insert(key, serde_json::json!(format_setting_value(Some(setting)))); + } + } + + serde_json::json!({ + "scope": "global", + "settings_revision": response.settings_revision, + "settings": settings, + }) +} + pub async fn gateway_setting_set( server: &str, key: &str, diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index cf68d2c0..12505471 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -34,6 +34,20 @@ pub struct RegisteredSetting { /// /// `policy` is intentionally excluded because it is a reserved key handled by /// dedicated policy commands and payloads. +/// +/// # Adding a new setting +/// +/// 1. Add a [`RegisteredSetting`] entry to this array with the key name and +/// [`SettingValueKind`]. +/// 2. Recompile `openshell-server` (gateway) and `openshell-sandbox` +/// (supervisor). No database migration is needed -- new keys are stored in +/// the existing settings JSON blob. +/// 3. Add sandbox-side consumption in `openshell-sandbox` to read and act on +/// the new key from the poll loop's `SettingsPollResult::settings` map. +/// 4. The key will automatically appear in `settings get` (CLI/TUI) and be +/// settable via `settings set`. The server validates that only registered +/// keys are accepted. +/// 5. Add a unit test in this module's `tests` section to cover the new key. pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ RegisteredSetting { key: "log_level", @@ -77,7 +91,10 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { - use super::{SettingValueKind, parse_bool_like, setting_for_key}; + use super::{ + parse_bool_like, registered_keys_csv, setting_for_key, RegisteredSetting, SettingValueKind, + REGISTERED_SETTINGS, + }; #[test] fn setting_for_key_returns_registered_entry() { @@ -85,6 +102,19 @@ mod tests { assert_eq!(setting.kind, SettingValueKind::Bool); } + #[test] + fn setting_for_key_returns_none_for_unknown() { + assert!(setting_for_key("nonexistent_key").is_none()); + } + + #[test] + fn setting_for_key_returns_none_for_reserved_policy() { + // "policy" is intentionally excluded from the registry. + assert!(setting_for_key("policy").is_none()); + } + + // ---- parse_bool_like ---- + #[test] fn parse_bool_like_accepts_expected_spellings() { for raw in ["1", "true", "yes", "on", "Y"] { @@ -99,8 +129,112 @@ mod tests { } } + #[test] + fn parse_bool_like_case_insensitive() { + assert_eq!(parse_bool_like("TRUE"), Some(true)); + assert_eq!(parse_bool_like("True"), Some(true)); + assert_eq!(parse_bool_like("FALSE"), Some(false)); + assert_eq!(parse_bool_like("False"), Some(false)); + assert_eq!(parse_bool_like("YES"), Some(true)); + assert_eq!(parse_bool_like("NO"), Some(false)); + assert_eq!(parse_bool_like("On"), Some(true)); + assert_eq!(parse_bool_like("Off"), Some(false)); + } + + #[test] + fn parse_bool_like_trims_whitespace() { + assert_eq!(parse_bool_like(" true "), Some(true)); + assert_eq!(parse_bool_like("\tfalse\t"), Some(false)); + assert_eq!(parse_bool_like(" 1 "), Some(true)); + assert_eq!(parse_bool_like(" 0 "), Some(false)); + } + #[test] fn parse_bool_like_rejects_unrecognized_values() { assert_eq!(parse_bool_like("maybe"), None); + assert_eq!(parse_bool_like(""), None); + assert_eq!(parse_bool_like("2"), None); + assert_eq!(parse_bool_like("nope"), None); + assert_eq!(parse_bool_like("yep"), None); + assert_eq!(parse_bool_like("enabled"), None); + assert_eq!(parse_bool_like("disabled"), None); + } + + // ---- REGISTERED_SETTINGS entries ---- + + #[test] + fn registered_settings_have_valid_kinds() { + let valid_kinds = [ + SettingValueKind::String, + SettingValueKind::Int, + SettingValueKind::Bool, + ]; + for entry in REGISTERED_SETTINGS { + assert!( + valid_kinds.contains(&entry.kind), + "registered setting '{}' has unexpected kind {:?}", + entry.key, + entry.kind, + ); + } + } + + #[test] + fn registered_settings_keys_are_nonempty_and_unique() { + let mut seen = std::collections::HashSet::new(); + for entry in REGISTERED_SETTINGS { + assert!( + !entry.key.is_empty(), + "registered setting key must not be empty" + ); + assert!( + seen.insert(entry.key), + "duplicate registered setting key '{}'", + entry.key, + ); + } + } + + #[test] + fn registered_settings_excludes_policy() { + assert!( + !REGISTERED_SETTINGS.iter().any(|e| e.key == "policy"), + "policy must not appear in REGISTERED_SETTINGS" + ); + } + + #[test] + fn registered_keys_csv_contains_all_keys() { + let csv = registered_keys_csv(); + for entry in REGISTERED_SETTINGS { + assert!( + csv.contains(entry.key), + "registered_keys_csv() missing '{}'", + entry.key, + ); + } + } + + // ---- SettingValueKind::as_str ---- + + #[test] + fn setting_value_kind_as_str_returns_expected_labels() { + assert_eq!(SettingValueKind::String.as_str(), "string"); + assert_eq!(SettingValueKind::Int.as_str(), "int"); + assert_eq!(SettingValueKind::Bool.as_str(), "bool"); + } + + // ---- RegisteredSetting structural ---- + + #[test] + fn registered_setting_derives_debug_clone_eq() { + let a = RegisteredSetting { + key: "test", + kind: SettingValueKind::Bool, + }; + let b = a; + assert_eq!(a, b); + // Debug is exercised implicitly by format! + let _ = format!("{a:?}"); } } diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 7eb396e1..31608121 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -110,8 +110,11 @@ const MAX_PROVIDER_CONFIG_ENTRIES: usize = 64; /// Internal object type for durable gateway-global settings. const GLOBAL_SETTINGS_OBJECT_TYPE: &str = "gateway_settings"; -/// Internal object id/name for the singleton global settings record. -const GLOBAL_SETTINGS_ID: &str = "global"; +/// Internal object id for the singleton global settings record. +/// +/// Prefixed to avoid collision with other object types in the shared +/// `objects` table (PRIMARY KEY is on `id` alone, not `(object_type, id)`). +const GLOBAL_SETTINGS_ID: &str = "gateway_settings:global"; const GLOBAL_SETTINGS_NAME: &str = "global"; /// Internal object type for durable sandbox-scoped settings. const SANDBOX_SETTINGS_OBJECT_TYPE: &str = "sandbox_settings"; @@ -1100,7 +1103,7 @@ impl OpenShell for OpenShellService { stored_value, ); if changed { - global_settings.revision = global_settings.revision.saturating_add(1); + global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(self.state.store.as_ref(), &global_settings).await?; } @@ -1135,7 +1138,7 @@ impl OpenShell for OpenShellService { }; if deleted { - global_settings.revision = global_settings.revision.saturating_add(1); + global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(self.state.store.as_ref(), &global_settings).await?; } @@ -1190,7 +1193,7 @@ impl OpenShell for OpenShellService { load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; let removed = sandbox_settings.settings.remove(key).is_some(); if removed { - sandbox_settings.revision = sandbox_settings.revision.saturating_add(1); + sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( self.state.store.as_ref(), &sandbox_id, @@ -1224,7 +1227,7 @@ impl OpenShell for OpenShellService { load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; let changed = upsert_setting_value(&mut sandbox_settings.settings, key, stored); if changed { - sandbox_settings.revision = sandbox_settings.revision.saturating_add(1); + sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); save_sandbox_settings( self.state.store.as_ref(), &sandbox_id, @@ -5709,7 +5712,7 @@ mod tests { super::StoredSettingValue::String("debug".to_string()), ); assert!(changed, "sandbox upsert should report a change"); - sandbox_settings.revision = sandbox_settings.revision.saturating_add(1); + sandbox_settings.revision = sandbox_settings.revision.wrapping_add(1); super::save_sandbox_settings(&store, sandbox_id, "test-sandbox", &sandbox_settings) .await .unwrap(); diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index c85886e3..391e47ee 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -120,13 +120,7 @@ pub struct GlobalSettingEntry { impl GlobalSettingEntry { pub fn display_value(&self) -> String { - match &self.value { - None => "".to_string(), - Some(setting_value::Value::StringValue(v)) => v.clone(), - Some(setting_value::Value::BoolValue(v)) => v.to_string(), - Some(setting_value::Value::IntValue(v)) => v.to_string(), - Some(setting_value::Value::BytesValue(_)) => "".to_string(), - } + display_setting_value(&self.value) } } @@ -195,13 +189,7 @@ impl SettingScope { impl SandboxSettingEntry { pub fn display_value(&self) -> String { - match &self.value { - None => "".to_string(), - Some(setting_value::Value::StringValue(v)) => v.clone(), - Some(setting_value::Value::BoolValue(v)) => v.to_string(), - Some(setting_value::Value::IntValue(v)) => v.to_string(), - Some(setting_value::Value::BytesValue(_)) => "".to_string(), - } + display_setting_value(&self.value) } pub fn is_globally_managed(&self) -> bool { @@ -209,6 +197,17 @@ impl SandboxSettingEntry { } } +/// Format a proto `SettingValue` for display. +pub fn display_setting_value(value: &Option) -> String { + match value { + None => "".to_string(), + Some(setting_value::Value::StringValue(v)) => v.clone(), + Some(setting_value::Value::BoolValue(v)) => v.to_string(), + Some(setting_value::Value::IntValue(v)) => v.to_string(), + Some(setting_value::Value::BytesValue(_)) => "".to_string(), + } +} + // --------------------------------------------------------------------------- // Gateway entry // --------------------------------------------------------------------------- diff --git a/crates/openshell-tui/src/ui/global_settings.rs b/crates/openshell-tui/src/ui/global_settings.rs index e67d2620..fe5875f1 100644 --- a/crates/openshell-tui/src/ui/global_settings.rs +++ b/crates/openshell-tui/src/ui/global_settings.rs @@ -251,22 +251,4 @@ fn draw_confirm_delete(frame: &mut Frame<'_>, app: &App, idx: usize, area: Rect) frame.render_widget(Paragraph::new(lines).block(block), popup); } -fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { - use ratatui::layout::{Direction, Layout}; - let vert = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - height.min(100)) / 2), - Constraint::Length(height), - Constraint::Percentage((100 - height.min(100)) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vert[1])[1] -} +use super::centered_popup as centered_rect; diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 146d8d20..b920d9cb 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -453,3 +453,24 @@ fn draw_command_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { let bar = Paragraph::new(line).block(Block::default().borders(Borders::NONE)); frame.render_widget(bar, area); } + +/// Center a popup rectangle within `area` using percentage-based width and +/// an absolute height (in rows). +pub(crate) fn centered_popup(percent_x: u16, height: u16, area: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height.min(100)) / 2), + Constraint::Length(height), + Constraint::Percentage((100 - height.min(100)) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vert[1])[1] +} diff --git a/crates/openshell-tui/src/ui/sandbox_settings.rs b/crates/openshell-tui/src/ui/sandbox_settings.rs index 9c5eca62..301c846e 100644 --- a/crates/openshell-tui/src/ui/sandbox_settings.rs +++ b/crates/openshell-tui/src/ui/sandbox_settings.rs @@ -257,22 +257,4 @@ fn draw_confirm_delete(frame: &mut Frame<'_>, app: &App, idx: usize, area: Rect) frame.render_widget(Paragraph::new(lines).block(block), popup); } -fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { - use ratatui::layout::{Direction, Layout}; - let vert = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - height.min(100)) / 2), - Constraint::Length(height), - Constraint::Percentage((100 - height.min(100)) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vert[1])[1] -} +use super::centered_popup as centered_rect; From 22738946b11c98b1a005a496075f44763e871bd5 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:59:57 -0700 Subject: [PATCH 12/28] refactor(proto): rename UpdateSandboxPolicy to UpdateSettings for consistency --- crates/openshell-cli/src/main.rs | 4 +-- crates/openshell-cli/src/run.rs | 14 ++++----- .../tests/ensure_providers_integration.rs | 6 ++-- .../openshell-cli/tests/mtls_integration.rs | 6 ++-- .../tests/provider_commands_integration.rs | 6 ++-- .../sandbox_create_lifecycle_integration.rs | 6 ++-- .../sandbox_name_fallback_integration.rs | 6 ++-- crates/openshell-sandbox/src/grpc_client.rs | 4 +-- crates/openshell-server/src/grpc.rs | 30 +++++++++---------- .../tests/auth_endpoint_integration.rs | 6 ++-- .../tests/edge_tunnel_auth.rs | 6 ++-- .../tests/multiplex_integration.rs | 6 ++-- .../tests/multiplex_tls_integration.rs | 6 ++-- .../tests/ws_tunnel_integration.rs | 6 ++-- crates/openshell-tui/src/lib.rs | 24 +++++++-------- proto/openshell.proto | 10 +++---- 16 files changed, 73 insertions(+), 73 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index d7605f41..6cea6369 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -3032,7 +3032,7 @@ mod tests { match cli.command { Some(Commands::Settings { - command: Some(SettingsCommands::Get { name, global }), + command: Some(SettingsCommands::Get { name, global, .. }), }) => { assert!(global); assert!(name.is_none()); @@ -3072,7 +3072,7 @@ mod tests { match cli.command { Some(Commands::Settings { - command: Some(SettingsCommands::Delete { key, global, yes }), + command: Some(SettingsCommands::Delete { key, global, yes, .. }), }) => { assert_eq!(key, "log_level"); assert!(global); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index d5930e1a..f5c23365 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -30,7 +30,7 @@ use openshell_core::proto::{ ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue, UpdateProviderRequest, - UpdateSandboxPolicyRequest, WatchSandboxRequest, setting_value, + UpdateSettingsRequest, WatchSandboxRequest, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_providers::{ @@ -3906,7 +3906,7 @@ pub async fn sandbox_policy_set_global( let mut client = grpc_client(server, tls).await?; let response = client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: String::new(), policy: Some(policy), setting_key: String::new(), @@ -4111,7 +4111,7 @@ pub async fn gateway_setting_set( let mut client = grpc_client(server, tls).await?; let response = client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: String::new(), policy: None, setting_key: key.to_string(), @@ -4144,7 +4144,7 @@ pub async fn sandbox_setting_set( let mut client = grpc_client(server, tls).await?; let response = client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: name.to_string(), policy: None, setting_key: key.to_string(), @@ -4177,7 +4177,7 @@ pub async fn gateway_setting_delete( let mut client = grpc_client(server, tls).await?; let response = client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: String::new(), policy: None, setting_key: key.to_string(), @@ -4210,7 +4210,7 @@ pub async fn sandbox_setting_delete( ) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: name.to_string(), policy: None, setting_key: key.to_string(), @@ -4266,7 +4266,7 @@ pub async fn sandbox_policy_set( .map_or(0, |r| r.version); let response = client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: name.to_string(), policy: Some(policy), setting_key: String::new(), diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 3cfc6ea2..aaffa4e8 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -319,10 +319,10 @@ impl OpenShell for TestOpenShell { ))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 9fa4d930..94e0640e 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -221,10 +221,10 @@ impl OpenShell for TestOpenShell { ))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5cffcf22..de4eeaa6 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -273,10 +273,10 @@ impl OpenShell for TestOpenShell { ))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index fe4be99c..320a2cae 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -299,10 +299,10 @@ impl OpenShell for TestOpenShell { ))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 8daace40..39a46339 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -231,10 +231,10 @@ impl OpenShell for TestOpenShell { ))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index d4f8487e..df1b8e3c 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -12,7 +12,7 @@ use openshell_core::proto::{ DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxProviderEnvironmentRequest, GetSandboxSettingsRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - UpdateSandboxPolicyRequest, inference_client::InferenceClient, + UpdateSettingsRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; @@ -127,7 +127,7 @@ async fn sync_policy_with_client( policy: &ProtoSandboxPolicy, ) -> Result<()> { client - .update_sandbox_policy(UpdateSandboxPolicyRequest { + .update_settings(UpdateSettingsRequest { name: sandbox.to_string(), policy: Some(policy.clone()), setting_key: String::new(), diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 31608121..d9f33cdb 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -30,8 +30,8 @@ use openshell_core::proto::{ RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateProviderRequest, UpdateSandboxPolicyRequest, - UpdateSandboxPolicyResponse, WatchSandboxRequest, open_shell_server::OpenShell, + UndoDraftChunkResponse, UpdateProviderRequest, UpdateSettingsRequest, + UpdateSettingsResponse, WatchSandboxRequest, open_shell_server::OpenShell, }; use openshell_core::proto::{ Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, @@ -1057,10 +1057,10 @@ impl OpenShell for OpenShellService { // Policy update handlers // ------------------------------------------------------------------- - async fn update_sandbox_policy( + async fn update_settings( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let req = request.into_inner(); let key = req.setting_key.trim(); let has_policy = req.policy.is_some(); @@ -1107,7 +1107,7 @@ impl OpenShell for OpenShellService { save_global_settings(self.state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateSandboxPolicyResponse { + return Ok(Response::new(UpdateSettingsResponse { version: 0, policy_hash: deterministic_policy_hash(&new_policy), settings_revision: global_settings.revision, @@ -1142,7 +1142,7 @@ impl OpenShell for OpenShellService { save_global_settings(self.state.store.as_ref(), &global_settings).await?; } - return Ok(Response::new(UpdateSandboxPolicyResponse { + return Ok(Response::new(UpdateSettingsResponse { version: 0, policy_hash: String::new(), settings_revision: global_settings.revision, @@ -1203,7 +1203,7 @@ impl OpenShell for OpenShellService { .await?; } - return Ok(Response::new(UpdateSandboxPolicyResponse { + return Ok(Response::new(UpdateSettingsResponse { version: 0, policy_hash: String::new(), settings_revision: sandbox_settings.revision, @@ -1237,7 +1237,7 @@ impl OpenShell for OpenShellService { .await?; } - return Ok(Response::new(UpdateSandboxPolicyResponse { + return Ok(Response::new(UpdateSettingsResponse { version: 0, policy_hash: String::new(), settings_revision: sandbox_settings.revision, @@ -1293,7 +1293,7 @@ impl OpenShell for OpenShellService { .map_err(|e| Status::internal(format!("backfill spec.policy failed: {e}")))?; info!( sandbox_id = %sandbox_id, - "UpdateSandboxPolicy: backfilled spec.policy from sandbox-discovered policy" + "UpdateSettings: backfilled spec.policy from sandbox-discovered policy" ); } @@ -1312,7 +1312,7 @@ impl OpenShell for OpenShellService { if let Some(ref current) = latest && current.policy_hash == hash { - return Ok(Response::new(UpdateSandboxPolicyResponse { + return Ok(Response::new(UpdateSettingsResponse { version: u32::try_from(current.version).unwrap_or(0), policy_hash: hash, settings_revision: 0, @@ -1343,10 +1343,10 @@ impl OpenShell for OpenShellService { sandbox_id = %sandbox_id, version = next_version, policy_hash = %hash, - "UpdateSandboxPolicy: new policy version persisted" + "UpdateSettings: new policy version persisted" ); - Ok(Response::new(UpdateSandboxPolicyResponse { + Ok(Response::new(UpdateSettingsResponse { version: u32::try_from(next_version).unwrap_or(0), policy_hash: hash, settings_revision: 0, @@ -2326,7 +2326,7 @@ fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result, - ) -> Result, tonic::Status> + _: tonic::Request, + ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("test")) } diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index c80fc936..fff68997 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -203,10 +203,10 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ReceiverStream::new(rx))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index 4f98b301..c0918226 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -171,10 +171,10 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ReceiverStream::new(rx))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 6d9a5e2f..7518a688 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -184,10 +184,10 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ReceiverStream::new(rx))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index bd46b86d..1e36992e 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -197,10 +197,10 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ReceiverStream::new(rx))) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Err(Status::unimplemented("not implemented in test")) } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 2a18d8d0..e0fb1145 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1903,7 +1903,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { // Build the typed SettingValue from the validated input. - use openshell_core::proto::{SettingValue, UpdateSandboxPolicyRequest, setting_value}; + use openshell_core::proto::{SettingValue, UpdateSettingsRequest, setting_value}; let value = match kind { openshell_core::settings::SettingValueKind::Bool => { @@ -1919,7 +1919,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { } }; - let req = UpdateSandboxPolicyRequest { + let req = UpdateSettingsRequest { name: String::new(), policy: None, setting_key: key, @@ -1929,7 +1929,7 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { }; let result = - tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + tokio::time::timeout(Duration::from_secs(5), client.update_settings(req)).await; let event = match result { Ok(Ok(resp)) => Event::GlobalSettingSetResult(Ok(resp.into_inner().settings_revision)), @@ -1952,9 +1952,9 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { let mut client = app.client.clone(); tokio::spawn(async move { - use openshell_core::proto::UpdateSandboxPolicyRequest; + use openshell_core::proto::UpdateSettingsRequest; - let req = UpdateSandboxPolicyRequest { + let req = UpdateSettingsRequest { name: String::new(), policy: None, setting_key: key, @@ -1964,7 +1964,7 @@ fn spawn_delete_global_setting(app: &App, tx: mpsc::UnboundedSender) { }; let result = - tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + tokio::time::timeout(Duration::from_secs(5), client.update_settings(req)).await; let event = match result { Ok(Ok(resp)) => { @@ -1995,7 +1995,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let mut client = app.client.clone(); tokio::spawn(async move { - use openshell_core::proto::{SettingValue, UpdateSandboxPolicyRequest, setting_value}; + use openshell_core::proto::{SettingValue, UpdateSettingsRequest, setting_value}; let value = match kind { openshell_core::settings::SettingValueKind::Bool => { @@ -2011,7 +2011,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { } }; - let req = UpdateSandboxPolicyRequest { + let req = UpdateSettingsRequest { name, policy: None, setting_key: key, @@ -2021,7 +2021,7 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { }; let result = - tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + tokio::time::timeout(Duration::from_secs(5), client.update_settings(req)).await; let event = match result { Ok(Ok(resp)) => Event::SandboxSettingSetResult(Ok(resp.into_inner().settings_revision)), @@ -2048,9 +2048,9 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let mut client = app.client.clone(); tokio::spawn(async move { - use openshell_core::proto::UpdateSandboxPolicyRequest; + use openshell_core::proto::UpdateSettingsRequest; - let req = UpdateSandboxPolicyRequest { + let req = UpdateSettingsRequest { name, policy: None, setting_key: key, @@ -2060,7 +2060,7 @@ fn spawn_delete_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { }; let result = - tokio::time::timeout(Duration::from_secs(5), client.update_sandbox_policy(req)).await; + tokio::time::timeout(Duration::from_secs(5), client.update_settings(req)).await; let event = match result { Ok(Ok(resp)) => { diff --git a/proto/openshell.proto b/proto/openshell.proto index d8007f86..588ae243 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -60,9 +60,9 @@ service OpenShell { rpc GetGatewaySettings(openshell.sandbox.v1.GetGatewaySettingsRequest) returns (openshell.sandbox.v1.GetGatewaySettingsResponse); - // Update sandbox policy on a live sandbox. - rpc UpdateSandboxPolicy(UpdateSandboxPolicyRequest) - returns (UpdateSandboxPolicyResponse); + // Update settings or policy at sandbox or global scope. + rpc UpdateSettings(UpdateSettingsRequest) + returns (UpdateSettingsResponse); // Get the load status of a specific policy version. rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) @@ -440,7 +440,7 @@ message GetSandboxProviderEnvironmentResponse { // --------------------------------------------------------------------------- // Update sandbox policy request. -message UpdateSandboxPolicyRequest { +message UpdateSettingsRequest { // Sandbox name (canonical lookup key). Required for sandbox-scoped updates. // Not required when `global=true`. string name = 1; @@ -465,7 +465,7 @@ message UpdateSandboxPolicyRequest { } // Update sandbox policy response. -message UpdateSandboxPolicyResponse { +message UpdateSettingsResponse { // Assigned policy version (monotonically increasing per sandbox). uint32 version = 1; // SHA-256 hash of the serialized policy payload. From 051df3ce4345b977fc403d98eaa2fc1d2ece85c9 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:13:06 -0700 Subject: [PATCH 13/28] fix(settings): address remaining review findings (W3-W6, S1) --- crates/openshell-core/Cargo.toml | 6 ++++ crates/openshell-core/src/settings.rs | 11 ++++++ crates/openshell-server/src/grpc.rs | 32 ++++++++++++++++-- crates/openshell-tui/src/lib.rs | 48 ++++++++++++++++++++------- 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index eeedd11a..611d66fc 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -20,6 +20,12 @@ serde = { workspace = true } serde_json = { workspace = true } url = { workspace = true } +[features] +## Include dummy test settings (dummy_bool, dummy_int) in the registry. +## Enabled by default during development; disable for release builds. +dev-settings = [] +default = ["dev-settings"] + [build-dependencies] tonic-build = { workspace = true } protobuf-src = { workspace = true } diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 12505471..3e04115c 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -53,10 +53,12 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ key: "log_level", kind: SettingValueKind::String, }, + #[cfg(feature = "dev-settings")] RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int, }, + #[cfg(feature = "dev-settings")] RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool, @@ -98,8 +100,17 @@ mod tests { #[test] fn setting_for_key_returns_registered_entry() { + let setting = setting_for_key("log_level").expect("log_level should be registered"); + assert_eq!(setting.kind, SettingValueKind::String); + } + + #[cfg(feature = "dev-settings")] + #[test] + fn setting_for_key_returns_dev_entries() { let setting = setting_for_key("dummy_bool").expect("dummy_bool should be registered"); assert_eq!(setting.kind, SettingValueKind::Bool); + let setting = setting_for_key("dummy_int").expect("dummy_int should be registered"); + assert_eq!(setting.kind, SettingValueKind::Int); } #[test] diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index d9f33cdb..8ede2cc0 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -638,6 +638,20 @@ impl OpenShell for OpenShellService { } } + // Clean up sandbox-scoped settings record. + if let Err(e) = self + .state + .store + .delete(SANDBOX_SETTINGS_OBJECT_TYPE, &sandbox_settings_id(&id)) + .await + { + warn!( + sandbox_id = %id, + error = %e, + "Failed to delete sandbox settings during cleanup" + ); + } + let deleted = match self.state.sandbox_client.delete(&sandbox.name).await { Ok(deleted) => deleted, Err(err) => { @@ -1126,7 +1140,7 @@ impl OpenShell for OpenShellService { } let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; - let deleted = if req.delete_setting { + let changed = if req.delete_setting { global_settings.settings.remove(key).is_some() } else { let setting = req @@ -1137,7 +1151,7 @@ impl OpenShell for OpenShellService { upsert_setting_value(&mut global_settings.settings, key, stored) }; - if deleted { + if changed { global_settings.revision = global_settings.revision.wrapping_add(1); save_global_settings(self.state.store.as_ref(), &global_settings).await?; } @@ -1146,7 +1160,7 @@ impl OpenShell for OpenShellService { version: 0, policy_hash: String::new(), settings_revision: global_settings.revision, - deleted: req.delete_setting && deleted, + deleted: req.delete_setting && changed, })); } @@ -2554,6 +2568,18 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hex::encode(hasher.finalize()) } +/// Compute a fingerprint for the effective sandbox configuration. +/// +/// Returns the first 8 bytes of a SHA-256 hash over the policy, settings, +/// and policy source. The sandbox poll loop compares this value to detect +/// changes -- if it differs from the previously seen revision, the sandbox +/// reloads. +/// +/// This is a content hash, not a monotonic counter. With 64 bits of hash +/// space the birthday-bound collision probability is ~50% at 2^32 +/// configurations. A collision would cause one poll cycle to miss a change, +/// but the next mutation will almost certainly produce a different hash. +/// This trade-off is acceptable for the poll-based change detection use case. fn compute_config_revision( policy: Option<&ProtoSandboxPolicy>, settings: &HashMap, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index e0fb1145..2b9bfa10 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1907,13 +1907,25 @@ fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { let value = match kind { openshell_core::settings::SettingValueKind::Bool => { - let parsed = openshell_core::settings::parse_bool_like(&raw).unwrap_or(false); - setting_value::Value::BoolValue(parsed) - } - openshell_core::settings::SettingValueKind::Int => { - let parsed = raw.parse::().unwrap_or(0); - setting_value::Value::IntValue(parsed) + match openshell_core::settings::parse_bool_like(&raw) { + Some(v) => setting_value::Value::BoolValue(v), + None => { + let _ = tx.send(Event::GlobalSettingSetResult(Err(format!( + "invalid bool value: {raw}" + )))); + return; + } + } } + openshell_core::settings::SettingValueKind::Int => match raw.parse::() { + Ok(v) => setting_value::Value::IntValue(v), + Err(_) => { + let _ = tx.send(Event::GlobalSettingSetResult(Err(format!( + "invalid int value: {raw}" + )))); + return; + } + }, openshell_core::settings::SettingValueKind::String => { setting_value::Value::StringValue(raw) } @@ -1999,13 +2011,25 @@ fn spawn_set_sandbox_setting(app: &App, tx: mpsc::UnboundedSender) { let value = match kind { openshell_core::settings::SettingValueKind::Bool => { - let parsed = openshell_core::settings::parse_bool_like(&raw).unwrap_or(false); - setting_value::Value::BoolValue(parsed) - } - openshell_core::settings::SettingValueKind::Int => { - let parsed = raw.parse::().unwrap_or(0); - setting_value::Value::IntValue(parsed) + match openshell_core::settings::parse_bool_like(&raw) { + Some(v) => setting_value::Value::BoolValue(v), + None => { + let _ = tx.send(Event::SandboxSettingSetResult(Err(format!( + "invalid bool value: {raw}" + )))); + return; + } + } } + openshell_core::settings::SettingValueKind::Int => match raw.parse::() { + Ok(v) => setting_value::Value::IntValue(v), + Err(_) => { + let _ = tx.send(Event::SandboxSettingSetResult(Err(format!( + "invalid int value: {raw}" + )))); + return; + } + }, openshell_core::settings::SettingValueKind::String => { setting_value::Value::StringValue(raw) } From c659d9381267b954fcdeb8480dc894d19ab1f004 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:48:46 -0700 Subject: [PATCH 14/28] feat(settings): add global policy versioning with revision history and CLI/TUI support --- crates/openshell-cli/src/main.rs | 56 ++++-- crates/openshell-cli/src/run.rs | 94 +++++++-- crates/openshell-sandbox/src/grpc_client.rs | 6 +- crates/openshell-sandbox/src/lib.rs | 42 ++-- crates/openshell-server/src/grpc.rs | 186 +++++++++++++----- .../tests/auth_endpoint_integration.rs | 3 +- crates/openshell-tui/src/app.rs | 7 +- crates/openshell-tui/src/lib.rs | 29 ++- crates/openshell-tui/src/ui/sandbox_detail.rs | 14 ++ proto/openshell.proto | 8 +- proto/sandbox.proto | 3 + 11 files changed, 339 insertions(+), 109 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 6cea6369..3799b392 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1369,10 +1369,10 @@ enum PolicyCommands { timeout: u64, }, - /// Show current active policy for a sandbox. + /// Show current active policy for a sandbox or the global policy. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { - /// Sandbox name (defaults to last-used sandbox). + /// Sandbox name (defaults to last-used sandbox). Ignored with --global. #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, @@ -1383,18 +1383,26 @@ enum PolicyCommands { /// Print the full policy as YAML. #[arg(long)] full: bool, + + /// Show the global policy revision. + #[arg(long)] + global: bool, }, - /// List policy history for a sandbox. + /// List policy history for a sandbox or the global policy. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] List { - /// Sandbox name (defaults to last-used sandbox). + /// Sandbox name (defaults to last-used sandbox). Ignored with --global. #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, /// Maximum number of revisions to return. #[arg(long, default_value_t = 20)] limit: u32, + + /// List global policy revisions. + #[arg(long)] + global: bool, }, /// Delete the gateway-global policy lock, restoring sandbox-level policy control. @@ -1839,6 +1847,12 @@ async fn main() -> Result<()> { timeout, } => { if global { + if wait { + return Err(miette::miette!( + "--wait is not supported for global policies; \ + global policies are effective immediately" + )); + } run::sandbox_policy_set_global( &ctx.endpoint, &policy, @@ -1854,13 +1868,30 @@ async fn main() -> Result<()> { .await?; } } - PolicyCommands::Get { name, rev, full } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_get(&ctx.endpoint, &name, rev, full, &tls).await?; + PolicyCommands::Get { + name, + rev, + full, + global, + } => { + if global { + run::sandbox_policy_get_global(&ctx.endpoint, rev, full, &tls).await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_policy_get(&ctx.endpoint, &name, rev, full, &tls).await?; + } } - PolicyCommands::List { name, limit } => { - let name = resolve_sandbox_name(name, &ctx.name)?; - run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; + PolicyCommands::List { + name, + limit, + global, + } => { + if global { + run::sandbox_policy_list_global(&ctx.endpoint, limit, &tls).await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_policy_list(&ctx.endpoint, &name, limit, &tls).await?; + } } PolicyCommands::Delete { global, yes } => { if !global { @@ -3072,7 +3103,10 @@ mod tests { match cli.command { Some(Commands::Settings { - command: Some(SettingsCommands::Delete { key, global, yes, .. }), + command: + Some(SettingsCommands::Delete { + key, global, yes, .. + }), }) => { assert_eq!(key, "log_level"); assert!(global); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index f5c23365..10c3f93a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3958,10 +3958,7 @@ pub async fn sandbox_settings_get( if json { let obj = settings_to_json_sandbox(name, &response); - println!( - "{}", - serde_json::to_string_pretty(&obj).into_diagnostic()? - ); + println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?); return Ok(()); } @@ -4014,10 +4011,7 @@ pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> if json { let obj = settings_to_json_global(&response); - println!( - "{}", - serde_json::to_string_pretty(&obj).into_diagnostic()? - ); + println!("{}", serde_json::to_string_pretty(&obj).into_diagnostic()?); return Ok(()); } @@ -4259,6 +4253,7 @@ pub async fn sandbox_policy_set( .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { name: name.to_string(), version: 0, + global: false, }) .await .ok() @@ -4318,6 +4313,7 @@ pub async fn sandbox_policy_set( .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { name: name.to_string(), version: resp.version, + global: false, }) .await .into_diagnostic()?; @@ -4372,6 +4368,7 @@ pub async fn sandbox_policy_get( .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { name: name.to_string(), version, + global: false, }) .await .into_diagnostic()?; @@ -4410,6 +4407,54 @@ pub async fn sandbox_policy_get( Ok(()) } +pub async fn sandbox_policy_get_global( + server: &str, + version: u32, + full: bool, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + + let status_resp = client + .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { + name: String::new(), + version, + global: true, + }) + .await + .into_diagnostic()?; + + let inner = status_resp.into_inner(); + if let Some(rev) = inner.revision { + let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified); + println!("Scope: global"); + println!("Version: {}", rev.version); + println!("Hash: {}", rev.policy_hash); + println!("Status: {status:?}"); + if rev.created_at_ms > 0 { + println!("Created: {} ms", rev.created_at_ms); + } + if rev.loaded_at_ms > 0 { + println!("Loaded: {} ms", rev.loaded_at_ms); + } + + if full { + if let Some(ref policy) = rev.policy { + println!("---"); + let yaml_str = openshell_policy::serialize_sandbox_policy(policy) + .wrap_err("failed to serialize policy to YAML")?; + print!("{yaml_str}"); + } else { + eprintln!("Policy payload not available for this version"); + } + } + } else { + eprintln!("No global policy history found"); + } + + Ok(()) +} + pub async fn sandbox_policy_list( server: &str, name: &str, @@ -4423,6 +4468,7 @@ pub async fn sandbox_policy_list( name: name.to_string(), limit, offset: 0, + global: false, }) .await .into_diagnostic()?; @@ -4433,11 +4479,39 @@ pub async fn sandbox_policy_list( return Ok(()); } + print_policy_revision_table(&revisions); + Ok(()) +} + +pub async fn sandbox_policy_list_global(server: &str, limit: u32, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + + let resp = client + .list_sandbox_policies(ListSandboxPoliciesRequest { + name: String::new(), + limit, + offset: 0, + global: true, + }) + .await + .into_diagnostic()?; + + let revisions = resp.into_inner().revisions; + if revisions.is_empty() { + eprintln!("No global policy history found"); + return Ok(()); + } + + print_policy_revision_table(&revisions); + Ok(()) +} + +fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicyRevision]) { println!( "{:<8} {:<14} {:<12} {:<24} ERROR", "VERSION", "HASH", "STATUS", "CREATED" ); - for rev in &revisions { + for rev in revisions { let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified); let hash_short = if rev.policy_hash.len() >= 12 { &rev.policy_hash[..12] @@ -4458,8 +4532,6 @@ pub async fn sandbox_policy_list( error_short, ); } - - Ok(()) } // --------------------------------------------------------------------------- diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index df1b8e3c..5cb21d8e 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -12,8 +12,7 @@ use openshell_core::proto::{ DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxProviderEnvironmentRequest, GetSandboxSettingsRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - UpdateSettingsRequest, inference_client::InferenceClient, - open_shell_client::OpenShellClient, + UpdateSettingsRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; @@ -223,6 +222,8 @@ pub struct SettingsPollResult { pub policy_source: PolicySource, /// Effective settings keyed by name. pub settings: std::collections::HashMap, + /// When `policy_source` is `Global`, the version of the global policy revision. + pub global_policy_version: u32, } impl CachedOpenShellClient { @@ -259,6 +260,7 @@ impl CachedOpenShellClient { policy_source: PolicySource::try_from(inner.policy_source) .unwrap_or(PolicySource::Unspecified), settings: inner.settings, + global_policy_version: inner.global_policy_version, }) } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 305fca0e..493e4d23 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1366,7 +1366,9 @@ async fn run_policy_poll_loop( // Only reload OPA when the policy payload actually changed. if policy_changed { let Some(policy) = result.policy.as_ref() else { - warn!("Settings poll: policy hash changed but no policy payload present; skipping reload"); + warn!( + "Settings poll: policy hash changed but no policy payload present; skipping reload" + ); current_config_revision = result.config_revision; current_policy_hash = result.policy_hash; current_settings = result.settings; @@ -1375,10 +1377,18 @@ async fn run_policy_poll_loop( match opa_engine.reload_from_proto(policy) { Ok(()) => { - info!( - policy_hash = %result.policy_hash, - "Policy reloaded successfully" - ); + if result.global_policy_version > 0 { + info!( + policy_hash = %result.policy_hash, + global_version = result.global_policy_version, + "Policy reloaded successfully (global)" + ); + } else { + info!( + policy_hash = %result.policy_hash, + "Policy reloaded successfully" + ); + } if result.version > 0 && result.policy_source == PolicySource::Sandbox { if let Err(e) = client .report_policy_status(sandbox_id, result.version, true, "") @@ -1390,20 +1400,20 @@ async fn run_policy_poll_loop( } Err(e) => { warn!( - version = result.version, - error = %e, - "Policy reload failed, keeping last-known-good policy" - ); - if result.version > 0 && result.policy_source == PolicySource::Sandbox { - if let Err(report_err) = client - .report_policy_status(sandbox_id, result.version, false, &e.to_string()) - .await - { - warn!(error = %report_err, "Failed to report policy load failure"); + version = result.version, + error = %e, + "Policy reload failed, keeping last-known-good policy" + ); + if result.version > 0 && result.policy_source == PolicySource::Sandbox { + if let Err(report_err) = client + .report_policy_status(sandbox_id, result.version, false, &e.to_string()) + .await + { + warn!(error = %report_err, "Failed to report policy load failure"); + } } } } - } } current_config_revision = result.config_revision; diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 8ede2cc0..79b8d35a 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -30,8 +30,8 @@ use openshell_core::proto::{ RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, - UndoDraftChunkResponse, UpdateProviderRequest, UpdateSettingsRequest, - UpdateSettingsResponse, WatchSandboxRequest, open_shell_server::OpenShell, + UndoDraftChunkResponse, UpdateProviderRequest, UpdateSettingsRequest, UpdateSettingsResponse, + WatchSandboxRequest, open_shell_server::OpenShell, }; use openshell_core::proto::{ Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, @@ -120,6 +120,9 @@ const GLOBAL_SETTINGS_NAME: &str = "global"; const SANDBOX_SETTINGS_OBJECT_TYPE: &str = "sandbox_settings"; /// Reserved settings key used to store global policy payload. const POLICY_SETTING_KEY: &str = "policy"; +/// Sentinel `sandbox_id` used to store global policy revisions in the +/// `sandbox_policies` table alongside sandbox-scoped revisions. +const GLOBAL_POLICY_SANDBOX_ID: &str = "__global__"; #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct StoredSettings { @@ -846,6 +849,8 @@ impl OpenShell for OpenShellService { let sandbox_settings = load_sandbox_settings(self.state.store.as_ref(), &sandbox_id).await?; + let mut global_policy_version: u32 = 0; + if let Some(global_policy) = decode_policy_from_global_settings(&global_settings)? { policy = Some(global_policy.clone()); policy_hash = deterministic_policy_hash(&global_policy); @@ -855,6 +860,15 @@ impl OpenShell for OpenShellService { if version == 0 { version = 1; } + // Look up the global policy revision version number. + if let Ok(Some(global_rev)) = self + .state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + { + global_policy_version = u32::try_from(global_rev.version).unwrap_or(0); + } } let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; @@ -867,6 +881,7 @@ impl OpenShell for OpenShellService { settings, config_revision, policy_source: policy_source.into(), + global_policy_version, })) } @@ -1108,9 +1123,74 @@ impl OpenShell for OpenShellService { openshell_policy::ensure_sandbox_process_identity(&mut new_policy); validate_policy_safety(&new_policy)?; + // Compute hash and check for no-op (same policy as latest). + let payload = new_policy.encode_to_vec(); + let hash = deterministic_policy_hash(&new_policy); + + let latest = self + .state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + .map_err(|e| { + Status::internal(format!("fetch latest global policy failed: {e}")) + })?; + + if let Some(ref current) = latest { + if current.policy_hash == hash { + return Ok(Response::new(UpdateSettingsResponse { + version: u32::try_from(current.version).unwrap_or(0), + policy_hash: hash, + settings_revision: 0, + deleted: false, + })); + } + } + + let next_version = latest.map_or(1, |r| r.version + 1); + let policy_id = uuid::Uuid::new_v4().to_string(); + + // Persist the global policy revision. + self.state + .store + .put_policy_revision( + &policy_id, + GLOBAL_POLICY_SANDBOX_ID, + next_version, + &payload, + &hash, + ) + .await + .map_err(|e| { + Status::internal(format!("persist global policy revision failed: {e}")) + })?; + + // Mark it as loaded immediately (no sandbox confirmation for + // global policies) and supersede older revisions. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64); + let _ = self + .state + .store + .update_policy_status( + GLOBAL_POLICY_SANDBOX_ID, + next_version, + "loaded", + None, + Some(now_ms), + ) + .await; + let _ = self + .state + .store + .supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, next_version) + .await; + + // Also store in the settings blob (delivery mechanism for + // GetSandboxSettings). let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; - let stored_value = - StoredSettingValue::Bytes(hex::encode(new_policy.encode_to_vec())); + let stored_value = StoredSettingValue::Bytes(hex::encode(&payload)); let changed = upsert_setting_value( &mut global_settings.settings, POLICY_SETTING_KEY, @@ -1122,8 +1202,8 @@ impl OpenShell for OpenShellService { } return Ok(Response::new(UpdateSettingsResponse { - version: 0, - policy_hash: deterministic_policy_hash(&new_policy), + version: u32::try_from(next_version).unwrap_or(0), + policy_hash: hash, settings_revision: global_settings.revision, deleted: false, })); @@ -1373,38 +1453,43 @@ impl OpenShell for OpenShellService { request: Request, ) -> Result, Status> { let req = request.into_inner(); - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - let sandbox = self - .state - .store - .get_message_by_name::(&req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - - let sandbox_id = sandbox.id; + let (policy_id, active_version) = if req.global { + (GLOBAL_POLICY_SANDBOX_ID.to_string(), 0_u32) + } else { + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + let sandbox = self + .state + .store + .get_message_by_name::(&req.name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + (sandbox.id, sandbox.current_policy_version) + }; let record = if req.version == 0 { self.state .store - .get_latest_policy(&sandbox_id) + .get_latest_policy(&policy_id) .await .map_err(|e| Status::internal(format!("fetch policy failed: {e}")))? } else { self.state .store - .get_policy_by_version(&sandbox_id, i64::from(req.version)) + .get_policy_by_version(&policy_id, i64::from(req.version)) .await .map_err(|e| Status::internal(format!("fetch policy failed: {e}")))? }; - let record = - record.ok_or_else(|| Status::not_found("no policy revision found for this sandbox"))?; - - let active_version = sandbox.current_policy_version; + let not_found_msg = if req.global { + "no global policy revision found" + } else { + "no policy revision found for this sandbox" + }; + let record = record.ok_or_else(|| Status::not_found(not_found_msg))?; Ok(Response::new(GetSandboxPolicyStatusResponse { revision: Some(policy_record_to_revision(&record, true)), @@ -1417,23 +1502,28 @@ impl OpenShell for OpenShellService { request: Request, ) -> Result, Status> { let req = request.into_inner(); - if req.name.is_empty() { - return Err(Status::invalid_argument("name is required")); - } - let sandbox = self - .state - .store - .get_message_by_name::(&req.name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; + let policy_id = if req.global { + GLOBAL_POLICY_SANDBOX_ID.to_string() + } else { + if req.name.is_empty() { + return Err(Status::invalid_argument("name is required")); + } + let sandbox = self + .state + .store + .get_message_by_name::(&req.name) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + sandbox.id + }; let limit = clamp_limit(req.limit, 50, MAX_PAGE_SIZE); let records = self .state .store - .list_policies(&sandbox.id, limit, req.offset) + .list_policies(&policy_id, limit, req.offset) .await .map_err(|e| Status::internal(format!("list policies failed: {e}")))?; @@ -5972,12 +6062,13 @@ mod tests { let _guard = mutex.lock().await; let mut settings = super::load_global_settings(&store).await.unwrap(); // Simulate per-key mutation: each task sets a unique key. - settings.settings.insert( - format!("key_{i}"), - super::StoredSettingValue::Int(i as i64), - ); + settings + .settings + .insert(format!("key_{i}"), super::StoredSettingValue::Int(i as i64)); settings.revision = settings.revision.wrapping_add(1); - super::save_global_settings(&store, &settings).await.unwrap(); + super::save_global_settings(&store, &settings) + .await + .unwrap(); })); } @@ -6018,12 +6109,13 @@ mod tests { let mut settings = super::load_global_settings(&store).await.unwrap(); // Yield to encourage interleaving between load and save. tokio::task::yield_now().await; - settings.settings.insert( - format!("key_{i}"), - super::StoredSettingValue::Int(i as i64), - ); + settings + .settings + .insert(format!("key_{i}"), super::StoredSettingValue::Int(i as i64)); settings.revision = settings.revision.wrapping_add(1); - super::save_global_settings(&store, &settings).await.unwrap(); + super::save_global_settings(&store, &settings) + .await + .unwrap(); })); } @@ -6046,9 +6138,7 @@ mod tests { the locked test is the authoritative correctness check" ); } else { - eprintln!( - "unlocked test: {lost} lost writes out of {n} (expected behavior)" - ); + eprintln!("unlocked test: {lost} lost writes out of {n} (expected behavior)"); } // Either way, the WITH-lock test above asserts correctness. } diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index 99c62329..4c3a741f 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -552,8 +552,7 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { async fn update_settings( &self, _: tonic::Request, - ) -> Result, tonic::Status> - { + ) -> Result, tonic::Status> { Err(tonic::Status::unimplemented("test")) } diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 391e47ee..f24a4833 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -472,6 +472,8 @@ pub struct App { // Sandbox policy pane tab + sandbox settings pub sandbox_policy_tab: SandboxPolicyTab, + pub sandbox_policy_is_global: bool, + pub sandbox_global_policy_version: u32, pub sandbox_settings: Vec, pub sandbox_settings_selected: usize, pub sandbox_setting_edit: Option, @@ -598,6 +600,8 @@ impl App { pending_sandbox_detail: false, pending_shell_connect: false, sandbox_policy_tab: SandboxPolicyTab::Policy, + sandbox_policy_is_global: false, + sandbox_global_policy_version: 0, sandbox_settings: Vec::new(), sandbox_settings_selected: 0, sandbox_setting_edit: None, @@ -1203,8 +1207,7 @@ impl App { entry.key ); } else if entry.value.is_some() { - self.sandbox_confirm_setting_delete = - Some(self.sandbox_settings_selected); + self.sandbox_confirm_setting_delete = Some(self.sandbox_settings_selected); } } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 2b9bfa10..532f4c45 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -326,19 +326,12 @@ pub async fn run( // Refresh per-sandbox draft counts for badges (dashboard + detail). refresh_sandbox_draft_counts(&mut app).await; - // Auto-refresh the policy view when a new version is detected. + // Auto-refresh sandbox detail (policy, settings, drafts) on + // every tick when viewing a sandbox. The gRPC call is + // lightweight and ensures settings changes, global policy + // changes, and policy version bumps are reflected live. if app.screen == Screen::Sandbox { - let displayed = app.sandbox_policy.as_ref().map_or(0, |p| p.version); - let listed = app - .sandbox_policy_versions - .get(app.sandbox_selected) - .copied() - .unwrap_or(0); - if listed > 0 && listed != displayed { - refresh_sandbox_policy(&mut app).await; - } - - // Refresh draft chunks when on sandbox screen. + refresh_sandbox_policy(&mut app).await; refresh_draft_chunks(&mut app).await; } } @@ -763,12 +756,14 @@ async fn fetch_sandbox_detail(app: &mut App) { app.policy_lines = render_policy_lines(&policy, &app.theme); app.sandbox_policy = Some(policy); } - // Populate sandbox settings from the same response. + // Populate sandbox settings and policy source from the same response. + app.sandbox_policy_is_global = + inner.policy_source == openshell_core::proto::PolicySource::Global as i32; + app.sandbox_global_policy_version = inner.global_policy_version; app.apply_sandbox_settings(inner.settings); } Ok(Err(e)) => { - let msg = e.message().to_string(); - tracing::warn!("failed to fetch sandbox policy: {msg}"); + tracing::warn!("failed to fetch sandbox policy: {}", e.message()); } Err(_) => { tracing::warn!("sandbox policy request timed out"); @@ -2203,6 +2198,10 @@ async fn refresh_sandbox_policy(app: &mut App) { app.policy_lines = render_policy_lines(&policy, &app.theme); app.sandbox_policy = Some(policy); } + // Refresh settings and policy source alongside the policy. + app.sandbox_policy_is_global = + inner.policy_source == openshell_core::proto::PolicySource::Global as i32; + app.apply_sandbox_settings(inner.settings); } Ok(Err(e)) => { tracing::warn!("failed to refresh sandbox policy: {}", e.message()); diff --git a/crates/openshell-tui/src/ui/sandbox_detail.rs b/crates/openshell-tui/src/ui/sandbox_detail.rs index 34f4762e..0eab1178 100644 --- a/crates/openshell-tui/src/ui/sandbox_detail.rs +++ b/crates/openshell-tui/src/ui/sandbox_detail.rs @@ -97,6 +97,20 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let mut lines = vec![Line::from(""), row1, row2, row3, row4]; + // Show global policy indicator when the sandbox's policy is managed at + // gateway scope. + if app.sandbox_policy_is_global { + let version_label = if app.sandbox_global_policy_version > 0 { + format!("managed globally (v{})", app.sandbox_global_policy_version) + } else { + "managed globally".to_string() + }; + lines.push(Line::from(vec![ + Span::styled(" Policy: ", t.muted), + Span::styled(version_label, t.status_warn), + ])); + } + // Show pending network rules prompt — but not when delete confirmation is // active, since it would push the confirmation off the bottom of the pane. if pending_count > 0 && !app.confirm_delete { diff --git a/proto/openshell.proto b/proto/openshell.proto index 588ae243..5be1b506 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -478,10 +478,12 @@ message UpdateSettingsResponse { // Get sandbox policy status request. message GetSandboxPolicyStatusRequest { - // Sandbox name (canonical lookup key). + // 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. @@ -494,10 +496,12 @@ message GetSandboxPolicyStatusResponse { // List sandbox policies request. message ListSandboxPoliciesRequest { - // Sandbox name (canonical lookup key). + // 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. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index bcd798eb..899db198 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -172,4 +172,7 @@ message GetSandboxSettingsResponse { 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; } From 2dac61e3a54eec1a10d437a16824f6cf91630009 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:32:34 -0700 Subject: [PATCH 15/28] feat(settings): add global policy versioning, dashboard indicator, and auto-refresh sandbox settings --- crates/openshell-tui/src/app.rs | 6 ++++++ crates/openshell-tui/src/lib.rs | 25 ++++++++++++++++++++++++ crates/openshell-tui/src/ui/dashboard.rs | 18 ++++++++++++++--- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index f24a4833..254ad5de 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -431,6 +431,10 @@ pub struct App { // Middle pane tab (providers vs global settings) pub middle_pane_tab: MiddlePaneTab, + // Global policy indicator (dashboard) + pub global_policy_active: bool, + pub global_policy_version: u32, + // Global settings pub global_settings: Vec, pub global_settings_selected: usize, @@ -563,6 +567,8 @@ impl App { gateway_selected: 0, pending_gateway_switch: None, middle_pane_tab: MiddlePaneTab::Providers, + global_policy_active: false, + global_policy_version: 0, global_settings: Vec::new(), global_settings_selected: 0, global_settings_revision: 0, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 532f4c45..646f523c 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1881,6 +1881,31 @@ async fn refresh_global_settings(app: &mut App) { app.apply_global_settings(inner.settings, inner.settings_revision); } } + + // Check for active global policy. + let policy_req = openshell_core::proto::ListSandboxPoliciesRequest { + name: String::new(), + limit: 1, + offset: 0, + global: true, + }; + if let Ok(Ok(resp)) = tokio::time::timeout( + Duration::from_secs(5), + app.client.list_sandbox_policies(policy_req), + ) + .await + { + let revisions = resp.into_inner().revisions; + if let Some(latest) = revisions.first() { + let status = + openshell_core::proto::PolicyStatus::try_from(latest.status).unwrap_or_default(); + app.global_policy_active = status == openshell_core::proto::PolicyStatus::Loaded; + app.global_policy_version = latest.version; + } else { + app.global_policy_active = false; + app.global_policy_version = 0; + } + } } fn spawn_set_global_setting(app: &App, tx: mpsc::UnboundedSender) { diff --git a/crates/openshell-tui/src/ui/dashboard.rs b/crates/openshell-tui/src/ui/dashboard.rs index 5de1d0ae..9b0f4415 100644 --- a/crates/openshell-tui/src/ui/dashboard.rs +++ b/crates/openshell-tui/src/ui/dashboard.rs @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; +use ratatui::Frame; use crate::app::{App, Focus, MiddlePaneTab}; @@ -41,6 +41,7 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) { Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("TYPE", t.muted)), Cell::from(Span::styled("STATUS", t.muted)), + Cell::from(Span::styled("", t.muted)), ]) .bottom_margin(1); @@ -79,10 +80,20 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) { Cell::from(Span::styled("-", t.muted)) }; + let policy_cell = if is_active && app.global_policy_active { + Cell::from(Span::styled( + format!("Global Policy Active (v{})", app.global_policy_version), + t.status_warn, + )) + } else { + Cell::from(Span::raw("")) + }; + Row::new(vec![ name_cell, Cell::from(Span::styled(type_label, t.muted)), status_cell, + policy_cell, ]) }) .collect(); @@ -96,8 +107,9 @@ fn draw_gateway_list(frame: &mut Frame<'_>, app: &App, area: Rect) { .padding(Padding::horizontal(1)); let widths = [ - Constraint::Percentage(45), - Constraint::Percentage(20), + Constraint::Percentage(30), + Constraint::Percentage(10), + Constraint::Percentage(25), Constraint::Percentage(35), ]; From befaf7879edcc6249330ef55cd21c3d92ff9fc0d Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 08:53:04 -0700 Subject: [PATCH 16/28] chore: fix rustfmt import ordering --- crates/openshell-core/src/settings.rs | 4 ++-- crates/openshell-tui/src/ui/dashboard.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 3e04115c..43f06537 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -94,8 +94,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - parse_bool_like, registered_keys_csv, setting_for_key, RegisteredSetting, SettingValueKind, - REGISTERED_SETTINGS, + REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, + registered_keys_csv, setting_for_key, }; #[test] diff --git a/crates/openshell-tui/src/ui/dashboard.rs b/crates/openshell-tui/src/ui/dashboard.rs index 9b0f4415..43ae6a93 100644 --- a/crates/openshell-tui/src/ui/dashboard.rs +++ b/crates/openshell-tui/src/ui/dashboard.rs @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; -use ratatui::Frame; use crate::app::{App, Focus, MiddlePaneTab}; From aef42f2f00c7d531e851cd1fb622d5e7c5ff526c Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:11:20 -0700 Subject: [PATCH 17/28] fix(e2e): update Python tests for UpdateSandboxPolicy -> UpdateSettings rename --- e2e/python/test_policy_validation.py | 6 +++--- e2e/python/test_sandbox_policy.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/e2e/python/test_policy_validation.py b/e2e/python/test_policy_validation.py index ee12ae5c..b77b3935 100644 --- a/e2e/python/test_policy_validation.py +++ b/e2e/python/test_policy_validation.py @@ -126,7 +126,7 @@ def test_update_policy_rejects_immutable_fields( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """UpdateSandboxPolicy rejects removal of filesystem paths on a live sandbox. + """UpdateSettings rejects removal of filesystem paths on a live sandbox. Filesystem paths are enforced by Landlock at sandbox startup and cannot be removed after the fact. This test verifies that the server rejects updates @@ -153,8 +153,8 @@ def test_update_policy_rejects_immutable_fields( ) with pytest.raises(grpc.RpcError) as exc_info: - stub.UpdateSandboxPolicy( - openshell_pb2.UpdateSandboxPolicyRequest( + stub.UpdateSettings( + openshell_pb2.UpdateSettingsRequest( name=sandbox_name, policy=unsafe_policy, ) diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index ab135d63..db67fa6d 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -1156,8 +1156,8 @@ def test_live_policy_update_and_logs( initial_hash = status_resp.revision.policy_hash # --- LPU-2: Set the same policy -> no new version --- - update_resp = stub.UpdateSandboxPolicy( - openshell_pb2.UpdateSandboxPolicyRequest( + update_resp = stub.UpdateSettings( + openshell_pb2.UpdateSettingsRequest( name=sandbox_name, policy=policy_a, ) @@ -1169,8 +1169,8 @@ def test_live_policy_update_and_logs( assert update_resp.policy_hash == initial_hash # --- LPU-3: Push policy B -> new version --- - update_resp = stub.UpdateSandboxPolicy( - openshell_pb2.UpdateSandboxPolicyRequest( + update_resp = stub.UpdateSettings( + openshell_pb2.UpdateSettingsRequest( name=sandbox_name, policy=policy_b, ) @@ -1213,8 +1213,8 @@ def test_live_policy_update_and_logs( ) # --- LPU-4: Push policy B again -> unchanged --- - update_resp = stub.UpdateSandboxPolicy( - openshell_pb2.UpdateSandboxPolicyRequest( + update_resp = stub.UpdateSettings( + openshell_pb2.UpdateSettingsRequest( name=sandbox_name, policy=policy_b, ) From f0fdbd00741054c77251141adcf540f49521e83e Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:26:59 -0700 Subject: [PATCH 18/28] fix(tui): add Left arrow key to sandbox policy/settings tab switching --- crates/openshell-tui/src/app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 254ad5de..dab1629e 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1138,7 +1138,7 @@ impl App { self.policy_scroll = 0; } KeyCode::Char('q') => self.running = false, - KeyCode::Char('h') | KeyCode::Right => { + KeyCode::Char('h') | KeyCode::Left | KeyCode::Right => { self.sandbox_policy_tab = self.sandbox_policy_tab.next(); } _ => {} @@ -1154,7 +1154,7 @@ impl App { self.screen = Screen::Dashboard; self.focus = Focus::Sandboxes; } - KeyCode::Char('h') | KeyCode::Right => { + KeyCode::Char('h') | KeyCode::Left | KeyCode::Right => { self.sandbox_policy_tab = self.sandbox_policy_tab.next(); } KeyCode::Char('l') => { From 11ff1e93d8b0dc343d1a700469e549241106d841 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:35:45 -0700 Subject: [PATCH 19/28] chore(settings): gate dev keys behind feature flag, filter stale keys from responses --- .github/workflows/docker-build.yml | 3 +++ crates/openshell-cli/src/run.rs | 4 ++-- crates/openshell-core/Cargo.toml | 6 +++--- crates/openshell-core/src/settings.rs | 18 ++++++------------ crates/openshell-server/Cargo.toml | 3 +++ crates/openshell-server/src/grpc.rs | 13 +++++++++++-- crates/openshell-tui/src/ui/global_settings.rs | 2 +- .../openshell-tui/src/ui/sandbox_settings.rs | 2 +- deploy/docker/Dockerfile.images | 6 ++++-- tasks/scripts/docker-build-image.sh | 6 ++++++ tasks/test.toml | 5 ++++- 11 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 48f68ab6..16a8447c 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -90,4 +90,7 @@ jobs: env: DOCKER_BUILDER: openshell OPENSHELL_CARGO_VERSION: ${{ steps.version.outputs.cargo_version }} + # Enable dev-settings feature for test settings (dummy_bool, dummy_int) + # used by e2e tests. + EXTRA_CARGO_FEATURES: openshell-core/dev-settings run: mise run --no-prepare docker:build:${{ inputs.component }} diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 10c3f93a..a29ff697 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3975,7 +3975,7 @@ pub async fn sandbox_settings_get( println!("Policy Hash: {}", response.policy_hash); if response.settings.is_empty() { - println!("Settings: (none)"); + println!("Settings: No settings available."); return Ok(()); } @@ -4019,7 +4019,7 @@ pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> println!("Settings Rev: {}", response.settings_revision); if response.settings.is_empty() { - println!("Settings: (none)"); + println!("Settings: No settings available."); return Ok(()); } diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 611d66fc..8bccef54 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -21,10 +21,10 @@ serde_json = { workspace = true } url = { workspace = true } [features] -## Include dummy test settings (dummy_bool, dummy_int) in the registry. -## Enabled by default during development; disable for release builds. +## Include test-only settings (dummy_bool, dummy_int) in the registry. +## Off by default so production builds have an empty registry. +## Enabled by e2e tests and during development. dev-settings = [] -default = ["dev-settings"] [build-dependencies] tonic-build = { workspace = true } diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 43f06537..537f3007 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -49,10 +49,10 @@ pub struct RegisteredSetting { /// keys are accepted. /// 5. Add a unit test in this module's `tests` section to cover the new key. pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ - RegisteredSetting { - key: "log_level", - kind: SettingValueKind::String, - }, + // Production settings go here. Add entries following the steps above. + // + // Test-only keys live behind the `dev-settings` feature flag so they + // don't appear in production builds. #[cfg(feature = "dev-settings")] RegisteredSetting { key: "dummy_int", @@ -94,16 +94,10 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, - registered_keys_csv, setting_for_key, + parse_bool_like, registered_keys_csv, setting_for_key, RegisteredSetting, SettingValueKind, + REGISTERED_SETTINGS, }; - #[test] - fn setting_for_key_returns_registered_entry() { - let setting = setting_for_key("log_level").expect("log_level should be registered"); - assert_eq!(setting.kind, SettingValueKind::String); - } - #[cfg(feature = "dev-settings")] #[test] fn setting_for_key_returns_dev_entries() { diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 7bd72113..0308f30f 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -74,6 +74,9 @@ russh = "0.57" rand = "0.9" petname = "2" +[features] +dev-settings = ["openshell-core/dev-settings"] + [dev-dependencies] hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring", "webpki-tokio"] } rcgen = { version = "0.13", features = ["crypto", "pem"] } diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 79b8d35a..48bb782a 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -2910,7 +2910,7 @@ fn merge_effective_settings( } for (key, value) in &sandbox.settings { - if key == POLICY_SETTING_KEY { + if key == POLICY_SETTING_KEY || settings::setting_for_key(key).is_none() { continue; } merged.insert( @@ -2923,7 +2923,7 @@ fn merge_effective_settings( } for (key, value) in &global.settings { - if key == POLICY_SETTING_KEY { + if key == POLICY_SETTING_KEY || settings::setting_for_key(key).is_none() { continue; } merged.insert( @@ -2950,6 +2950,11 @@ fn materialize_global_settings( if key == POLICY_SETTING_KEY { continue; } + // Only include keys that are in the current registry. Stale keys + // from a previous build are ignored. + if settings::setting_for_key(key).is_none() { + continue; + } materialized.insert(key.clone(), stored_setting_to_proto(value)?); } @@ -5308,6 +5313,7 @@ mod tests { assert!(err.message().contains("value")); } + #[cfg(feature = "dev-settings")] #[test] fn merge_effective_settings_global_overrides_sandbox_key() { let global = super::StoredSettings { @@ -5479,6 +5485,7 @@ mod tests { assert!(err.message().contains("unknown setting key")); } + #[cfg(feature = "dev-settings")] #[test] fn proto_setting_to_stored_rejects_type_mismatch() { let value = openshell_core::proto::SettingValue { @@ -5492,6 +5499,7 @@ mod tests { assert!(err.message().contains("expects bool value")); } + #[cfg(feature = "dev-settings")] #[test] fn proto_setting_to_stored_accepts_bool_for_registered_bool_key() { let value = openshell_core::proto::SettingValue { @@ -5504,6 +5512,7 @@ mod tests { // ---- merge_effective_settings: sandbox-scoped values ---- + #[cfg(feature = "dev-settings")] #[test] fn merge_effective_settings_sandbox_scoped_value_has_sandbox_scope() { let global = super::StoredSettings::default(); diff --git a/crates/openshell-tui/src/ui/global_settings.rs b/crates/openshell-tui/src/ui/global_settings.rs index fe5875f1..cac59b0a 100644 --- a/crates/openshell-tui/src/ui/global_settings.rs +++ b/crates/openshell-tui/src/ui/global_settings.rs @@ -78,7 +78,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { width: area.width.saturating_sub(4), height: area.height.saturating_sub(3), }; - let msg = Paragraph::new(Span::styled(" Loading settings...", t.muted)); + let msg = Paragraph::new(Span::styled(" No settings available.", t.muted)); frame.render_widget(msg, inner); } diff --git a/crates/openshell-tui/src/ui/sandbox_settings.rs b/crates/openshell-tui/src/ui/sandbox_settings.rs index 301c846e..c26f4a66 100644 --- a/crates/openshell-tui/src/ui/sandbox_settings.rs +++ b/crates/openshell-tui/src/ui/sandbox_settings.rs @@ -89,7 +89,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { width: area.width.saturating_sub(4), height: area.height.saturating_sub(3), }; - let msg = Paragraph::new(Span::styled(" Loading settings...", t.muted)); + let msg = Paragraph::new(Span::styled(" No settings available.", t.muted)); frame.render_widget(msg, inner); } diff --git a/deploy/docker/Dockerfile.images b/deploy/docker/Dockerfile.images index 2139e4c6..9cc50085 100644 --- a/deploy/docker/Dockerfile.images +++ b/deploy/docker/Dockerfile.images @@ -110,13 +110,14 @@ RUN touch \ FROM gateway-workspace AS gateway-builder ARG CARGO_CODEGEN_UNITS +ARG EXTRA_CARGO_FEATURES="" RUN --mount=type=cache,id=cargo-registry-${TARGETARCH},sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=cargo-git-${TARGETARCH},sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=cargo-target-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ --mount=type=cache,id=sccache-${TARGETARCH},sharing=locked,target=/tmp/sccache \ . cross-build.sh && \ - cargo_cross_build --release -p openshell-server && \ + cargo_cross_build --release -p openshell-server ${EXTRA_CARGO_FEATURES:+--features "$EXTRA_CARGO_FEATURES"} && \ mkdir -p /build/out && \ cp "$(cross_output_dir release)/openshell-server" /build/out/ @@ -138,13 +139,14 @@ RUN touch \ FROM supervisor-workspace AS supervisor-builder ARG CARGO_CODEGEN_UNITS +ARG EXTRA_CARGO_FEATURES="" RUN --mount=type=cache,id=cargo-registry-${TARGETARCH},sharing=locked,target=/usr/local/cargo/registry \ --mount=type=cache,id=cargo-git-${TARGETARCH},sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,id=cargo-target-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \ --mount=type=cache,id=sccache-${TARGETARCH},sharing=locked,target=/tmp/sccache \ . cross-build.sh && \ - cargo_cross_build --release -p openshell-sandbox && \ + cargo_cross_build --release -p openshell-sandbox ${EXTRA_CARGO_FEATURES:+--features "$EXTRA_CARGO_FEATURES"} && \ mkdir -p /build/out && \ cp "$(cross_output_dir release)/openshell-sandbox" /build/out/ diff --git a/tasks/scripts/docker-build-image.sh b/tasks/scripts/docker-build-image.sh index ea2fa08b..f8da08c4 100755 --- a/tasks/scripts/docker-build-image.sh +++ b/tasks/scripts/docker-build-image.sh @@ -159,6 +159,11 @@ else exit 1 fi +FEATURE_ARGS=() +if [[ -n "${EXTRA_CARGO_FEATURES:-}" ]]; then + FEATURE_ARGS=(--build-arg "EXTRA_CARGO_FEATURES=${EXTRA_CARGO_FEATURES}") +fi + docker buildx build \ ${BUILDER_ARGS[@]+"${BUILDER_ARGS[@]}"} \ ${DOCKER_PLATFORM:+--platform ${DOCKER_PLATFORM}} \ @@ -167,6 +172,7 @@ docker buildx build \ ${VERSION_ARGS[@]+"${VERSION_ARGS[@]}"} \ ${K3S_ARGS[@]+"${K3S_ARGS[@]}"} \ ${CODEGEN_ARGS[@]+"${CODEGEN_ARGS[@]}"} \ + ${FEATURE_ARGS[@]+"${FEATURE_ARGS[@]}"} \ --build-arg "CARGO_TARGET_CACHE_SCOPE=${CARGO_TARGET_CACHE_SCOPE}" \ -f "${DOCKERFILE}" \ --target "${DOCKER_TARGET}" \ diff --git a/tasks/test.toml b/tasks/test.toml index f53f9152..78118760 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -30,7 +30,10 @@ hide = true ["e2e:rust"] description = "Run Rust CLI e2e tests (requires a running cluster)" depends = ["cluster"] -run = ["cargo build -p openshell-cli", "cargo test --manifest-path e2e/rust/Cargo.toml --features e2e"] +run = [ + "cargo build -p openshell-cli --features openshell-core/dev-settings", + "cargo test --manifest-path e2e/rust/Cargo.toml --features e2e", +] ["e2e:python"] description = "Run Python e2e tests (E2E_PARALLEL=N or 'auto'; default 5)" From efe4c52fe57aa18f302dcd564b5d0a3b74f799a1 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:01:15 -0700 Subject: [PATCH 20/28] chore: fix rustfmt import ordering in settings.rs --- crates/openshell-core/src/settings.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 537f3007..b94c08fc 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -94,8 +94,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - parse_bool_like, registered_keys_csv, setting_for_key, RegisteredSetting, SettingValueKind, - REGISTERED_SETTINGS, + REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, + registered_keys_csv, setting_for_key, }; #[cfg(feature = "dev-settings")] From 1b761b079ea201b80ce4c9a7e8739c4ce3171ef1 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:08:52 -0700 Subject: [PATCH 21/28] fix(settings): gate CLI tests referencing dev-settings keys --- crates/openshell-cli/Cargo.toml | 3 +++ crates/openshell-cli/src/run.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 61c20450..ef6d8779 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -74,6 +74,9 @@ tracing-subscriber = { workspace = true } [lints] workspace = true +[features] +dev-settings = ["openshell-core/dev-settings"] + [dev-dependencies] futures = { workspace = true } rcgen = { version = "0.13", features = ["crypto", "pem"] } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a29ff697..b0751a6f 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5062,6 +5062,7 @@ mod tests { )); } + #[cfg(feature = "dev-settings")] #[test] fn parse_cli_setting_value_parses_bool_aliases() { let yes_value = parse_cli_setting_value("dummy_bool", "yes").expect("parse yes"); @@ -5079,6 +5080,7 @@ mod tests { ); } + #[cfg(feature = "dev-settings")] #[test] fn parse_cli_setting_value_parses_int_key() { let int_value = parse_cli_setting_value("dummy_int", "42").expect("parse int"); @@ -5088,6 +5090,7 @@ mod tests { ); } + #[cfg(feature = "dev-settings")] #[test] fn parse_cli_setting_value_rejects_invalid_bool() { let err = From 633e9704616f49f537835e9cc819701615f7a6ca Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:02:31 -0700 Subject: [PATCH 22/28] fix(settings): block draft chunk approval when global policy is active --- crates/openshell-server/src/grpc.rs | 21 ++++++ crates/openshell-tui/src/app.rs | 67 +++++++++++++------- crates/openshell-tui/src/ui/sandbox_draft.rs | 32 ++++++++-- 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 48bb782a..84b575d4 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1915,6 +1915,8 @@ impl OpenShell for OpenShellService { return Err(Status::invalid_argument("chunk_id is required")); } + require_no_global_policy(&self.state).await?; + // Resolve sandbox. let sandbox = self .state @@ -2035,7 +2037,10 @@ impl OpenShell for OpenShellService { ); // If the chunk was approved, remove its rule from the active policy. + // Block revoke when a global policy is active since the sandbox policy + // isn't in use anyway. if was_approved { + require_no_global_policy(&self.state).await?; remove_chunk_from_policy(&self.state, &sandbox_id, &chunk).await?; } @@ -2063,6 +2068,8 @@ impl OpenShell for OpenShellService { return Err(Status::invalid_argument("name is required")); } + require_no_global_policy(&self.state).await?; + // Resolve sandbox. let sandbox = self .state @@ -2435,6 +2442,20 @@ fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result Result<(), Status> { + let global = load_global_settings(state.store.as_ref()).await?; + if global.settings.contains_key(POLICY_SETTING_KEY) { + return Err(Status::failed_precondition( + "cannot approve rules while a global policy is active; \ + delete the global policy to manage per-sandbox rules", + )); + } + Ok(()) +} + async fn merge_chunk_into_policy( store: &crate::persistence::Store, sandbox_id: &str, diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index dab1629e..e7496a4f 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1318,22 +1318,32 @@ impl App { } // Allow approve/reject toggle from within the popup. KeyCode::Char('a') => { - let abs = self.draft_scroll + self.draft_selected; - if abs < self.draft_chunks.len() { - let st = self.draft_chunks[abs].status.as_str(); - if st == "pending" || st == "rejected" { - self.pending_draft_approve = true; - self.draft_detail_open = false; + if self.sandbox_policy_is_global { + self.status_text = + "Cannot approve rules while a global policy is active".to_string(); + } else { + let abs = self.draft_scroll + self.draft_selected; + if abs < self.draft_chunks.len() { + let st = self.draft_chunks[abs].status.as_str(); + if st == "pending" || st == "rejected" { + self.pending_draft_approve = true; + self.draft_detail_open = false; + } } } } KeyCode::Char('x') => { - let abs = self.draft_scroll + self.draft_selected; - if abs < self.draft_chunks.len() { - let st = self.draft_chunks[abs].status.as_str(); - if st == "pending" || st == "approved" { - self.pending_draft_reject = true; - self.draft_detail_open = false; + if self.sandbox_policy_is_global { + self.status_text = + "Cannot modify rules while a global policy is active".to_string(); + } else { + let abs = self.draft_scroll + self.draft_selected; + if abs < self.draft_chunks.len() { + let st = self.draft_chunks[abs].status.as_str(); + if st == "pending" || st == "approved" { + self.pending_draft_reject = true; + self.draft_detail_open = false; + } } } } @@ -1401,7 +1411,10 @@ impl App { } // Approve selected chunk (pending → approved, rejected → approved). KeyCode::Char('a') => { - if !self.draft_chunks.is_empty() { + if self.sandbox_policy_is_global { + self.status_text = + "Cannot approve rules while a global policy is active".to_string(); + } else if !self.draft_chunks.is_empty() { let abs = self.draft_scroll + self.draft_selected; if abs < total { let st = self.draft_chunks[abs].status.as_str(); @@ -1413,7 +1426,10 @@ impl App { } // Reject selected chunk (pending → rejected, approved → rejected). KeyCode::Char('x') => { - if !self.draft_chunks.is_empty() { + if self.sandbox_policy_is_global { + self.status_text = + "Cannot modify rules while a global policy is active".to_string(); + } else if !self.draft_chunks.is_empty() { let abs = self.draft_scroll + self.draft_selected; if abs < total { let st = self.draft_chunks[abs].status.as_str(); @@ -1425,15 +1441,20 @@ impl App { } // Approve all pending chunks — show confirmation modal. KeyCode::Char('A') => { - let pending: Vec<_> = self - .draft_chunks - .iter() - .filter(|c| c.status == "pending") - .cloned() - .collect(); - if !pending.is_empty() { - self.approve_all_confirm_chunks = pending; - self.approve_all_confirm_open = true; + if self.sandbox_policy_is_global { + self.status_text = + "Cannot approve rules while a global policy is active".to_string(); + } else { + let pending: Vec<_> = self + .draft_chunks + .iter() + .filter(|c| c.status == "pending") + .cloned() + .collect(); + if !pending.is_empty() { + self.approve_all_confirm_chunks = pending; + self.approve_all_confirm_open = true; + } } } KeyCode::Char('q') => self.running = false, diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index b5b683a9..528d1c60 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -30,12 +30,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { Line::from(Span::styled(" Network Rules ", t.heading)) }; - let block = Block::default() + let mut block = Block::default() .title(title) .borders(Borders::ALL) .border_style(t.border_focused) .padding(Padding::horizontal(1)); + if app.sandbox_policy_is_global { + block = block.title_bottom( + Line::from(Span::styled( + " Cannot approve rules while global policy is active ", + t.status_warn, + )) + .left_aligned(), + ); + } + if app.draft_chunks.is_empty() { let msg = Paragraph::new( "No network rules yet. Denied connections will \ @@ -69,14 +79,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { .map(|(i, chunk)| { let is_selected = i == cursor_pos; - let status_style = match chunk.status.as_str() { - "pending" => t.status_warn, - "approved" => t.status_ok, - "rejected" => t.status_err, - _ => t.muted, + let globally_locked = app.sandbox_policy_is_global; + + let status_style = if globally_locked { + t.muted + } else { + match chunk.status.as_str() { + "pending" => t.status_warn, + "approved" => t.status_ok, + "rejected" => t.status_err, + _ => t.muted, + } }; - let name_style = if is_selected { + let name_style = if globally_locked { + t.muted + } else if is_selected { t.selected } else if chunk.status == "rejected" { t.muted From 408650f8b329b46792ae12a14079d5999dff8c48 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:45:13 -0700 Subject: [PATCH 23/28] fix(settings): ensure global policy dedup still writes settings blob --- crates/openshell-server/src/grpc.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 84b575d4..67cc8a44 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1138,10 +1138,27 @@ impl OpenShell for OpenShellService { if let Some(ref current) = latest { if current.policy_hash == hash { + // Same policy hash — skip creating a new revision but + // still ensure the settings blob has the policy key + // (it may have been lost to a pod restart while the + // sandbox_policies table retained the revision). + let mut global_settings = + load_global_settings(self.state.store.as_ref()).await?; + let stored_value = StoredSettingValue::Bytes(hex::encode(&payload)); + let changed = upsert_setting_value( + &mut global_settings.settings, + POLICY_SETTING_KEY, + stored_value, + ); + if changed { + global_settings.revision = global_settings.revision.wrapping_add(1); + save_global_settings(self.state.store.as_ref(), &global_settings) + .await?; + } return Ok(Response::new(UpdateSettingsResponse { version: u32::try_from(current.version).unwrap_or(0), policy_hash: hash, - settings_revision: 0, + settings_revision: global_settings.revision, deleted: false, })); } From c9166c7d85296f37c4c9e5078f82c9226607a946 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:00:52 -0700 Subject: [PATCH 24/28] fix(settings): supersede global policy revisions when global policy is deleted --- crates/openshell-server/src/grpc.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 67cc8a44..2e3a6c2d 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1238,7 +1238,24 @@ impl OpenShell for OpenShellService { let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; let changed = if req.delete_setting { - global_settings.settings.remove(key).is_some() + let removed = global_settings.settings.remove(key).is_some(); + // When deleting the global policy key, supersede all global + // policy revisions so they no longer appear as "Loaded". + if removed && key == POLICY_SETTING_KEY { + if let Ok(Some(latest)) = self + .state + .store + .get_latest_policy(GLOBAL_POLICY_SANDBOX_ID) + .await + { + let _ = self + .state + .store + .supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, latest.version + 1) + .await; + } + } + removed } else { let setting = req .setting_value From c95de83d09ce32f7159db4fb90a28e5d2d1db306 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:05:16 -0700 Subject: [PATCH 25/28] fix(settings): skip dedup when latest global policy revision is superseded --- crates/openshell-server/src/grpc.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 2e3a6c2d..00bedd27 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -1137,7 +1137,10 @@ impl OpenShell for OpenShellService { })?; if let Some(ref current) = latest { - if current.policy_hash == hash { + // Only dedup if the latest revision is still active + // (loaded). If it was superseded (e.g. after a global + // policy delete), always create a new revision. + if current.policy_hash == hash && current.status == "loaded" { // Same policy hash — skip creating a new revision but // still ensure the settings blob has the policy key // (it may have been lost to a pod restart while the From 1998927bea0ebbc7a5207f1a77933970ba1cd2a5 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:18:09 -0700 Subject: [PATCH 26/28] docs(architecture): document global policy lifecycle, state machine, and sandbox effects --- architecture/gateway-settings.md | 219 +++++++++++++++++++++++++++---- architecture/gateway.md | 16 ++- architecture/sandbox.md | 12 +- architecture/security-policy.md | 18 ++- architecture/tui.md | 4 + 5 files changed, 228 insertions(+), 41 deletions(-) diff --git a/architecture/gateway-settings.md b/architecture/gateway-settings.md index f1f421f5..ef9538f5 100644 --- a/architecture/gateway-settings.md +++ b/architecture/gateway-settings.md @@ -10,13 +10,15 @@ The settings channel provides a two-tier key-value configuration system that the graph TD CLI["CLI / TUI"] GW["Gateway
(openshell-server)"] - DB["Store
(objects table)"] + OBJ["Store: objects table
(gateway_settings,
sandbox_settings blobs)"] + POL["Store: sandbox_policies table
(revisions for sandbox-scoped
and __global__ policies)"] SB["Sandbox
(poll loop)"] - CLI -- "UpdateSandboxPolicy
(setting_key + value)" --> GW - CLI -- "GetSandboxSettings
GetGatewaySettings" --> GW - GW -- "load/save
gateway_settings
sandbox_settings" --> DB - GW -- "GetSandboxSettingsResponse
(policy + settings + config_revision)" --> SB + CLI -- "UpdateSettings
(policy / setting_key + value)" --> GW + CLI -- "GetSandboxSettings
GetGatewaySettings
ListSandboxPolicies
GetSandboxPolicyStatus" --> GW + GW -- "load/save settings blobs
(delivery mechanism)" --> OBJ + GW -- "put/list/update
policy revisions
(audit + versioning)" --> POL + GW -- "GetSandboxSettingsResponse
(policy + settings +
config_revision +
global_policy_version)" --> SB SB -- "diff settings
reload OPA on policy change" --> SB ``` @@ -66,12 +68,12 @@ Helper functions: | RPC | Request | Response | Called by | |-----|---------|----------|-----------| -| `GetSandboxSettings` | `GetSandboxSettingsRequest { sandbox_id }` | `GetSandboxSettingsResponse { policy, version, policy_hash, settings, config_revision, policy_source }` | Sandbox poll loop, CLI `settings get` | +| `GetSandboxSettings` | `GetSandboxSettingsRequest { sandbox_id }` | `GetSandboxSettingsResponse { policy, version, policy_hash, settings, config_revision, policy_source, global_policy_version }` | Sandbox poll loop, CLI `settings get` | | `GetGatewaySettings` | `GetGatewaySettingsRequest {}` | `GetGatewaySettingsResponse { settings, settings_revision }` | CLI `settings get --global`, TUI dashboard | -### Extended `UpdateSandboxPolicyRequest` +### `UpdateSettingsRequest` -The existing `UpdateSandboxPolicy` RPC now multiplexes policy and setting mutations through additional fields: +The `UpdateSettings` RPC multiplexes policy and setting mutations through a single request message: | Field | Type | Description | |-------|------|-------------| @@ -93,11 +95,15 @@ Validation rules: ### Storage Model -Settings are persisted using the existing generic `objects` table with two new object types: +The settings channel uses two storage mechanisms: the `objects` table for settings blobs (fast delivery) and the `sandbox_policies` table for versioned policy revisions (audit/history). + +#### Settings blobs (`objects` table) + +Settings are persisted using the existing generic `objects` table with two object types: | Object type string | Record ID | Record name | Purpose | |--------------------|-----------|-------------|---------| -| `gateway_settings` | `"global"` | `"global"` | Singleton global settings | +| `gateway_settings` | `"global"` | `"global"` | Singleton global settings (includes reserved `policy` key for delivery) | | `sandbox_settings` | `"settings:{sandbox_uuid}"` | sandbox name | Per-sandbox settings | The sandbox settings ID is prefixed with `settings:` to avoid a primary key collision with the sandbox's own record in the `objects` table. The `sandbox_settings_id()` function computes this key. @@ -118,6 +124,24 @@ enum StoredSettingValue { } ``` +#### Policy revisions (`sandbox_policies` table) + +Global policy revisions are stored in the `sandbox_policies` table using the sentinel `sandbox_id = "__global__"` (`GLOBAL_POLICY_SANDBOX_ID` constant). This reuses the same schema as sandbox-scoped policy revisions: + +| Column | Type | Description | +|--------|------|-------------| +| `id` | `TEXT` | UUID primary key | +| `sandbox_id` | `TEXT` | `"__global__"` for global revisions, sandbox UUID for sandbox-scoped | +| `version` | `INTEGER` | Monotonically increasing per `sandbox_id` | +| `policy_payload` | `BLOB` | Protobuf-encoded `SandboxPolicy` | +| `policy_hash` | `TEXT` | Deterministic SHA-256 hash of the policy | +| `status` | `TEXT` | `pending`, `loaded`, `failed`, or `superseded` | +| `load_error` | `TEXT` | Error message (populated on `failed` status) | +| `created_at_ms` | `INTEGER` | Epoch milliseconds when the revision was created | +| `loaded_at_ms` | `INTEGER` | Epoch milliseconds when the revision was marked loaded | + +The `sandbox_policies` table provides history and audit trail (queried by `policy list --global` and `policy get --global`). The `gateway_settings` blob's `policy` key is the authoritative source that `GetSandboxSettings` reads for fast poll resolution. Both are written on `policy set --global` -- this dual-write is intentional. + ### Two-Tier Resolution (`merge_effective_settings`) The `GetSandboxSettings` handler resolves the effective settings map by merging sandbox and global tiers: @@ -141,25 +165,87 @@ flowchart LR ### Global Policy as a Setting -The reserved `policy` key in global settings stores a protobuf-encoded `SandboxPolicy`. When present, `GetSandboxSettings` uses the global policy instead of the sandbox's own policy: +The reserved `policy` key in global settings stores a hex-encoded protobuf `SandboxPolicy`. When present, `GetSandboxSettings` uses the global policy instead of the sandbox's own policy: 1. `decode_policy_from_global_settings()` checks for the `policy` key in global settings 2. If present, the global policy replaces the sandbox policy in the response 3. `policy_source` is set to `GLOBAL` 4. The sandbox policy version counter is preserved for status APIs +5. The `global_policy_version` field is populated from the latest `__global__` revision in the `sandbox_policies` table This allows operators to push a single policy that applies to all sandboxes via `openshell policy set --global --policy FILE`. -### Config Revision (`compute_config_revision`) +### Global Policy Lifecycle + +Global policies are versioned through a full revision lifecycle stored alongside sandbox policies. The sentinel `sandbox_id = "__global__"` (constant `GLOBAL_POLICY_SANDBOX_ID`) distinguishes global revisions from sandbox-scoped revisions in the same `sandbox_policies` table. + +#### State Machine + +```mermaid +stateDiagram-v2 + [*] --> NoGlobalPolicy + + NoGlobalPolicy --> v1_Loaded : policy set --global
(creates v1, marks loaded) + + v1_Loaded --> v1_Loaded : policy set --global
(same hash, dedup no-op) + v1_Loaded --> v2_Loaded : policy set --global
(different hash) + v1_Loaded --> AllSuperseded : policy delete --global + + v2_Loaded --> v2_Loaded : policy set --global
(same hash, dedup no-op) + v2_Loaded --> v3_Loaded : policy set --global
(different hash) + v2_Loaded --> AllSuperseded : policy delete --global + + v3_Loaded --> v3_Loaded : policy set --global
(same hash, dedup no-op) + v3_Loaded --> AllSuperseded : policy delete --global + + AllSuperseded --> NewVersion_Loaded : policy set --global
(any hash, no dedup) + + state "No Global Policy" as NoGlobalPolicy + state "v1: Loaded" as v1_Loaded + state "v2: Loaded, v1: Superseded" as v2_Loaded + state "v3: Loaded, v1-v2: Superseded" as v3_Loaded + state "All Revisions Superseded
(no active global policy)" as AllSuperseded + state "vN: Loaded, older: Superseded" as NewVersion_Loaded +``` + +#### Key behaviors + +- **Dedup on set**: When the latest global revision has status `loaded` and its hash matches the submitted policy, no new revision is created. The settings blob is still ensured to have the `policy` key (reconciliation against potential data loss from a pod restart while the `sandbox_policies` table retained the revision). See `crates/openshell-server/src/grpc.rs` -- `update_settings()`, lines around the `current.policy_hash == hash && current.status == "loaded"` check. + +- **No dedup against superseded**: If the latest revision has status `superseded` (e.g., after a `policy delete --global`), the same hash creates a new revision. This supports the toggle pattern: delete the global policy, then re-set the same policy. The dedup check explicitly requires `status == "loaded"`. + +- **Immediate load**: Global policy revisions are marked `loaded` immediately upon creation (no sandbox confirmation needed). The gateway calls `update_policy_status(GLOBAL_POLICY_SANDBOX_ID, next_version, "loaded", ...)` right after `put_policy_revision()`. Sandboxes pick up changes via the 10-second poll loop. + +- **Supersede on set**: When a new global revision is created, `supersede_older_policies(GLOBAL_POLICY_SANDBOX_ID, next_version)` marks all older revisions with `pending` or `loaded` status as `superseded`. + +- **Delete supersedes all**: `policy delete --global` removes the `policy` key from the `gateway_settings` blob and calls `supersede_older_policies()` with `latest.version + 1` to mark ALL `__global__` revisions as `superseded`. This restores sandbox-level policy control. + +- **Dual-write**: `policy set --global` writes to BOTH the `sandbox_policies` revision table (for audit/listing via `policy list --global`) AND the `gateway_settings` blob (for fast delivery via `GetSandboxSettings`). The revision table provides history; the settings blob is the authoritative source that sandboxes poll. + +- **Concurrency**: All global mutations acquire `ServerState.settings_mutex` (a `tokio::sync::Mutex<()>`) for the duration of the read-modify-write cycle. This prevents races between concurrent global policy set/delete operations and global setting mutations. + +#### Global policy effects on sandboxes + +When a global policy is active (the `policy` key exists in `gateway_settings`): + +| Operation | Effect | +|-----------|--------| +| `GetSandboxSettings` | Returns the global policy payload instead of the sandbox's own policy. `policy_source = GLOBAL`. `global_policy_version` set to the active revision's version number. | +| `policy set ` | Rejected with `FailedPrecondition: "policy is managed globally; delete global policy before sandbox policy update"` | +| `rule approve ` | Rejected with `FailedPrecondition: "cannot approve rules while a global policy is active; delete the global policy to manage per-sandbox rules"` | +| `rule approve-all` | Rejected with same `FailedPrecondition` as `rule approve` | +| Revoking an approved chunk (via `rule reject` on an `approved` chunk) | Rejected with same `FailedPrecondition` -- revoking would modify the sandbox policy which is not in use | +| Rejecting a `pending` chunk | Allowed -- rejection does not modify the sandbox policy | +| `settings set/delete` at sandbox scope | Allowed -- settings and policy are independent channels | +| Draft chunk collection | Continues normally -- sandbox proxy still generates proposals. Chunks are visible but cannot be approved. | + +The blocking logic is implemented by `require_no_global_policy()` in `crates/openshell-server/src/grpc.rs`, which checks for the `policy` key in global settings and returns `FailedPrecondition` if present. + +### `config_revision` and `global_policy_version` -The `config_revision` field is a 64-bit fingerprint that changes whenever the effective configuration changes. The sandbox poll loop compares this value to detect changes without re-parsing the full response. +**`config_revision`** (`u64`): Content hash of the merged effective config. Computed by `compute_config_revision()` from three inputs: `policy_source` (as 4 LE bytes), the deterministic policy hash (if policy present), and sorted settings entries (key bytes + scope as 4 LE bytes + type tag byte + value bytes). The SHA-256 digest is truncated to 8 bytes and interpreted as `u64` (little-endian). Changes when the global policy, sandbox policy, settings, or policy source changes. Used by the sandbox poll loop for change detection. -Computation: -1. Hash `policy_source` as 4 little-endian bytes -2. Hash the deterministic policy hash (if policy present) -3. Sort settings entries by key -4. For each entry: hash key bytes, scope as 4 LE bytes, then a type tag byte + value bytes -5. Truncate the SHA-256 digest to 8 bytes and interpret as `u64` (little-endian) +**`global_policy_version`** (`u32`): The version number of the active global policy revision. Populated in `GetSandboxSettingsResponse` when `policy_source == GLOBAL` by looking up the latest revision for `GLOBAL_POLICY_SANDBOX_ID`. Zero when no global policy is active or when `policy_source == SANDBOX`. Displayed in the TUI dashboard and sandbox metadata pane, and logged by the sandbox on reload. ### Per-Key Mutual Exclusion @@ -178,7 +264,7 @@ This prevents conflicting values at different scopes. An operator must delete a ### Sandbox-Scoped Policy Update Interaction -When a global policy is set, sandbox-scoped policy updates via `UpdateSandboxPolicy` are rejected with `FailedPrecondition`: +When a global policy is set, sandbox-scoped policy updates via `UpdateSettings` are rejected with `FailedPrecondition`: ``` policy is managed globally; delete global policy before sandbox policy update @@ -253,10 +339,11 @@ pub struct SettingsPollResult { pub config_revision: u64, pub policy_source: PolicySource, pub settings: HashMap, + pub global_policy_version: u32, } ``` -The `poll_settings()` method maps the full `GetSandboxSettingsResponse` into this struct. The `settings` field carries the effective settings map for diff logging. +The `poll_settings()` method maps the full `GetSandboxSettingsResponse` into this struct. The `settings` field carries the effective settings map for diff logging. The `global_policy_version` field is propagated from the response and used for logging when the sandbox reloads a global policy. ## CLI Commands @@ -308,22 +395,48 @@ openshell settings delete --global --key log_level --yes ### `policy set --global --policy FILE [--yes]` -Set a gateway-global policy that overrides all sandbox policies. +Set a gateway-global policy that overrides all sandbox policies. Creates a versioned revision in the `sandbox_policies` table and writes the policy to the `gateway_settings` blob for delivery. ```bash openshell policy set --global --policy policy.yaml --yes ``` -The `--wait` flag is not supported for global policy updates. +The `--wait` flag is rejected for global policy updates with: `"--wait is not supported for global policies; global policies are effective immediately"`. See `crates/openshell-cli/src/main.rs`. ### `policy delete --global [--yes]` -Delete the gateway-global policy, restoring sandbox-level policy control. +Delete the gateway-global policy, restoring sandbox-level policy control. Removes the `policy` key from the `gateway_settings` blob and supersedes all `__global__` revisions. ```bash openshell policy delete --global --yes ``` +Note: `policy delete` without `--global` is not supported (sandbox policies are managed through versioned updates, not deletion). The CLI returns: `"sandbox policy delete is not supported; use --global to remove global policy lock"`. + +### `policy list --global [--limit N]` + +List global policy revision history. Uses `ListSandboxPolicies` with `global: true`, which routes to the `__global__` sentinel in the `sandbox_policies` table. + +```bash +openshell policy list --global +openshell policy list --global --limit 10 +``` + +### `policy get --global [--rev N] [--full]` + +Show a specific global policy revision (or the latest). Uses `GetSandboxPolicyStatus` with `global: true`. + +```bash +# Latest global revision +openshell policy get --global + +# Specific version +openshell policy get --global --rev 3 + +# Full policy payload as YAML +openshell policy get --global --full +``` + ### HITL Confirmation All `--global` mutations require human-in-the-loop confirmation via an interactive prompt. The `--yes` flag bypasses the prompt for scripted/CI usage. In non-interactive mode (no TTY), `--yes` is required -- otherwise the command fails with an error. @@ -338,6 +451,12 @@ The confirmation message varies: **File:** `crates/openshell-tui/src/` +### Dashboard: Global Policy Indicator + +**File:** `crates/openshell-tui/src/ui/dashboard.rs` + +The gateway row in the dashboard shows a yellow `Global Policy Active (vN)` indicator when a global policy is active. The TUI detects this by calling `ListSandboxPolicies` with `global: true, limit: 1` on each polling tick and checking if the latest revision has `PolicyStatus::Loaded`. The version number and active flag are tracked in `App.global_policy_active` and `App.global_policy_version`. + ### Dashboard: Global Settings Tab The dashboard's middle pane has a tabbed interface: **Providers** | **Global Settings**. Press `Tab` to switch. @@ -352,6 +471,18 @@ The Global Settings tab displays registered keys with their current values, fetc - **Confirmation modals**: Both edit and delete operations show a confirmation dialog before applying - **Scope indicators**: Each key shows its current value or `` +### Sandbox Metadata Pane: Global Policy Indicator + +**File:** `crates/openshell-tui/src/ui/sandbox_detail.rs` + +When the sandbox's policy source is `GLOBAL` (detected via `policy_source` in the `GetSandboxSettings` response), the metadata pane shows `Policy: managed globally (vN)` in yellow. The version comes from `global_policy_version` in the response. Tracked in `App.sandbox_policy_is_global` and `App.sandbox_global_policy_version`. + +### Network Rules Pane: Global Policy Warning + +**File:** `crates/openshell-tui/src/ui/sandbox_draft.rs` + +When `sandbox_policy_is_global` is true, the Network Rules pane displays a yellow bottom title: `" Cannot approve rules while global policy is active "`. Draft chunks are still rendered but their status styles are greyed out (`t.muted`). Keyboard actions for approve (`a`), reject/revoke (`x`), and approve-all are intercepted client-side with status messages like `"Cannot approve rules while a global policy is active"` and `"Cannot modify rules while a global policy is active"`. See `crates/openshell-tui/src/app.rs` -- draft key handling. + ### Sandbox Screen: Settings Tab The sandbox detail view's bottom pane has a tabbed interface: **Policy** | **Settings**. Press `l` to switch tabs. @@ -364,7 +495,7 @@ The Settings tab shows effective settings for the selected sandbox, fetched as p ### Data Refresh -Settings are refreshed on each 2-second polling tick alongside the sandbox list and health status. The global settings revision is tracked to detect changes. Sandbox settings are refreshed when viewing a specific sandbox. +Settings are refreshed on each 2-second polling tick alongside the sandbox list and health status. The global settings revision is tracked to detect changes. Sandbox settings are refreshed when viewing a specific sandbox. Global policy active status is detected on each tick via `ListSandboxPolicies` with `global: true`. ## Data Flow: Setting a Global Key @@ -373,16 +504,17 @@ End-to-end trace for `openshell settings set --global --key log_level --value de 1. **CLI** (`crates/openshell-cli/src/run.rs` -- `gateway_setting_set()`): - `parse_cli_setting_value("log_level", "debug")` -- looks up `SettingValueKind::String` in the registry, wraps as `SettingValue { string_value: "debug" }` - `confirm_global_setting_takeover()` -- skipped because `--yes` - - Sends `UpdateSandboxPolicyRequest { setting_key: "log_level", setting_value: Some(...), global: true }` + - Sends `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }` -2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_sandbox_policy()`): +2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`): + - Acquires `settings_mutex` for the duration of the operation - Detects `global=true`, `has_setting=true` - `validate_registered_setting_key("log_level")` -- passes (key is in registry) - `load_global_settings()` -- reads `gateway_settings` record from store - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::String("debug")` - `upsert_setting_value()` -- inserts into `BTreeMap`, returns `true` (changed) - Increments `revision`, calls `save_global_settings()` - - Returns `UpdateSandboxPolicyResponse { settings_revision: N }` + - Returns `UpdateSettingsResponse { settings_revision: N }` 3. **Sandbox** (next poll tick in `run_policy_poll_loop()`): - `poll_settings(sandbox_id)` returns new `config_revision` @@ -390,6 +522,37 @@ End-to-end trace for `openshell settings set --global --key log_level --value de - `policy_hash` unchanged -- no OPA reload - Updates tracked `current_config_revision` and `current_settings` +## Data Flow: Setting a Global Policy + +End-to-end trace for `openshell policy set --global --policy policy.yaml --yes`: + +1. **CLI** (`crates/openshell-cli/src/main.rs`, `crates/openshell-cli/src/run.rs` -- `sandbox_policy_set_global()`): + - Rejects `--wait` flag with `"--wait is not supported for global policies; global policies are effective immediately"` + - Loads and parses the YAML policy file into a `SandboxPolicy` protobuf + - Sends `UpdateSettingsRequest { policy: Some(sandbox_policy), global: true }` + +2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`): + - Acquires `settings_mutex` + - Detects `global=true`, `has_policy=true` + - `ensure_sandbox_process_identity()` -- ensures process identity defaults to "sandbox" + - `validate_policy_safety()` -- rejects unsafe policies (e.g., root process) + - `deterministic_policy_hash()` -- computes SHA-256 hash of the policy + - **Dedup check**: Fetches `get_latest_policy(GLOBAL_POLICY_SANDBOX_ID)` + - If latest exists with `status == "loaded"` and same hash → no-op (ensures settings blob has `policy` key, returns existing version) + - If no latest, or latest is `superseded`, or hash differs → create new revision + - `put_policy_revision(id, "__global__", next_version, payload, hash)` -- persists revision + - `update_policy_status("__global__", next_version, "loaded")` -- marks loaded immediately + - `supersede_older_policies("__global__", next_version)` -- marks all older revisions as superseded + - Stores hex-encoded payload in `gateway_settings` blob under `policy` key via `upsert_setting_value()` + - Returns `UpdateSettingsResponse { version: N, policy_hash: "..." }` + +3. **Sandbox** (next poll tick, ~10 seconds): + - `poll_settings(sandbox_id)` returns response with `policy_source: GLOBAL`, `global_policy_version: N` + - `config_revision` changed → enters change processing + - `policy_hash` changed → calls `opa_engine.reload_from_proto(global_policy)` + - Logs `"Policy reloaded successfully (global)"` with `global_version=N` + - Does NOT call `ReportPolicyStatus` (global policies skip per-sandbox status reporting) + ## Cross-References - [Gateway Architecture](gateway.md) -- Persistence layer, gRPC service, object types diff --git a/architecture/gateway.md b/architecture/gateway.md index ca569e74..39f97c8c 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -141,6 +141,9 @@ pub struct ServerState { pub sandbox_index: SandboxIndex, pub sandbox_watch_bus: SandboxWatchBus, pub tracing_log_bus: TracingLogBus, + pub ssh_connections_by_token: Mutex>, + pub ssh_connections_by_sandbox: Mutex>, + pub settings_mutex: tokio::sync::Mutex<()>, } ``` @@ -149,6 +152,7 @@ pub struct ServerState { - **`sandbox_index`** -- in-memory bidirectional index mapping sandbox names and agent pod names to sandbox IDs. Used by the event tailer to correlate Kubernetes events. - **`sandbox_watch_bus`** -- `broadcast`-based notification bus keyed by sandbox ID. Producers call `notify(&id)` when the persisted sandbox record changes; consumers in `WatchSandbox` streams receive `()` signals and re-read the record. - **`tracing_log_bus`** -- captures `tracing` events that include a `sandbox_id` field and republishes them as `SandboxLogLine` messages. Maintains a per-sandbox tail buffer (default 200 entries). Also contains a nested `PlatformEventBus` for Kubernetes events. +- **`settings_mutex`** -- serializes settings mutations (global and sandbox) to prevent read-modify-write races. Held for the duration of any setting set/delete or global policy set/delete operation. See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle). ## Protocol Multiplexing @@ -231,7 +235,7 @@ These RPCs are called by sandbox pods at startup and during runtime polling. | RPC | Description | |-----|-------------| -| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata (version, hash, source), merged effective settings, and a `config_revision` fingerprint for change detection. Two-tier resolution: registered keys start unset, sandbox values overlay, global values override. The reserved `policy` key in global settings can override the sandbox's own policy. See [Gateway Settings Channel](gateway-settings.md). | +| `GetSandboxSettings` | Returns effective sandbox config looked up by sandbox ID: policy payload, policy metadata (version, hash, source, `global_policy_version`), merged effective settings, and a `config_revision` fingerprint for change detection. Two-tier resolution: registered keys start unset, sandbox values overlay, global values override. The reserved `policy` key in global settings can override the sandbox's own policy. When a global policy is active, `policy_source` is `GLOBAL` and `global_policy_version` carries the active revision number. See [Gateway Settings Channel](gateway-settings.md). | | `GetGatewaySettings` | Returns gateway-global settings only (excluding the reserved `policy` key). Returns registered keys with empty values when unconfigured, and a monotonic `settings_revision`. | | `GetSandboxProviderEnvironment` | Resolves provider credentials into environment variables for a sandbox. Iterates the sandbox's `spec.providers` list, fetches each `Provider`, and collects credential key-value pairs. First provider wins on duplicate keys. Skips credential keys that do not match `^[A-Za-z_][A-Za-z0-9_]*$`. | @@ -243,9 +247,9 @@ These RPCs support the sandbox-initiated policy recommendation pipeline. The san |-----|-------------| | `SubmitPolicyAnalysis` | Receives pre-formed `PolicyChunk` proposals from a sandbox. Validates each chunk, persists via upsert on `(sandbox_id, host, port, binary)` dedup key, notifies watch bus. | | `GetDraftPolicy` | Returns all draft chunks for a sandbox with current draft version. | -| `ApproveDraftChunk` | Approves a pending or rejected chunk. Merges the proposed rule into the active policy (appends binary to existing rule or inserts new rule). | -| `RejectDraftChunk` | Rejects a pending chunk or revokes an approved chunk. If revoking, removes the binary from the active policy rule. | -| `ApproveAllDraftChunks` | Bulk approves all pending chunks for a sandbox. | +| `ApproveDraftChunk` | Approves a pending or rejected chunk. Merges the proposed rule into the active policy (appends binary to existing rule or inserts new rule). **Blocked when a global policy is active** -- returns `FailedPrecondition`. | +| `RejectDraftChunk` | Rejects a pending chunk or revokes an approved chunk. If revoking, removes the binary from the active policy rule. Rejection of `pending` chunks is always allowed. **Revoking approved chunks is blocked when a global policy is active** -- returns `FailedPrecondition`. | +| `ApproveAllDraftChunks` | Bulk approves all pending chunks for a sandbox. **Blocked when a global policy is active** -- returns `FailedPrecondition`. | | `EditDraftChunk` | Updates the proposed rule on a pending chunk. | | `GetDraftHistory` | Returns all chunks (including rejected) for audit trail. | @@ -464,9 +468,11 @@ Objects are identified by `(object_type, id)` with a unique constraint on `(obje | `"provider"` | `Provider` | `ObjectType`, `ObjectId`, `ObjectName` | | | `"ssh_session"` | `SshSession` | `ObjectType`, `ObjectId`, `ObjectName` | | | `"inference_route"` | `InferenceRoute` | `ObjectType`, `ObjectId`, `ObjectName` | | -| `"gateway_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Singleton, id=`"global"` | +| `"gateway_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Singleton, id=`"global"`. Contains the reserved `policy` key for global policy delivery. | | `"sandbox_settings"` | JSON `StoredSettings` | Generic `put`/`get` | Per-sandbox, id=`"settings:{sandbox_uuid}"` | +The `sandbox_policies` table stores versioned policy revisions for both sandbox-scoped and global policies. Global revisions use the sentinel `sandbox_id = "__global__"`. See [Gateway Settings Channel](gateway-settings.md#storage-model) for schema details. + ### Generic Protobuf Codec The `Store` provides typed helpers that leverage trait bounds: diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 2a953013..a9d80ac8 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -359,8 +359,9 @@ The `run_policy_poll_loop()` function in `crates/openshell-sandbox/src/lib.rs` i 4. **Config comparison**: If `result.config_revision == current_config_revision`, skip. 5. **Per-setting diff logging**: Call `log_setting_changes()` to diff old and new settings maps. Each individual change is logged with old and new values. 6. **Conditional OPA reload**: Only call `opa_engine.reload_from_proto(policy)` when `policy_hash` changes. Settings-only changes (e.g., `log_level` updated) update the tracked state without touching the OPA engine. -7. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still reload, but they do not write per-sandbox policy status history. -8. **Update tracked state**: After processing, update `current_config_revision`, `current_policy_hash`, and `current_settings` regardless of whether OPA was reloaded. +7. **Status reporting**: On success/failure, report status only for sandbox-scoped policy revisions (`policy_source = SANDBOX`, `version > 0`). Global policy overrides still trigger OPA reload, but they do not write per-sandbox policy status history. +8. **Global policy logging**: When `global_policy_version > 0`, the sandbox logs `"Policy reloaded successfully (global)"` with the `global_version` field. This distinguishes global reloads from sandbox-scoped reloads in the log stream. +9. **Update tracked state**: After processing, update `current_config_revision`, `current_policy_hash`, and `current_settings` regardless of whether OPA was reloaded. ### `CachedOpenShellClient` @@ -380,12 +381,13 @@ pub struct SettingsPollResult { pub config_revision: u64, pub policy_source: PolicySource, pub settings: HashMap, + pub global_policy_version: u32, } ``` Methods: - **`connect(endpoint)`**: Establish an mTLS channel and return a new client. -- **`poll_settings(sandbox_id)`**: Call `GetSandboxSettings` RPC and return a `SettingsPollResult` containing policy payload (optional), policy metadata, effective config revision, policy source, and the effective settings map (for diff logging). +- **`poll_settings(sandbox_id)`**: Call `GetSandboxSettings` RPC and return a `SettingsPollResult` containing policy payload (optional), policy metadata, effective config revision, policy source, global policy version, and the effective settings map (for diff logging). - **`report_policy_status(sandbox_id, version, loaded, error_msg)`**: Call `ReportPolicyStatus` RPC with the appropriate `PolicyStatus` enum value (`Loaded` or `Failed`). - **`raw_client()`**: Return a clone of the underlying `OpenShellClient` for direct RPC calls (used by the log push task). @@ -394,7 +396,7 @@ Methods: The gateway assigns a monotonically increasing version number to each sandbox policy revision. `GetSandboxSettingsResponse` carries the full effective configuration: policy payload, effective settings map (with per-key scope indicators), a `config_revision` fingerprint that changes when any effective input changes (policy, settings, or source), and a `policy_source` field indicating whether the policy came from the sandbox's own history or from a global override. Proto messages involved: -- `GetSandboxSettingsResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings` (map of `EffectiveSetting`), `config_revision`, `policy_source` +- `GetSandboxSettingsResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash`, `settings` (map of `EffectiveSetting`), `config_revision`, `policy_source`, `global_policy_version` - `EffectiveSetting` (`proto/sandbox.proto`): `SettingValue value`, `SettingScope scope` - `SettingScope` enum: `UNSPECIFIED`, `SANDBOX`, `GLOBAL` - `PolicySource` enum: `UNSPECIFIED`, `SANDBOX`, `GLOBAL` @@ -402,6 +404,8 @@ Proto messages involved: - `PolicyStatus` enum: `PENDING`, `LOADED`, `FAILED`, `SUPERSEDED` - `SandboxPolicyRevision` (`proto/openshell.proto`): Full revision metadata including `created_at_ms`, `loaded_at_ms` +The `global_policy_version` field is zero when no global policy is active or when `policy_source` is `SANDBOX`. When `policy_source` is `GLOBAL`, it carries the version number of the active global revision. The sandbox logs this value on reload (`"Policy reloaded successfully (global)" global_version=N`) and the TUI displays it in the dashboard and sandbox metadata pane. + See [Gateway Settings Channel](gateway-settings.md) for full details on the settings resolution model, storage, and CLI/TUI commands. ### Failure modes diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 6244edc4..b63179c4 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -242,14 +242,24 @@ openshell policy list --limit 20 #### Global Policy -The `--global` flag on `policy set` and `policy delete` manages a gateway-wide policy override. When a global policy is set, all sandboxes receive it through `GetSandboxSettings` (with `policy_source: GLOBAL`) instead of their own per-sandbox policy. This is implemented via the reserved `policy` key in the gateway settings store. See [Gateway Settings Channel](gateway-settings.md#global-policy-as-a-setting) for implementation details. +The `--global` flag on `policy set`, `policy delete`, `policy list`, and `policy get` manages a gateway-wide policy override. When a global policy is set, all sandboxes receive it through `GetSandboxSettings` (with `policy_source: GLOBAL`) instead of their own per-sandbox policy. Global policies are versioned through the `sandbox_policies` table using the sentinel `sandbox_id = "__global__"` and delivered to sandboxes via the reserved `policy` key in the `gateway_settings` blob. | Command | Behavior | |---------|----------| -| `policy set --global --policy FILE` | Stores the policy as a hex-encoded protobuf in the global settings under the reserved `policy` key. All sandboxes pick it up on their next poll. | -| `policy delete --global` | Removes the `policy` key from global settings. Sandboxes revert to their per-sandbox policy on the next poll. | +| `policy set --global --policy FILE` | Creates a versioned revision (marked `loaded` immediately) and stores the policy in the global settings blob. Sandboxes pick it up on their next poll (~10s). Deduplicates against the latest `loaded` revision by hash. | +| `policy delete --global` | Removes the `policy` key from global settings and supersedes all `__global__` revisions. Sandboxes revert to their per-sandbox policy on the next poll. | +| `policy list --global [--limit N]` | Lists global policy revision history (version, hash, status, timestamps). | +| `policy get --global [--rev N] [--full]` | Shows a specific global revision's metadata, or the latest. `--full` includes the full policy as YAML. | -Both commands require interactive confirmation (or `--yes` to bypass). The `--wait` flag is not supported for global policy updates because there is no single sandbox to track status for. +Both `set` and `delete` require interactive confirmation (or `--yes` to bypass). The `--wait` flag is rejected for global policy updates: `"--wait is not supported for global policies; global policies are effective immediately"`. + +When a global policy is active, sandbox-scoped policy mutations are blocked: +- `policy set ` returns `FailedPrecondition: "policy is managed globally"` +- `rule approve`, `rule approve-all` return `FailedPrecondition: "cannot approve rules while a global policy is active"` +- Revoking a previously approved draft chunk is blocked (it would modify the sandbox policy) +- Rejecting pending chunks is allowed (does not modify the sandbox policy) + +See [Gateway Settings Channel](gateway-settings.md#global-policy-lifecycle) for the full state machine, storage model, and implementation details. #### `policy get` flags diff --git a/architecture/tui.md b/architecture/tui.md index 8dbcb504..1a83e96d 100644 --- a/architecture/tui.md +++ b/architecture/tui.md @@ -63,6 +63,8 @@ The dashboard is divided into a top info pane and a middle pane with two tabs: - `○` **Unhealthy** (red) — the cluster is not operating correctly. - `…` — still connecting or status unknown. +**Global policy indicator**: When a global policy is active, the gateway row shows `Global Policy Active (vN)` in yellow (the `status_warn` style). The TUI detects this by polling `ListSandboxPolicies` with `global: true, limit: 1` on each tick and checking if the latest revision has `PolicyStatus::Loaded`. See `crates/openshell-tui/src/ui/dashboard.rs`. + #### Global Settings Tab The Global Settings tab shows all registered setting keys with their current values. Keys without a configured value display as ``. @@ -105,6 +107,8 @@ When viewing a specific sandbox (by pressing `Enter` on a selected row), the bot - **Policy** — the sandbox's current active policy, auto-refreshed on version change. - **Settings** — effective runtime settings for the sandbox (fetched via `GetSandboxSettings`). +**Global policy indicator on sandbox detail**: When the sandbox's policy is managed globally (`policy_source == GLOBAL` in the `GetSandboxSettings` response), the metadata pane shows `Policy: managed globally (vN)` in yellow. Draft chunks in the **Network Rules** pane are greyed out and a yellow warning reads `"Cannot approve rules while global policy is active"`. Approve (`a`), reject/revoke (`x`), and approve-all actions are blocked client-side with status messages. See `crates/openshell-tui/src/ui/sandbox_detail.rs` and `crates/openshell-tui/src/ui/sandbox_draft.rs`. + #### Sandbox Settings Tab The Settings tab shows all registered setting keys with their effective values and scope indicators: From 682cbbf5d38fdbb99cce02ff233460c13236aaf4 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:32:59 -0700 Subject: [PATCH 27/28] refactor(proto): rename GetSandboxSettings/GetGatewaySettings to GetSandboxConfig/GetGatewayConfig --- crates/openshell-cli/src/run.rs | 14 +++---- .../tests/ensure_providers_integration.rs | 26 ++++++------ .../openshell-cli/tests/mtls_integration.rs | 16 ++++---- .../tests/provider_commands_integration.rs | 26 ++++++------ .../sandbox_create_lifecycle_integration.rs | 30 +++++++------- .../sandbox_name_fallback_integration.rs | 28 ++++++------- crates/openshell-sandbox/src/grpc_client.rs | 12 +++--- crates/openshell-server/src/grpc.rs | 40 +++++++++---------- .../tests/auth_endpoint_integration.rs | 16 ++++---- .../tests/edge_tunnel_auth.rs | 30 +++++++------- .../tests/multiplex_integration.rs | 30 +++++++------- .../tests/multiplex_tls_integration.rs | 30 +++++++------- .../tests/ws_tunnel_integration.rs | 30 +++++++------- crates/openshell-tui/src/app.rs | 4 +- crates/openshell-tui/src/lib.rs | 14 +++---- proto/openshell.proto | 8 ++-- proto/sandbox.proto | 10 ++--- 17 files changed, 182 insertions(+), 182 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b0751a6f..6d6b6b89 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -25,9 +25,9 @@ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewaySettingsRequest, GetProviderRequest, GetSandboxLogsRequest, - GetSandboxPolicyStatusRequest, GetSandboxRequest, GetSandboxSettingsRequest, HealthRequest, - ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, + GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, + GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest, + ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue, UpdateProviderRequest, UpdateSettingsRequest, WatchSandboxRequest, setting_value, @@ -3949,7 +3949,7 @@ pub async fn sandbox_settings_get( .ok_or_else(|| miette::miette!("sandbox not found"))?; let response = client - .get_sandbox_settings(GetSandboxSettingsRequest { + .get_sandbox_config(GetSandboxConfigRequest { sandbox_id: sandbox.id.clone(), }) .await @@ -4004,7 +4004,7 @@ pub async fn sandbox_settings_get( pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client - .get_gateway_settings(GetGatewaySettingsRequest {}) + .get_gateway_config(GetGatewayConfigRequest {}) .await .into_diagnostic()? .into_inner(); @@ -4036,7 +4036,7 @@ pub async fn gateway_settings_get(server: &str, json: bool, tls: &TlsOptions) -> fn settings_to_json_sandbox( name: &str, - response: &openshell_core::proto::GetSandboxSettingsResponse, + response: &openshell_core::proto::GetSandboxConfigResponse, ) -> serde_json::Value { let policy_source = if response.policy_source == openshell_core::proto::PolicySource::Global as i32 { @@ -4075,7 +4075,7 @@ fn settings_to_json_sandbox( } fn settings_to_json_global( - response: &openshell_core::proto::GetGatewaySettingsResponse, + response: &openshell_core::proto::GetGatewayConfigResponse, ) -> serde_json::Value { let mut settings = serde_json::Map::new(); let mut keys: Vec<_> = response.settings.keys().cloned().collect(); diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index aaffa4e8..8f86766e 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -11,11 +11,11 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, }; @@ -154,18 +154,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 94e0640e..4f2eed8b 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -108,21 +108,21 @@ impl OpenShell for TestOpenShell { )) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Ok(Response::new( - openshell_core::proto::GetSandboxSettingsResponse::default(), + openshell_core::proto::GetSandboxConfigResponse::default(), )) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Ok(Response::new( - openshell_core::proto::GetGatewaySettingsResponse::default(), + openshell_core::proto::GetGatewayConfigResponse::default(), )) } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index de4eeaa6..b8abb1e6 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -7,11 +7,11 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, }; @@ -108,18 +108,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 320a2cae..66cd5b9e 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -8,13 +8,13 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, PlatformEvent, - ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, SandboxPhase, - SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, PlatformEvent, ProviderResponse, + RevokeSshSessionRequest, RevokeSshSessionResponse, Sandbox, SandboxPhase, SandboxResponse, + SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, sandbox_stream_event, }; use rcgen::{ @@ -157,18 +157,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 39a46339..612516a9 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -8,12 +8,12 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, Sandbox, - SandboxResponse, SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, Sandbox, SandboxResponse, + SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -132,18 +132,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index 5cb21d8e..649bfc08 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -9,10 +9,10 @@ use std::time::Duration; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ - DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, - GetSandboxProviderEnvironmentRequest, GetSandboxSettingsRequest, PolicySource, PolicyStatus, - ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - UpdateSettingsRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, + DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxConfigRequest, + GetSandboxProviderEnvironmentRequest, PolicySource, PolicyStatus, ReportPolicyStatusRequest, + SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateSettingsRequest, + inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; @@ -101,7 +101,7 @@ async fn fetch_policy_with_client( sandbox_id: &str, ) -> Result> { let response = client - .get_sandbox_settings(GetSandboxSettingsRequest { + .get_sandbox_config(GetSandboxConfigRequest { sandbox_id: sandbox_id.to_string(), }) .await @@ -244,7 +244,7 @@ impl CachedOpenShellClient { let response = self .client .clone() - .get_sandbox_settings(GetSandboxSettingsRequest { + .get_sandbox_config(GetSandboxConfigRequest { sandbox_id: sandbox_id.to_string(), }) .await diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index 00bedd27..5bbfbf35 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -18,14 +18,14 @@ use openshell_core::proto::{ DraftHistoryEntry, EditDraftChunkRequest, EditDraftChunkResponse, EffectiveSetting, ExecSandboxEvent, ExecSandboxExit, ExecSandboxRequest, ExecSandboxStderr, ExecSandboxStdout, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, - GetGatewaySettingsRequest, GetGatewaySettingsResponse, GetProviderRequest, - GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, - GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, - ListSandboxesRequest, ListSandboxesResponse, PolicyChunk, PolicySource, PolicyStatus, Provider, - ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, + GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxesRequest, + ListSandboxesResponse, PolicyChunk, PolicySource, PolicyStatus, Provider, ProviderResponse, + PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxLogLine, SandboxPolicyRevision, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, @@ -751,10 +751,10 @@ impl OpenShell for OpenShellService { Ok(Response::new(DeleteProviderResponse { deleted })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - request: Request, - ) -> Result, Status> { + request: Request, + ) -> Result, Status> { let sandbox_id = request.into_inner().sandbox_id; let sandbox = self @@ -780,7 +780,7 @@ impl OpenShell for OpenShellService { debug!( sandbox_id = %sandbox_id, version = record.version, - "GetSandboxSettings served from policy history" + "GetSandboxConfig served from policy history" ); ( Some(decoded), @@ -800,7 +800,7 @@ impl OpenShell for OpenShellService { None => { debug!( sandbox_id = %sandbox_id, - "GetSandboxSettings: no policy configured, returning empty response" + "GetSandboxConfig: no policy configured, returning empty response" ); (None, 0, String::new()) } @@ -837,7 +837,7 @@ impl OpenShell for OpenShellService { info!( sandbox_id = %sandbox_id, - "GetSandboxSettings served from spec (backfilled version 1)" + "GetSandboxConfig served from spec (backfilled version 1)" ); (Some(spec_policy), 1, hash) @@ -874,7 +874,7 @@ impl OpenShell for OpenShellService { let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); - Ok(Response::new(GetSandboxSettingsResponse { + Ok(Response::new(GetSandboxConfigResponse { policy, version, policy_hash, @@ -885,13 +885,13 @@ impl OpenShell for OpenShellService { })) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: Request, - ) -> Result, Status> { + _request: Request, + ) -> Result, Status> { let global_settings = load_global_settings(self.state.store.as_ref()).await?; let settings = materialize_global_settings(&global_settings)?; - Ok(Response::new(GetGatewaySettingsResponse { + Ok(Response::new(GetGatewayConfigResponse { settings, settings_revision: global_settings.revision, })) @@ -1208,7 +1208,7 @@ impl OpenShell for OpenShellService { .await; // Also store in the settings blob (delivery mechanism for - // GetSandboxSettings). + // GetSandboxConfig). let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; let stored_value = StoredSettingValue::Bytes(hex::encode(&payload)); let changed = upsert_setting_value( diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index 4c3a741f..95d8d7f8 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -435,23 +435,23 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { )) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _: tonic::Request, - ) -> Result, tonic::Status> + _: tonic::Request, + ) -> Result, tonic::Status> { Ok(tonic::Response::new( - openshell_core::proto::GetSandboxSettingsResponse::default(), + openshell_core::proto::GetSandboxConfigResponse::default(), )) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _: tonic::Request, - ) -> Result, tonic::Status> + _: tonic::Request, + ) -> Result, tonic::Status> { Ok(tonic::Response::new( - openshell_core::proto::GetGatewaySettingsResponse::default(), + openshell_core::proto::GetGatewayConfigResponse::default(), )) } diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index fff68997..1d48e099 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -37,13 +37,13 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, - ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, + UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -112,18 +112,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index c0918226..1601c8c0 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -11,13 +11,13 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, - ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, + UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -70,18 +70,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 7518a688..3a9b88f2 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -13,13 +13,13 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, - ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, + UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -83,18 +83,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index 1e36992e..045058a9 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -40,13 +40,13 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxRequest, GetGatewaySettingsRequest, GetGatewaySettingsResponse, - GetProviderRequest, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, GetSandboxSettingsRequest, - GetSandboxSettingsResponse, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, - RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, - ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + ExecSandboxEvent, ExecSandboxRequest, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, + HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ListSandboxesRequest, ListSandboxesResponse, ProviderResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, + UpdateProviderRequest, WatchSandboxRequest, open_shell_client::OpenShellClient, open_shell_server::{OpenShell, OpenShellServer}, }; @@ -106,18 +106,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_settings( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxSettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) } - async fn get_gateway_settings( + async fn get_gateway_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetGatewaySettingsResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index e7496a4f..6a556fd1 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -652,7 +652,7 @@ impl App { // Filtered log helpers // ------------------------------------------------------------------ - /// Apply fetched global settings from the `GetGatewaySettings` response. + /// Apply fetched global settings from the `GetGatewayConfig` response. pub fn apply_global_settings( &mut self, settings: HashMap, @@ -677,7 +677,7 @@ impl App { } } - /// Apply fetched sandbox settings from the `GetSandboxSettings` response. + /// Apply fetched sandbox settings from the `GetSandboxConfig` response. pub fn apply_sandbox_settings( &mut self, settings: HashMap, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 646f523c..3c442f46 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -698,7 +698,7 @@ async fn handle_sandbox_delete(app: &mut App) { /// Fetch sandbox details (policy + providers) when entering the sandbox screen. /// -/// Uses `GetSandbox` for metadata/providers, then `GetSandboxSettings` for the +/// Uses `GetSandbox` for metadata/providers, then `GetSandboxConfig` for the /// current live policy (which may have been updated since creation). async fn fetch_sandbox_detail(app: &mut App) { let sandbox_name = match app.selected_sandbox_name() { @@ -739,11 +739,11 @@ async fn fetch_sandbox_detail(app: &mut App) { // Step 2: Fetch the current live policy (includes updates since creation). if let Some(id) = sandbox_id { - let policy_req = openshell_core::proto::GetSandboxSettingsRequest { sandbox_id: id }; + let policy_req = openshell_core::proto::GetSandboxConfigRequest { sandbox_id: id }; match tokio::time::timeout( Duration::from_secs(5), - app.client.get_sandbox_settings(policy_req), + app.client.get_sandbox_config(policy_req), ) .await { @@ -1866,9 +1866,9 @@ async fn refresh_providers(app: &mut App) { } async fn refresh_global_settings(app: &mut App) { - let req = openshell_core::proto::GetGatewaySettingsRequest {}; + let req = openshell_core::proto::GetGatewayConfigRequest {}; let result = - tokio::time::timeout(Duration::from_secs(5), app.client.get_gateway_settings(req)).await; + 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()); @@ -2206,11 +2206,11 @@ async fn refresh_sandbox_policy(app: &mut App) { None => return, }; - let policy_req = openshell_core::proto::GetSandboxSettingsRequest { sandbox_id }; + let policy_req = openshell_core::proto::GetSandboxConfigRequest { sandbox_id }; match tokio::time::timeout( Duration::from_secs(5), - app.client.get_sandbox_settings(policy_req), + app.client.get_sandbox_config(policy_req), ) .await { diff --git a/proto/openshell.proto b/proto/openshell.proto index 5be1b506..edaf2408 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -53,12 +53,12 @@ service OpenShell { rpc DeleteProvider(DeleteProviderRequest) returns (DeleteProviderResponse); // Get sandbox settings by id (called by sandbox entrypoint and poll loop). - rpc GetSandboxSettings(openshell.sandbox.v1.GetSandboxSettingsRequest) - returns (openshell.sandbox.v1.GetSandboxSettingsResponse); + rpc GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) + returns (openshell.sandbox.v1.GetSandboxConfigResponse); // Get gateway-global settings. - rpc GetGatewaySettings(openshell.sandbox.v1.GetGatewaySettingsRequest) - returns (openshell.sandbox.v1.GetGatewaySettingsResponse); + rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) + returns (openshell.sandbox.v1.GetGatewayConfigResponse); // Update settings or policy at sandbox or global scope. rpc UpdateSettings(UpdateSettingsRequest) diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 899db198..a96ca33f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -110,16 +110,16 @@ message NetworkBinary { } // Request to get sandbox settings by sandbox ID. -message GetSandboxSettingsRequest { +message GetSandboxConfigRequest { // The sandbox ID. string sandbox_id = 1; } // Request to get gateway-global settings. -message GetGatewaySettingsRequest {} +message GetGatewayConfigRequest {} // Response containing gateway-global settings. -message GetGatewaySettingsResponse { +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; @@ -150,7 +150,7 @@ message EffectiveSetting { SettingScope scope = 2; } -// Source used for the policy payload in GetSandboxSettingsResponse. +// Source used for the policy payload in GetSandboxConfigResponse. enum PolicySource { POLICY_SOURCE_UNSPECIFIED = 0; POLICY_SOURCE_SANDBOX = 1; @@ -158,7 +158,7 @@ enum PolicySource { } // Response containing effective sandbox settings and policy. -message GetSandboxSettingsResponse { +message GetSandboxConfigResponse { // The sandbox policy configuration. SandboxPolicy policy = 1; // Current policy version (monotonically increasing per sandbox). From b0c4c85ec24e37376dbe256cd075cc21c9451320 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:52:34 -0700 Subject: [PATCH 28/28] fix(e2e): update remaining UpdateSandboxPolicy reference in Python test --- e2e/python/test_sandbox_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index db67fa6d..7f459c62 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -1306,8 +1306,8 @@ def test_live_policy_update_from_empty_network_policies( ) initial_version = initial_status.revision.version - update_resp = stub.UpdateSandboxPolicy( - openshell_pb2.UpdateSandboxPolicyRequest( + update_resp = stub.UpdateSettings( + openshell_pb2.UpdateSettingsRequest( name=sandbox_name, policy=updated_policy, )