Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 13 additions & 15 deletions architecture/gateway-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ The `REGISTERED_SETTINGS` static array defines the allowed setting keys and thei

```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 },
RegisteredSetting { key: "use_providers_v2", kind: SettingValueKind::Bool },
RegisteredSetting { key: "ocsf_json_enabled", kind: SettingValueKind::Bool },
];
```

Expand Down Expand Up @@ -373,15 +372,14 @@ Set a single setting key at sandbox or global scope.

```bash
# Sandbox-scoped
openshell settings set my-sandbox --key log_level --value debug
openshell settings set my-sandbox --key ocsf_json_enabled --value true

# 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
openshell settings set --global --key use_providers_v2 --value true
openshell settings set --global --key ocsf_json_enabled --value true

# Skip confirmation
openshell settings set --global --key log_level --value info --yes
openshell settings set --global --key use_providers_v2 --value true --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.
Expand All @@ -392,7 +390,7 @@ Delete a setting key from the specified scope.

```bash
# Global delete (unlocks sandbox control)
openshell settings delete --global --key log_level --yes
openshell settings delete --global --key use_providers_v2 --yes
```

### `policy set --global --policy FILE [--yes]`
Expand Down Expand Up @@ -502,26 +500,26 @@ Settings are refreshed on each 2-second polling tick alongside the sandbox list

## Data Flow: Setting a Global Key

End-to-end trace for `openshell settings set --global --key log_level --value debug --yes`:
End-to-end trace for `openshell settings set --global --key use_providers_v2 --value true --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" }`
- `parse_cli_setting_value("use_providers_v2", "true")` -- looks up `SettingValueKind::Bool` in the registry, wraps as `SettingValue { bool_value: true }`
- `confirm_global_setting_takeover()` -- skipped because `--yes`
- Sends `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }`
- Sends `UpdateSettingsRequest { setting_key: "use_providers_v2", 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)
- `validate_registered_setting_key("use_providers_v2")` -- 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")`
- `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::Bool(true)`
- `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="<unset>" new="debug"`
- `log_setting_changes()` logs: `Setting changed key="use_providers_v2" old="<unset>" new="true"`
- `policy_hash` unchanged -- no OPA reload
- Updates tracked `current_config_revision` and `current_settings`

Expand Down
57 changes: 57 additions & 0 deletions architecture/sandbox-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,67 @@ The gRPC surface is defined in `proto/openshell.proto`:
- `CreateProvider`
- `GetProvider`
- `ListProviders`
- `ListProviderProfiles`
- `GetProviderProfile`
- `UpdateProvider`
- `DeleteProvider`

## Provider Type Profiles

Provider type profiles are declarative metadata for provider types. Built-in profiles
live as one YAML document per provider under
`crates/openshell-providers/profiles/` and are exposed through
`ListProviderProfiles` and `GetProviderProfile`. The profile loader validates the
YAML catalog and materializes the same proto-backed shape that future API imports
will accept. Profiles describe credential names and environment variables, known
network endpoints, expected binaries, category, and whether the provider is
inference-capable. Categories are a proto enum so clients can group and filter
provider types without parsing display strings. Current values are `other`,
`inference`, `agent`, `source_control`, `messaging`, `data`, and `knowledge`.
Agent profiles such as `claude`, `codex`, and `opencode` can still be
inference-capable when their tool talks to an inference API.

Profiles are additive to provider records. A provider record with only `type`,
`credentials`, and `config` can be matched to built-in profile metadata by
`provider.type`. Profile-generated policy is still opt-in: the gateway composes provider
profile rules only when the gateway-global `use_providers_v2` setting is true.

This keeps the compatibility boundary at the gateway. A gateway without
`use_providers_v2=true` keeps the existing credential-only provider behavior, while a
gateway with the flag enabled routes all attached known provider types through the
profile-backed policy path.

### Provider Policy Composition

Sandbox policy fetch uses just-in-time composition:

```text
effective policy = base/static policy + provider profile rules + user rules
```

The composed policy is derived data. The sandbox still receives one normal
`SandboxPolicy`, but provider-generated entries are not persisted as user-authored
policy revisions. Full policy replacement and incremental policy updates continue to
mutate the user-authored policy layer. Provider-generated rules are re-added during
composition for each attached provider whose type has a built-in profile.

Provider-generated network rules use reserved `_provider_*` names derived from the
provider record name. If a user or global policy already has the same key, composition
keeps the policy entry and adds a numeric suffix to the provider entry. Duplicate
host/port endpoints across policy and provider rules are valid; OPA evaluates all
rules, so allow decisions are the union of matching allows and deny rules continue to
win globally.

Gateway-global policy still overrides sandbox-authored policy. When `use_providers_v2`
is true, provider layers compose JIT onto the effective policy source, whether that
source is sandbox-scoped or global. The composed payload is derived data and is not
persisted as a policy revision.

## Components

- `crates/openshell-providers`
- canonical provider type normalization and command detection,
- YAML-backed built-in provider profiles,
- provider registry and per-provider discovery plugins,
- shared discovery engine and context abstraction for testability.
- `crates/openshell-cli`
Expand Down Expand Up @@ -174,6 +228,7 @@ Also supported:

- `openshell provider get <name>`
- `openshell provider list`
- `openshell provider list-types`
- `openshell provider update <name> ...`
- `openshell provider delete <name> [<name>...]`

Expand Down Expand Up @@ -232,6 +287,8 @@ Key behaviors:
- Only `credentials` are injected, not `config`.
- Invalid env var keys (containing `.`, `-`, spaces, etc.) are skipped.
- Credentials are never persisted in the sandbox spec's environment map.
- Provider profiles do not change credential injection in the first iteration.
Injection still uses the existing placeholder environment path.

### Sandbox Supervisor: Fetching Credentials

Expand Down
16 changes: 11 additions & 5 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,10 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
$ openshell settings get my-sandbox
$ openshell settings get --global
$ openshell settings set my-sandbox --key log_level --value debug
$ openshell settings set --global --key log_level --value warn
$ openshell settings set --global --key dummy_bool --value yes
$ openshell settings set --global --key dummy_int --value 42
$ openshell settings delete --global --key log_level
$ openshell settings set --global --key use_providers_v2 --value true
$ openshell settings set my-sandbox --key ocsf_json_enabled --value true
$ openshell settings set --global --key ocsf_json_enabled --value true
$ openshell settings delete --global --key use_providers_v2
";

const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
Expand Down Expand Up @@ -700,6 +699,10 @@ enum ProviderCommands {
names: bool,
},

/// List available provider types.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
ListTypes,

/// Update an existing provider's credentials or config.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Update {
Expand Down Expand Up @@ -2628,6 +2631,9 @@ async fn main() -> Result<()> {
} => {
run::provider_list(endpoint, limit, offset, names, &tls).await?;
}
ProviderCommands::ListTypes => {
run::provider_list_types(endpoint, &tls).await?;
}
ProviderCommands::Update {
name,
from_existing,
Expand Down
76 changes: 70 additions & 6 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,18 @@ use openshell_bootstrap::{
get_gateway_metadata, list_gateways, load_active_gateway, remove_gateway_metadata,
resolve_ssh_hostname, save_active_gateway, save_last_sandbox, store_gateway_metadata,
};
use openshell_core::proto::ProviderProfileCategory;
use openshell_core::proto::{
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest,
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest,
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider,
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest,
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProviderProfilesRequest,
ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource,
PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxPhase,
SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope,
SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest,
exec_sandbox_event, setting_value,
};
use openshell_core::settings::{self, SettingValueKind};
use openshell_core::{ObjectId, ObjectName};
Expand Down Expand Up @@ -3579,7 +3581,7 @@ pub async fn provider_create(
created_at_ms: 0,
labels: HashMap::new(),
}),
r#type: provider_type,
r#type: provider_type.clone(),
credentials: credential_map,
config: config_map,
}),
Expand Down Expand Up @@ -3706,6 +3708,68 @@ pub async fn provider_list(
Ok(())
}

pub async fn provider_list_types(server: &str, tls: &TlsOptions) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.list_provider_profiles(ListProviderProfilesRequest {
limit: 100,
offset: 0,
})
.await
.into_diagnostic()?;
let mut profiles = response.into_inner().profiles;
profiles.sort_by(|left, right| {
left.category
.cmp(&right.category)
.then_with(|| left.id.cmp(&right.id))
});

if profiles.is_empty() {
println!("No provider types found.");
return Ok(());
}

println!("{}", "Available Provider Types:".cyan().bold());
let mut current_category = i32::MIN;
for profile in profiles {
if profile.category != current_category {
current_category = profile.category;
println!();
println!(" {}", display_provider_category(current_category).bold());
}
print_provider_type_row(&profile);
}

Ok(())
}

fn display_provider_category(category: i32) -> &'static str {
match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) {
ProviderProfileCategory::Inference => "INFERENCE",
ProviderProfileCategory::Agent => "AGENT",
ProviderProfileCategory::SourceControl => "SOURCE CONTROL",
ProviderProfileCategory::Messaging => "MESSAGING",
ProviderProfileCategory::Data => "DATA",
ProviderProfileCategory::Knowledge => "KNOWLEDGE",
ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER",
}
}

fn print_provider_type_row(profile: &ProviderProfile) {
let inference = if profile.inference_capable {
" inference"
} else {
""
};
println!(
" {:<12} {:<42} endpoints: {:<2}{}",
profile.id,
profile.display_name,
profile.endpoints.len(),
inference
);
}

pub async fn provider_update(
server: &str,
name: &str,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(ListProvidersResponse { providers }))
}

async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn update_provider(
&self,
request: tonic::Request<UpdateProviderRequest>,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,20 @@ impl OpenShell for TestOpenShell {
))
}

async fn list_provider_profiles(
&self,
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn get_provider_profile(
&self,
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
Err(Status::unimplemented("not implemented in test"))
}

async fn update_provider(
&self,
_request: tonic::Request<UpdateProviderRequest>,
Expand Down
Loading
Loading