Skip to content

Commit 043bde2

Browse files
authored
feat(providers): add profile-backed policy composition (#1037)
Foundation for providers v2. Add provider profiles and provider profile composition with user policies.
1 parent 04e48d5 commit 043bde2

44 files changed

Lines changed: 1972 additions & 53 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/debug-openshell-cluster/SKILL.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,11 @@ Component images (server, sandbox) can reach kubelet via two paths:
187187
Gateway and cluster image builds consume Rust binaries staged at `deploy/docker/.build/prebuilt-binaries/<arch>/`. In CI these come from the reusable Rust native build workflow; locally `tasks/scripts/docker-build-image.sh` runs `tasks/scripts/stage-prebuilt-binaries.sh` before invoking Docker unless `PREBUILT_AUTO_STAGE=0` is set.
188188

189189
```bash
190-
# Verify image refs currently used by openshell deployment
191-
openshell doctor exec -- kubectl -n openshell get statefulset openshell -o jsonpath="{.spec.template.spec.containers[*].image}"
190+
# Verify image refs currently used by openshell deployment.
191+
# The gateway image and server.supervisorImage should use the same build tag
192+
# in branch/E2E deploys; a stale supervisor image can make sandbox behavior
193+
# lag behind gateway policy/proto changes.
194+
openshell doctor exec -- kubectl -n openshell get statefulset openshell -o jsonpath="{.spec.template.spec.containers[*].image}{\"\n\"}{.spec.template.spec.containers[*].env[?(@.name==\"OPENSHELL_SUPERVISOR_IMAGE\")].value}{\"\n\"}"
192195

193196
# Verify registry mirror/auth endpoint configuration
194197
openshell doctor exec -- cat /etc/rancher/k3s/registries.yaml

‎.github/workflows/branch-e2e.yml‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,19 @@ jobs:
4646
component: cluster
4747
platform: linux/arm64
4848

49+
build-supervisor:
50+
needs: [pr_metadata]
51+
if: needs.pr_metadata.outputs.should_run == 'true'
52+
permissions:
53+
contents: read
54+
packages: write
55+
uses: ./.github/workflows/docker-build.yml
56+
with:
57+
component: supervisor
58+
platform: linux/arm64
59+
4960
e2e:
50-
needs: [pr_metadata, build-gateway, build-cluster]
61+
needs: [pr_metadata, build-gateway, build-cluster, build-supervisor]
5162
if: needs.pr_metadata.outputs.should_run == 'true'
5263
permissions:
5364
contents: read

‎.github/workflows/shadow-branch-e2e.yml‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,21 @@ jobs:
5858
image-tag: shadow-branch-e2e-${{ github.sha }}
5959
secrets: inherit
6060

61+
build-supervisor:
62+
needs: [pr_metadata]
63+
if: needs.pr_metadata.outputs.should_run == 'true'
64+
permissions:
65+
contents: read
66+
packages: write
67+
uses: ./.github/workflows/docker-build.yml
68+
with:
69+
component: supervisor
70+
platform: linux/arm64
71+
image-tag: shadow-branch-e2e-${{ github.sha }}
72+
secrets: inherit
73+
6174
e2e:
62-
needs: [pr_metadata, build-gateway, build-cluster]
75+
needs: [pr_metadata, build-gateway, build-cluster, build-supervisor]
6376
if: needs.pr_metadata.outputs.should_run == 'true'
6477
permissions:
6578
contents: read

‎Cargo.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎architecture/gateway-settings.md‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@ The `REGISTERED_SETTINGS` static array defines the allowed setting keys and thei
3030

3131
```rust
3232
pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
33-
RegisteredSetting { key: "log_level", kind: SettingValueKind::String },
34-
RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int },
35-
RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool },
33+
RegisteredSetting { key: "providers_v2_enabled", kind: SettingValueKind::Bool },
34+
RegisteredSetting { key: "ocsf_json_enabled", kind: SettingValueKind::Bool },
3635
];
3736
```
3837

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

374373
```bash
375374
# Sandbox-scoped
376-
openshell settings set my-sandbox --key log_level --value debug
375+
openshell settings set my-sandbox --key ocsf_json_enabled --value true
377376

378377
# Global (requires confirmation)
379-
openshell settings set --global --key log_level --value warn
380-
openshell settings set --global --key dummy_bool --value yes
381-
openshell settings set --global --key dummy_int --value 42
378+
openshell settings set --global --key providers_v2_enabled --value true
379+
openshell settings set --global --key ocsf_json_enabled --value true
382380

383381
# Skip confirmation
384-
openshell settings set --global --key log_level --value info --yes
382+
openshell settings set --global --key providers_v2_enabled --value true --yes
385383
```
386384

387385
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.
@@ -392,7 +390,7 @@ Delete a setting key from the specified scope.
392390

393391
```bash
394392
# Global delete (unlocks sandbox control)
395-
openshell settings delete --global --key log_level --yes
393+
openshell settings delete --global --key providers_v2_enabled --yes
396394
```
397395

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

503501
## Data Flow: Setting a Global Key
504502

505-
End-to-end trace for `openshell settings set --global --key log_level --value debug --yes`:
503+
End-to-end trace for `openshell settings set --global --key providers_v2_enabled --value true --yes`:
506504

507505
1. **CLI** (`crates/openshell-cli/src/run.rs` -- `gateway_setting_set()`):
508-
- `parse_cli_setting_value("log_level", "debug")` -- looks up `SettingValueKind::String` in the registry, wraps as `SettingValue { string_value: "debug" }`
506+
- `parse_cli_setting_value("providers_v2_enabled", "true")` -- looks up `SettingValueKind::Bool` in the registry, wraps as `SettingValue { bool_value: true }`
509507
- `confirm_global_setting_takeover()` -- skipped because `--yes`
510-
- Sends `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }`
508+
- Sends `UpdateSettingsRequest { setting_key: "providers_v2_enabled", setting_value: Some(...), global: true }`
511509

512510
2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`):
513511
- Acquires `settings_mutex` for the duration of the operation
514512
- Detects `global=true`, `has_setting=true`
515-
- `validate_registered_setting_key("log_level")` -- passes (key is in registry)
513+
- `validate_registered_setting_key("providers_v2_enabled")` -- passes (key is in registry)
516514
- `load_global_settings()` -- reads `gateway_settings` record from store
517-
- `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::String("debug")`
515+
- `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::Bool(true)`
518516
- `upsert_setting_value()` -- inserts into `BTreeMap`, returns `true` (changed)
519517
- Increments `revision`, calls `save_global_settings()`
520518
- Returns `UpdateSettingsResponse { settings_revision: N }`
521519

522520
3. **Sandbox** (next poll tick in `run_policy_poll_loop()`):
523521
- `poll_settings(sandbox_id)` returns new `config_revision`
524-
- `log_setting_changes()` logs: `Setting changed key="log_level" old="<unset>" new="debug"`
522+
- `log_setting_changes()` logs: `Setting changed key="providers_v2_enabled" old="<unset>" new="true"`
525523
- `policy_hash` unchanged -- no OPA reload
526524
- Updates tracked `current_config_revision` and `current_settings`
527525

‎architecture/sandbox-providers.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,67 @@ The gRPC surface is defined in `proto/openshell.proto`:
4141
- `CreateProvider`
4242
- `GetProvider`
4343
- `ListProviders`
44+
- `ListProviderProfiles`
45+
- `GetProviderProfile`
4446
- `UpdateProvider`
4547
- `DeleteProvider`
4648

49+
## Provider Type Profiles
50+
51+
Provider type profiles are declarative metadata for provider types. Built-in profiles
52+
live as one YAML document per provider under the top-level `providers/` directory
53+
and are exposed through
54+
`ListProviderProfiles` and `GetProviderProfile`. The profile loader validates the
55+
YAML catalog and materializes the same proto-backed shape that future API imports
56+
will accept. Profiles describe credential names and environment variables, known
57+
network endpoints, expected binaries, category, and whether the provider is
58+
inference-capable. Categories are a proto enum so clients can group and filter
59+
provider types without parsing display strings. Current values are `other`,
60+
`inference`, `agent`, `source_control`, `messaging`, `data`, and `knowledge`.
61+
Agent profiles such as `claude`, `codex`, and `opencode` can still be
62+
inference-capable when their tool talks to an inference API.
63+
64+
Profiles are additive to provider records. A provider record with only `type`,
65+
`credentials`, and `config` can be matched to built-in profile metadata by
66+
`provider.type`. Profile-generated policy is still opt-in: the gateway composes provider
67+
profile rules only when the gateway-global `providers_v2_enabled` setting is true.
68+
69+
This keeps the compatibility boundary at the gateway. A gateway without
70+
`providers_v2_enabled=true` keeps the existing credential-only provider behavior, while a
71+
gateway with the flag enabled routes all attached known provider types through the
72+
profile-backed policy path.
73+
74+
### Provider Policy Composition
75+
76+
Sandbox policy fetch uses just-in-time composition:
77+
78+
```text
79+
effective policy = base/static policy + provider profile rules + user rules
80+
```
81+
82+
The composed policy is derived data. The sandbox still receives one normal
83+
`SandboxPolicy`, but provider-generated entries are not persisted as user-authored
84+
policy revisions. Full policy replacement and incremental policy updates continue to
85+
mutate the user-authored policy layer. Provider-generated rules are re-added during
86+
composition for each attached provider whose type has a built-in profile.
87+
88+
Provider-generated network rules use reserved `_provider_*` names derived from the
89+
provider record name. If a user or global policy already has the same key, composition
90+
keeps the policy entry and adds a numeric suffix to the provider entry. Duplicate
91+
host/port endpoints across policy and provider rules are valid; OPA evaluates all
92+
rules, so allow decisions are the union of matching allows and deny rules continue to
93+
win globally.
94+
95+
Gateway-global policy still overrides sandbox-authored policy. When `providers_v2_enabled`
96+
is true, provider layers compose JIT onto the effective policy source, whether that
97+
source is sandbox-scoped or global. The composed payload is derived data and is not
98+
persisted as a policy revision.
99+
47100
## Components
48101

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

175229
- `openshell provider get <name>`
176230
- `openshell provider list`
231+
- `openshell provider list-profiles`
177232
- `openshell provider update <name> ...`
178233
- `openshell provider delete <name> [<name>...]`
179234

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

236293
### Sandbox Supervisor: Fetching Credentials
237294

‎crates/openshell-cli/src/main.rs‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,11 +299,10 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
299299
const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
300300
$ openshell settings get my-sandbox
301301
$ openshell settings get --global
302-
$ openshell settings set my-sandbox --key log_level --value debug
303-
$ openshell settings set --global --key log_level --value warn
304-
$ openshell settings set --global --key dummy_bool --value yes
305-
$ openshell settings set --global --key dummy_int --value 42
306-
$ openshell settings delete --global --key log_level
302+
$ openshell settings set --global --key providers_v2_enabled --value true
303+
$ openshell settings set my-sandbox --key ocsf_json_enabled --value true
304+
$ openshell settings set --global --key ocsf_json_enabled --value true
305+
$ openshell settings delete --global --key providers_v2_enabled
307306
";
308307

309308
const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
@@ -735,6 +734,10 @@ enum ProviderCommands {
735734
names: bool,
736735
},
737736

737+
/// List available provider profiles.
738+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
739+
ListProfiles,
740+
738741
/// Update an existing provider's credentials or config.
739742
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
740743
Update {
@@ -2764,6 +2767,9 @@ async fn main() -> Result<()> {
27642767
} => {
27652768
run::provider_list(endpoint, limit, offset, names, &tls).await?;
27662769
}
2770+
ProviderCommands::ListProfiles => {
2771+
run::provider_list_profiles(endpoint, &tls).await?;
2772+
}
27672773
ProviderCommands::Update {
27682774
name,
27692775
from_existing,
@@ -3454,6 +3460,19 @@ mod tests {
34543460
}
34553461
}
34563462

3463+
#[test]
3464+
fn provider_list_profiles_parses() {
3465+
let cli = Cli::try_parse_from(["openshell", "provider", "list-profiles"])
3466+
.expect("provider list-profiles should parse");
3467+
3468+
assert!(matches!(
3469+
cli.command,
3470+
Some(Commands::Provider {
3471+
command: Some(ProviderCommands::ListProfiles)
3472+
})
3473+
));
3474+
}
3475+
34573476
#[test]
34583477
fn settings_set_global_parses_yes_flag() {
34593478
let cli = Cli::try_parse_from([

‎crates/openshell-cli/src/run.rs‎

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ use openshell_bootstrap::{
2222
get_gateway_metadata, list_gateways, load_active_gateway, remove_gateway_metadata,
2323
resolve_ssh_hostname, save_active_gateway, save_last_sandbox, store_gateway_metadata,
2424
};
25+
use openshell_core::proto::ProviderProfileCategory;
2526
use openshell_core::proto::{
2627
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest,
2728
CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest,
2829
ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
2930
GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
30-
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest,
31-
ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider,
32-
RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
33-
SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest,
34-
UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value,
31+
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProviderProfilesRequest,
32+
ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource,
33+
PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxPhase,
34+
SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope,
35+
SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest,
36+
exec_sandbox_event, setting_value,
3537
};
3638
use openshell_core::settings::{self, SettingValueKind};
3739
use openshell_core::{ObjectId, ObjectName};
@@ -3851,7 +3853,7 @@ pub async fn provider_create(
38513853
created_at_ms: 0,
38523854
labels: HashMap::new(),
38533855
}),
3854-
r#type: provider_type,
3856+
r#type: provider_type.clone(),
38553857
credentials: credential_map,
38563858
config: config_map,
38573859
}),
@@ -3978,6 +3980,68 @@ pub async fn provider_list(
39783980
Ok(())
39793981
}
39803982

3983+
pub async fn provider_list_profiles(server: &str, tls: &TlsOptions) -> Result<()> {
3984+
let mut client = grpc_client(server, tls).await?;
3985+
let response = client
3986+
.list_provider_profiles(ListProviderProfilesRequest {
3987+
limit: 100,
3988+
offset: 0,
3989+
})
3990+
.await
3991+
.into_diagnostic()?;
3992+
let mut profiles = response.into_inner().profiles;
3993+
profiles.sort_by(|left, right| {
3994+
left.category
3995+
.cmp(&right.category)
3996+
.then_with(|| left.id.cmp(&right.id))
3997+
});
3998+
3999+
if profiles.is_empty() {
4000+
println!("No provider profiles found.");
4001+
return Ok(());
4002+
}
4003+
4004+
println!("{}", "Available Provider Profiles:".cyan().bold());
4005+
let mut current_category = i32::MIN;
4006+
for profile in profiles {
4007+
if profile.category != current_category {
4008+
current_category = profile.category;
4009+
println!();
4010+
println!(" {}", display_provider_category(current_category).bold());
4011+
}
4012+
print_provider_type_row(&profile);
4013+
}
4014+
4015+
Ok(())
4016+
}
4017+
4018+
fn display_provider_category(category: i32) -> &'static str {
4019+
match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) {
4020+
ProviderProfileCategory::Inference => "INFERENCE",
4021+
ProviderProfileCategory::Agent => "AGENT",
4022+
ProviderProfileCategory::SourceControl => "SOURCE CONTROL",
4023+
ProviderProfileCategory::Messaging => "MESSAGING",
4024+
ProviderProfileCategory::Data => "DATA",
4025+
ProviderProfileCategory::Knowledge => "KNOWLEDGE",
4026+
ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER",
4027+
}
4028+
}
4029+
4030+
fn print_provider_type_row(profile: &ProviderProfile) {
4031+
let inference = if profile.inference_capable {
4032+
" inference"
4033+
} else {
4034+
""
4035+
};
4036+
println!(
4037+
" {:<12} {:<42} endpoints: {:<2}{}",
4038+
profile.id,
4039+
profile.display_name,
4040+
profile.endpoints.len(),
4041+
inference
4042+
);
4043+
}
4044+
39814045
pub async fn provider_update(
39824046
server: &str,
39834047
name: &str,

0 commit comments

Comments
 (0)