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/architecture/README.md b/architecture/README.md index d65b9b23..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 @@ -234,7 +236,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. @@ -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-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-settings.md b/architecture/gateway-settings.md new file mode 100644 index 00000000..ef9538f5 --- /dev/null +++ b/architecture/gateway-settings.md @@ -0,0 +1,561 @@ +# 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)"] + 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 -- "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 +``` + +## 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, global_policy_version }` | Sandbox poll loop, CLI `settings get` | +| `GetGatewaySettings` | `GetGatewaySettingsRequest {}` | `GetGatewaySettingsResponse { settings, settings_revision }` | CLI `settings get --global`, TUI dashboard | + +### `UpdateSettingsRequest` + +The `UpdateSettings` RPC multiplexes policy and setting mutations through a single request message: + +| 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 + +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 (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. + +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) +} +``` + +#### 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: + +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 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`. + +### 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` + +**`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. + +**`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 + +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 `UpdateSettings` 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, + 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 `global_policy_version` field is propagated from the response and used for logging when the sandbox reloads a global policy. + +## 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. 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 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. 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. + +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 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. + +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 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. + +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. Global policy active status is detected on each tick via `ListSandboxPolicies` with `global: true`. + +## 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 `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }` + +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 `UpdateSettingsResponse { 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` + +## 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 +- [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 ca541c7b..39f97c8c 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 @@ -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 @@ -225,13 +229,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 | |-----|-------------| -| `GetSandboxPolicy` | Returns the `SandboxPolicy` from a sandbox's spec, looked up by sandbox ID. | +| `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_]*$`. | #### Policy Recommendation (Network Rules) @@ -242,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. | @@ -457,12 +462,16 @@ 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"`. 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 @@ -559,6 +568,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-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 e5c831a8..a9d80ac8 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: GetSandboxPolicy(sandbox_id) - GW-->>PL: policy + version + hash - PL->>PL: Store initial version + PL->>GW: GetSandboxSettings(sandbox_id) + 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: GetSandboxPolicy(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) + PL->>GW: GetSandboxSettings(sandbox_id) + 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,14 @@ 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. -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. +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. **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 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` @@ -364,29 +374,40 @@ pub struct CachedOpenShellClient { client: OpenShellClient, } -pub struct PolicyPollResult { - pub policy: ProtoSandboxPolicy, +pub struct SettingsPollResult { + pub policy: Option, pub version: u32, pub policy_hash: String, + 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_policy(sandbox_id)`**: Call `GetSandboxPolicy` RPC and return a `PolicyPollResult` containing the policy, version, and hash. +- **`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). ### 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. `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: -- `GetSandboxPolicyResponse` (`proto/sandbox.proto`): `policy`, `version`, `policy_hash` +- `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` - `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` +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 | Condition | Behavior | diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 4be33589..b63179c4 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. @@ -206,34 +206,61 @@ 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`, `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` | 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 `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 | Flag | Default | Description | @@ -1382,6 +1409,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 f0915c18..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):
GetSandboxPolicy,
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..1a83e96d 100644 --- a/architecture/tui.md +++ b/architecture/tui.md @@ -48,15 +48,35 @@ 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 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 ``. + +| 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 +102,23 @@ 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`). + +**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: + +- **(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 +149,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 +182,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. 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/main.rs b/crates/openshell-cli/src/main.rs index 84a323b5..3799b392 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,21 @@ 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 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 + $ openshell settings set --global --key dummy_int --value 42 + $ 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 +410,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 +1344,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 +1352,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, @@ -1341,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, @@ -1355,18 +1383,101 @@ 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. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Delete { + /// Delete the global policy setting. + #[arg(long)] + global: bool, + + /// Skip the confirmation prompt for global policy delete. + #[arg(long)] + yes: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum SettingsCommands { + /// 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, + + /// Output as JSON. + #[arg(long)] + json: bool, + }, + + /// 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 (string input; bool keys accept true/false/yes/no/1/0). + #[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 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, + + /// Delete at gateway-global scope. + #[arg(long)] + global: bool, + + /// Skip the confirmation prompt for global setting delete. + #[arg(long)] + yes: bool, }, } @@ -1730,20 +1841,119 @@ 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 { + 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, + 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)?; - 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 { + return Err(miette::miette!( + "sandbox policy delete is not supported; use --global to remove global policy lock" + )); + } + run::gateway_setting_delete(&ctx.endpoint, "policy", yes, &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, 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, json, &tls).await?; + } else { + let name = resolve_sandbox_name(name, &ctx.name)?; + run::sandbox_settings_get(&ctx.endpoint, &name, json, &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 { + 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?; + } } } } @@ -2229,6 +2439,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 +3020,99 @@ 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 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"]) + .expect("policy delete --global should parse"); + + match cli.command { + Some(Commands::Policy { + 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 2f9dd2f7..6d6b6b89 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -24,13 +24,15 @@ use openshell_bootstrap::{ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetProviderRequest, - GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, - ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, + GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, + GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, + GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest, + ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, - SetClusterInferenceRequest, UpdateProviderRequest, UpdateSandboxPolicyRequest, - WatchSandboxRequest, + SetClusterInferenceRequest, SettingScope, SettingValue, UpdateProviderRequest, + UpdateSettingsRequest, WatchSandboxRequest, setting_value, }; +use openshell_core::settings::{self, SettingValueKind}; use openshell_providers::{ ProviderRegistry, detect_provider_from_command, normalize_provider_type, }; @@ -3783,6 +3785,456 @@ 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 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(); + }; + 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_settings(UpdateSettingsRequest { + 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, + json: bool, + 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_config(GetSandboxConfigRequest { + sandbox_id: sandbox.id.clone(), + }) + .await + .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" + } 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: No settings available."); + 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", + _ => "unset", + }; + println!( + " {} = {} ({})", + key, + format_setting_value(setting.value.as_ref()), + scope + ); + } + } + + Ok(()) +} + +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_config(GetGatewayConfigRequest {}) + .await + .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); + + if response.settings.is_empty() { + println!("Settings: No settings available."); + 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(()) +} + +fn settings_to_json_sandbox( + name: &str, + response: &openshell_core::proto::GetSandboxConfigResponse, +) -> 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::GetGatewayConfigResponse, +) -> 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, + value: &str, + 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?; + let response = client + .update_settings(UpdateSettingsRequest { + name: String::new(), + policy: None, + setting_key: key.to_string(), + setting_value: Some(setting_value), + 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 setting_value = parse_cli_setting_value(key, value)?; + + let mut client = grpc_client(server, tls).await?; + let response = client + .update_settings(UpdateSettingsRequest { + name: name.to_string(), + policy: None, + setting_key: key.to_string(), + setting_value: Some(setting_value), + 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, + yes: bool, + tls: &TlsOptions, +) -> Result<()> { + confirm_global_setting_delete(key, yes)?; + + let mut client = grpc_client(server, tls).await?; + let response = client + .update_settings(UpdateSettingsRequest { + 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_setting_delete( + server: &str, + name: &str, + key: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .update_settings(UpdateSettingsRequest { + 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, @@ -3801,6 +4253,7 @@ pub async fn sandbox_policy_set( .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { name: name.to_string(), version: 0, + global: false, }) .await .ok() @@ -3808,9 +4261,13 @@ 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(), + setting_value: None, + delete_setting: false, + global: false, }) .await .into_diagnostic()?; @@ -3856,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()?; @@ -3910,6 +4368,7 @@ pub async fn sandbox_policy_get( .get_sandbox_policy_status(GetSandboxPolicyStatusRequest { name: name.to_string(), version, + global: false, }) .await .into_diagnostic()?; @@ -3948,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, @@ -3961,6 +4468,7 @@ pub async fn sandbox_policy_list( name: name.to_string(), limit, offset: 0, + global: false, }) .await .into_diagnostic()?; @@ -3971,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] @@ -3996,8 +4532,6 @@ pub async fn sandbox_policy_list( error_short, ); } - - Ok(()) } // --------------------------------------------------------------------------- @@ -4406,8 +4940,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; @@ -4527,6 +5062,49 @@ 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"); + 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 + )) + ); + } + + #[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"); + assert_eq!( + int_value.value, + Some(openshell_core::proto::setting_value::Value::IntValue(42)) + ); + } + + #[cfg(feature = "dev-settings")] + #[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..8f86766e 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -11,12 +11,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, - Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + 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, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -153,11 +154,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -311,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 8b238da9..4f2eed8b 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -108,12 +108,21 @@ impl OpenShell for TestOpenShell { )) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { + _request: tonic::Request, + ) -> Result, Status> { Ok(Response::new( - openshell_core::proto::GetSandboxPolicyResponse::default(), + openshell_core::proto::GetSandboxConfigResponse::default(), + )) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::GetGatewayConfigResponse::default(), )) } @@ -212,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 af7e80a3..b8abb1e6 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -7,12 +7,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, - Provider, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, UpdateProviderRequest, WatchSandboxRequest, + 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, }; use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -107,11 +108,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -265,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 9fcfeced..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,14 @@ 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, 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::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, @@ -156,11 +157,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -291,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 3fce5d8d..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, GetProviderRequest, GetSandboxPolicyRequest, - GetSandboxPolicyResponse, GetSandboxProviderEnvironmentRequest, - GetSandboxProviderEnvironmentResponse, GetSandboxRequest, 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,11 +132,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -224,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-core/Cargo.toml b/crates/openshell-core/Cargo.toml index eeedd11a..8bccef54 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 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 = [] + [build-dependencies] tonic-build = { workspace = true } protobuf-src = { workspace = true } 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..b94c08fc --- /dev/null +++ b/crates/openshell-core/src/settings.rs @@ -0,0 +1,245 @@ +// 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. +/// +/// # 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] = &[ + // 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", + kind: SettingValueKind::Int, + }, + #[cfg(feature = "dev-settings")] + 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::{ + REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, + registered_keys_csv, setting_for_key, + }; + + #[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] + 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"] { + 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_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-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index a1a0f75b..649bfc08 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -9,9 +9,9 @@ use std::time::Duration; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::proto::{ - DenialSummary, GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxPolicyRequest, - GetSandboxProviderEnvironmentRequest, PolicyStatus, ReportPolicyStatusRequest, - SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateSandboxPolicyRequest, + 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}; @@ -101,7 +101,7 @@ async fn fetch_policy_with_client( sandbox_id: &str, ) -> Result> { let response = client - .get_sandbox_policy(GetSandboxPolicyRequest { + .get_sandbox_config(GetSandboxConfigRequest { sandbox_id: sandbox_id.to_string(), }) .await @@ -126,9 +126,13 @@ 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(), + setting_value: None, + delete_setting: false, + global: false, }) .await .into_diagnostic() @@ -209,11 +213,17 @@ pub struct CachedOpenShellClient { client: OpenShellClient, } -/// Policy poll result returned by [`CachedOpenShellClient::poll_policy`]. -pub struct PolicyPollResult { - pub policy: ProtoSandboxPolicy, +/// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. +pub struct SettingsPollResult { + pub policy: Option, pub version: u32, pub policy_hash: String, + pub config_revision: u64, + 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 { @@ -229,26 +239,28 @@ 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_config(GetSandboxConfigRequest { sandbox_id: sandbox_id.to_string(), }) .await .into_diagnostic()?; let inner = response.into_inner(); - let policy = inner - .policy - .ok_or_else(|| miette::miette!("Server returned empty policy"))?; - Ok(PolicyPollResult { - policy, + Ok(SettingsPollResult { + 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), + 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 754c3be0..493e4d23 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -1309,18 +1309,29 @@ 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; - - // Initialize current_version from the first poll. - match client.poll_policy(sandbox_id).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_version = result.version; - debug!(version = current_version, "Policy poll: initial version"); + current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash.clone(); + current_settings = result.settings; + debug!( + config_revision = current_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"); } } @@ -1328,55 +1339,125 @@ 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"); + debug!(error = %e, "Settings poll: server unreachable, will retry"); continue; } }; - if result.version <= current_version { + if result.config_revision == current_config_revision { continue; } + let policy_changed = result.policy_hash != current_policy_hash; + + // Log which settings changed. + log_setting_changes(¤t_settings, &result.settings); + info!( - old_version = current_version, - new_version = result.version, - policy_hash = %result.policy_hash, - "Policy poll: new version detected, reloading" + old_config_revision = current_config_revision, + new_config_revision = result.config_revision, + policy_changed, + "Settings poll: config change detected" ); - match opa_engine.reload_from_proto(&result.policy) { - Ok(()) => { - current_version = result.version; - info!( - version = current_version, - policy_hash = %result.policy_hash, - "Policy reloaded successfully" + // 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" ); - if let Err(e) = client - .report_policy_status(sandbox_id, result.version, true, "") - .await - { - warn!(error = %e, "Failed to report policy load success"); + current_config_revision = result.config_revision; + current_policy_hash = result.policy_hash; + current_settings = result.settings; + continue; + }; + + match opa_engine.reload_from_proto(policy) { + Ok(()) => { + 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, "") + .await + { + warn!(error = %e, "Failed to report policy load success"); + } + } + } + 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"); + } + } } } - Err(e) => { - warn!( - version = result.version, - 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"); + } + + 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(), + } } #[cfg(test)] 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 740cdb80..5bbfbf35 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -6,36 +6,41 @@ #![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, + GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + 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, + SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingScope, SettingValue, SshSession, + SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateProviderRequest, UpdateSettingsRequest, UpdateSettingsResponse, WatchSandboxRequest, open_shell_server::OpenShell, }; 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}; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; @@ -103,6 +108,38 @@ 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 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"; +/// 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 { + 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), @@ -604,6 +641,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) => { @@ -700,10 +751,10 @@ impl OpenShell for OpenShellService { Ok(Response::new(DeleteProviderResponse { deleted })) } - async fn get_sandbox_policy( + 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 @@ -722,73 +773,127 @@ 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" + "GetSandboxConfig 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, + "GetSandboxConfig: 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, + "GetSandboxConfig 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(); + 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?; - // 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 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); + 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; + } + // 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); + } } - 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(GetSandboxConfigResponse { + policy, + version, + policy_hash, + settings, + config_revision, + policy_source: policy_source.into(), + global_policy_version, + })) + } - Ok(Response::new(GetSandboxPolicyResponse { - policy: Some(policy), - version: 1, - policy_hash: hash, + async fn get_gateway_config( + &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(GetGatewayConfigResponse { + settings, + settings_revision: global_settings.revision, })) } @@ -981,17 +1086,206 @@ 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(); + 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 { + // 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( + "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)?; + + // 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 { + // 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 + // 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: global_settings.revision, + 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 + // 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( + &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(next_version).unwrap_or(0), + policy_hash: hash, + 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", + )); + } + if key != POLICY_SETTING_KEY { + validate_registered_setting_key(key)?; + } + + let mut global_settings = load_global_settings(self.state.store.as_ref()).await?; + let changed = if req.delete_setting { + 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 + .as_ref() + .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; + let stored = proto_setting_to_stored(key, setting)?; + upsert_setting_value(&mut global_settings.settings, key, stored) + }; + + 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: 0, + policy_hash: String::new(), + settings_revision: global_settings.revision, + deleted: req.delete_setting && changed, + })); + } + 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 +1295,99 @@ 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 { + // 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", + )); + } + + let global_settings = load_global_settings(self.state.store.as_ref()).await?; + 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.wrapping_add(1); + save_sandbox_settings( + self.state.store.as_ref(), + &sandbox_id, + &sandbox.name, + &sandbox_settings, + ) + .await?; + } + + return Ok(Response::new(UpdateSettingsResponse { + 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" + ))); + } + + let setting = req + .setting_value + .as_ref() + .ok_or_else(|| Status::invalid_argument("setting_value is required"))?; + let stored = proto_setting_to_stored(key, 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.wrapping_add(1); + save_sandbox_settings( + self.state.store.as_ref(), + &sandbox_id, + &sandbox.name, + &sandbox_settings, + ) + .await?; + } + + return Ok(Response::new(UpdateSettingsResponse { + 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 @@ -1040,7 +1424,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" ); } @@ -1059,9 +1443,11 @@ 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, + deleted: false, })); } @@ -1088,12 +1474,14 @@ 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, + deleted: false, })) } @@ -1102,38 +1490,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)), @@ -1146,23 +1539,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}")))?; @@ -1554,6 +1952,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 @@ -1674,7 +2074,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?; } @@ -1702,6 +2105,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 @@ -2069,11 +2474,25 @@ 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, @@ -2297,41 +2716,344 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hex::encode(hasher.finalize()) } -/// 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 { - let effective = if log_source.is_empty() { - "gateway" - } else { - log_source - }; - filters.iter().any(|f| f == effective) -} - -/// Check if a log line's level meets the minimum level threshold. -/// Empty `min_level` means no filtering (all levels pass). -fn level_matches(log_level: &str, min_level: &str) -> bool { - if min_level.is_empty() { - return true; +/// 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, + 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 to_num = |s: &str| match s.to_uppercase().as_str() { - "ERROR" => 0, - "WARN" => 1, - "INFO" => 2, - "DEBUG" => 3, - "TRACE" => 4, - _ => 5, // unknown levels always pass - }; - to_num(log_level) <= to_num(min_level) -} -// --------------------------------------------------------------------------- -// Policy helper functions -// --------------------------------------------------------------------------- + let digest = hasher.finalize(); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + u64::from_le_bytes(bytes) +} -// --------------------------------------------------------------------------- -// Sandbox spec validation -// --------------------------------------------------------------------------- +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 (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) +} + +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 +} + +/// 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_settings_id(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_settings_id(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 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 || settings::setting_for_key(key).is_none() { + 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 || settings::setting_for_key(key).is_none() { + continue; + } + merged.insert( + key.clone(), + EffectiveSetting { + value: Some(stored_setting_to_proto(value)?), + scope: SettingScope::Global.into(), + }, + ); + } + + 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; + } + // 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)?); + } + + 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 { + let effective = if log_source.is_empty() { + "gateway" + } else { + log_source + }; + filters.iter().any(|f| f == effective) +} + +/// Check if a log line's level meets the minimum level threshold. +/// Empty `min_level` means no filtering (all levels pass). +fn level_matches(log_level: &str, min_level: &str) -> bool { + if min_level.is_empty() { + return true; + } + let to_num = |s: &str| match s.to_uppercase().as_str() { + "ERROR" => 0, + "WARN" => 1, + "INFO" => 2, + "DEBUG" => 3, + "TRACE" => 4, + _ => 5, // unknown levels always pass + }; + to_num(log_level) <= to_num(min_level) +} + +// --------------------------------------------------------------------------- +// Policy helper functions +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Sandbox spec validation +// --------------------------------------------------------------------------- /// Validate field sizes on a `CreateSandboxRequest` before persisting. /// @@ -4186,7 +4908,7 @@ mod tests { }; store.put_message(&sandbox).await.unwrap(); - // Simulate what update_sandbox_policy does when spec.policy is None: + // Simulate what update_settings does when spec.policy is None: // backfill spec.policy with the new policy. let new_policy = ProtoSandboxPolicy { version: 1, @@ -4648,4 +5370,843 @@ mod tests { assert_eq!(err.code(), Code::InvalidArgument); assert!(err.message().contains("value")); } + + #[cfg(feature = "dev-settings")] + #[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()), + ), + ("dummy_int".to_string(), super::StoredSettingValue::Int(7)), + ] + .into_iter() + .collect(), + }; + let sandbox = super::StoredSettings { + revision: 1, + settings: [ + ( + "log_level".to_string(), + super::StoredSettingValue::String("debug".to_string()), + ), + ( + "dummy_bool".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 dummy_bool = merged.get("dummy_bool").expect("dummy_bool present"); + assert_eq!( + 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] + 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); + } + + #[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")); + } + + #[cfg(feature = "dev-settings")] + #[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")); + } + + #[cfg(feature = "dev-settings")] + #[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)); + } + + // ---- 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(); + 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.wrapping_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(()), } } } diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index ad65bbc4..95d8d7f8 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -435,13 +435,23 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { )) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _: tonic::Request, - ) -> Result, tonic::Status> + _: tonic::Request, + ) -> Result, tonic::Status> { Ok(tonic::Response::new( - openshell_core::proto::GetSandboxPolicyResponse::default(), + openshell_core::proto::GetSandboxConfigResponse::default(), + )) + } + + async fn get_gateway_config( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Ok(tonic::Response::new( + openshell_core::proto::GetGatewayConfigResponse::default(), )) } @@ -539,11 +549,10 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { )) } - async fn update_sandbox_policy( + async fn update_settings( &self, - _: tonic::Request, - ) -> 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 e8c4974b..1d48e099 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -37,12 +37,13 @@ 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, 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}, }; @@ -111,11 +112,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -195,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 e07f2ca0..1601c8c0 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -11,12 +11,13 @@ 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, 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}, }; @@ -69,11 +70,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -163,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 5faaf103..3a9b88f2 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -13,12 +13,13 @@ 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, 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}, }; @@ -82,11 +83,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -176,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 ce99c719..045058a9 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -40,12 +40,13 @@ 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, 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}, }; @@ -105,11 +106,18 @@ impl OpenShell for TestOpenShell { Ok(Response::new(DeleteSandboxResponse { deleted: true })) } - async fn get_sandbox_policy( + async fn get_sandbox_config( &self, - _request: tonic::Request, - ) -> Result, Status> { - Ok(Response::new(GetSandboxPolicyResponse::default())) + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetSandboxConfigResponse::default())) + } + + async fn get_gateway_config( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new(GetGatewayConfigResponse::default())) } async fn get_sandbox_provider_environment( @@ -189,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/app.rs b/crates/openshell-tui/src/app.rs index 9d7f86f3..6a556fd1 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,128 @@ 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 { + display_setting_value(&self.value) + } +} + +/// Editing state for a global or sandbox setting. +#[derive(Debug, Clone)] +pub struct SettingEditState { + /// Index into the settings list being edited. + pub index: usize, + /// Text buffer for string/int types. + pub input: String, + /// Validation error to display. + 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 { + display_setting_value(&self.value) + } + + pub fn is_globally_managed(&self) -> bool { + self.scope == SettingScope::Global + } +} + +/// 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 // --------------------------------------------------------------------------- @@ -304,6 +428,23 @@ pub struct App { pub provider_selected: usize, pub provider_count: usize, + // 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, + 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, @@ -333,6 +474,18 @@ 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_policy_is_global: bool, + pub sandbox_global_policy_version: u32, + 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, @@ -413,6 +566,17 @@ impl App { gateways: Vec::new(), 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, + 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(), @@ -441,6 +605,16 @@ impl App { pending_sandbox_delete: false, 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, + 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(), @@ -478,6 +652,66 @@ impl App { // Filtered log helpers // ------------------------------------------------------------------ + /// Apply fetched global settings from the `GetGatewayConfig` 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; + } + } + + /// Apply fetched sandbox settings from the `GetSandboxConfig` 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 @@ -515,6 +749,32 @@ 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.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; + } if self.create_form.is_some() { self.handle_create_form_key(key); return; @@ -541,7 +801,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 +897,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; + } _ => {} } } @@ -685,10 +1089,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; } @@ -727,6 +1138,155 @@ impl App { self.policy_scroll = 0; } KeyCode::Char('q') => self.running = false, + KeyCode::Char('h') | KeyCode::Left | 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::Left | 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; + } _ => {} } } @@ -758,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; + } } } } @@ -841,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(); @@ -853,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(); @@ -865,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/event.rs b/crates/openshell-tui/src/event.rs index dc575605..e73862eb 100644 --- a/crates/openshell-tui/src/event.rs +++ b/crates/openshell-tui/src/event.rs @@ -32,6 +32,25 @@ 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), + /// 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 e0b36f94..3c442f46 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -112,6 +112,24 @@ 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()); + } + // --- 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; @@ -222,6 +240,61 @@ 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::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); @@ -253,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; } } @@ -632,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 `GetSandboxPolicy` 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() { @@ -673,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::GetSandboxPolicyRequest { 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_policy(policy_req), + app.client.get_sandbox_config(policy_req), ) .await { @@ -690,10 +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 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"); @@ -1756,6 +1826,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 +1865,258 @@ async fn refresh_providers(app: &mut App) { } } +async fn refresh_global_settings(app: &mut App) { + let req = openshell_core::proto::GetGatewayConfigRequest {}; + let result = + 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()); + } + 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); + } + } + + // 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) { + 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, UpdateSettingsRequest, setting_value}; + + let value = match kind { + openshell_core::settings::SettingValueKind::Bool => { + 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) + } + }; + + let req = UpdateSettingsRequest { + 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_settings(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::UpdateSettingsRequest; + + let req = UpdateSettingsRequest { + 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_settings(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); + }); +} + +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, UpdateSettingsRequest, setting_value}; + + let value = match kind { + openshell_core::settings::SettingValueKind::Bool => { + 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) + } + }; + + let req = UpdateSettingsRequest { + 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_settings(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::UpdateSettingsRequest; + + let req = UpdateSettingsRequest { + 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_settings(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; @@ -1883,11 +2206,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::GetSandboxConfigRequest { sandbox_id }; match tokio::time::timeout( Duration::from_secs(5), - app.client.get_sandbox_policy(policy_req), + app.client.get_sandbox_config(policy_req), ) .await { @@ -1900,6 +2223,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/dashboard.rs b/crates/openshell-tui/src/ui/dashboard.rs index 801be17f..43ae6a93 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); } @@ -31,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); @@ -69,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(); @@ -86,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), ]; 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..cac59b0a --- /dev/null +++ b/crates/openshell-tui/src/ui/global_settings.rs @@ -0,0 +1,254 @@ +// 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(" No settings available.", 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); +} + +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 9c05466d..b920d9cb 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -4,11 +4,13 @@ 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; pub(crate) mod sandbox_logs; mod sandbox_policy; +pub(crate) mod sandbox_settings; pub(crate) mod sandboxes; mod splash; @@ -80,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). @@ -161,11 +166,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), @@ -354,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), @@ -400,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/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() 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/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 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..c26f4a66 --- /dev/null +++ b/crates/openshell-tui/src/ui/sandbox_settings.rs @@ -0,0 +1,260 @@ +// 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(" No settings available.", 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); +} + +use super::centered_popup as centered_rect; 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/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/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..7f459c62 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, ) @@ -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, ) diff --git a/e2e/rust/tests/settings_management.rs b/e2e/rust/tests/settings_management.rs new file mode 100644 index 00000000..69cb7cf1 --- /dev/null +++ b/e2e/rust/tests/settings_management.rs @@ -0,0 +1,327 @@ +// 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"); + + // 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.success, + "sandbox setting delete should succeed (exit {:?}):\n{}", + sandbox_delete.exit_code, + sandbox_delete.clean_output + ); + assert!( + sandbox_delete + .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", + ]) + .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 + ); + + // 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, + "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; +} diff --git a/proto/openshell.proto b/proto/openshell.proto index ad93848d..edaf2408 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -52,13 +52,17 @@ 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 GetSandboxConfig(openshell.sandbox.v1.GetSandboxConfigRequest) + returns (openshell.sandbox.v1.GetSandboxConfigResponse); - // Update sandbox policy on a live sandbox. - rpc UpdateSandboxPolicy(UpdateSandboxPolicyRequest) - returns (UpdateSandboxPolicyResponse); + // Get gateway-global settings. + rpc GetGatewayConfig(openshell.sandbox.v1.GetGatewayConfigRequest) + returns (openshell.sandbox.v1.GetGatewayConfigResponse); + + // Update settings or policy at sandbox or global scope. + rpc UpdateSettings(UpdateSettingsRequest) + returns (UpdateSettingsResponse); // Get the load status of a specific policy version. rpc GetSandboxPolicyStatus(GetSandboxPolicyStatusRequest) @@ -436,29 +440,50 @@ message GetSandboxProviderEnvironmentResponse { // --------------------------------------------------------------------------- // Update sandbox policy request. -message UpdateSandboxPolicyRequest { - // Sandbox name (canonical lookup key). +message UpdateSettingsRequest { + // 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. -message UpdateSandboxPolicyResponse { +message UpdateSettingsResponse { // Assigned policy version (monotonically increasing per sandbox). uint32 version = 1; // SHA-256 hash of the serialized policy payload. string policy_hash = 2; + // Settings revision for the scope that was modified. + uint64 settings_revision = 3; + // True when a setting delete operation removed an existing key. + bool deleted = 4; } // Get sandbox policy status request. message GetSandboxPolicyStatusRequest { - // Sandbox name (canonical lookup key). + // 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. @@ -471,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 01925fbe..a96ca33f 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -109,18 +109,70 @@ 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 GetSandboxConfigRequest { // The sandbox ID. string sandbox_id = 1; } -// Response containing sandbox policy. -message GetSandboxPolicyResponse { +// Request to get gateway-global settings. +message GetGatewayConfigRequest {} + +// Response containing gateway-global settings. +message GetGatewayConfigResponse { + // Gateway-global settings map excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty SettingValue. + map settings = 1; + // Monotonically increasing revision for gateway-global settings. + uint64 settings_revision = 2; +} + +// Scope that currently controls a setting. +enum SettingScope { + SETTING_SCOPE_UNSPECIFIED = 0; + SETTING_SCOPE_SANDBOX = 1; + SETTING_SCOPE_GLOBAL = 2; +} + +// Type-aware setting value for sandbox/gateway settings. +message SettingValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + bytes bytes_value = 4; + } +} + +// Effective setting value and the scope it was resolved from. +message EffectiveSetting { + SettingValue value = 1; + SettingScope scope = 2; +} + +// Source used for the policy payload in GetSandboxConfigResponse. +enum PolicySource { + POLICY_SOURCE_UNSPECIFIED = 0; + POLICY_SOURCE_SANDBOX = 1; + POLICY_SOURCE_GLOBAL = 2; +} + +// Response containing effective sandbox settings and policy. +message GetSandboxConfigResponse { // The sandbox policy configuration. SandboxPolicy policy = 1; // Current policy version (monotonically increasing per sandbox). uint32 version = 2; // SHA-256 hash of the serialized policy payload. string policy_hash = 3; + // Effective settings resolved for this sandbox, excluding the reserved policy key. + // Registered keys without a configured value are returned with an empty EffectiveSetting.value. + map settings = 4; + // Fingerprint for effective config (policy + settings). Changes when any effective input changes. + uint64 config_revision = 5; + // Source of the policy payload for this response. + PolicySource policy_source = 6; + // When policy_source is GLOBAL, the version of the global policy revision. + // Zero when no global policy is active or when policy_source is SANDBOX. + uint32 global_policy_version = 7; } 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)"