From 03dd4764cc1b67077254537622bb554f2dff6dab Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 22 Jul 2026 18:19:05 +0200 Subject: [PATCH 1/2] test(supervisor-network): default L7 eval context in tests Signed-off-by: Evan Lezar --- .../src/l7/graphql.rs | 4 +- .../src/l7/middleware.rs | 8 +- .../src/l7/relay.rs | 89 +++++-------------- .../src/l7/token_grant_injection.rs | 4 +- .../src/l7/websocket.rs | 4 +- .../openshell-supervisor-network/src/proxy.rs | 20 ++--- 6 files changed, 34 insertions(+), 95 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/graphql.rs b/crates/openshell-supervisor-network/src/l7/graphql.rs index 12979f0b19..2569bc68fa 100644 --- a/crates/openshell-supervisor-network/src/l7/graphql.rs +++ b/crates/openshell-supervisor-network/src/l7/graphql.rs @@ -801,9 +801,7 @@ network_policies: ancestors: Vec::new(), cmdline_paths: Vec::new(), secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let request_info = crate::l7::L7RequestInfo { action: req.action, diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 263d886f28..03366bced6 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -562,9 +562,7 @@ mod tests { ancestors: Vec::new(), cmdline_paths: Vec::new(), secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let req = crate::l7::provider::L7Request { action: "POST".into(), @@ -612,9 +610,7 @@ mod tests { ancestors: Vec::new(), cmdline_paths: Vec::new(), secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let req = crate::l7::provider::L7Request { action: "POST".into(), diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 4bf1bb0bc0..dd34a373c8 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -34,6 +34,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, warn}; /// Context for L7 request policy evaluation. +#[cfg_attr(test, derive(Default))] pub struct L7EvalContext { /// Host from the CONNECT request. pub host: String, @@ -2361,9 +2362,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; (config, tunnel_engine, ctx) @@ -2451,9 +2450,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; (config, tunnel_engine, ctx) } @@ -2497,9 +2494,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; (config, tunnel_engine, ctx) } @@ -3012,9 +3007,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); @@ -3129,9 +3122,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let req = || crate::l7::provider::L7Request { action: "POST".into(), @@ -3342,9 +3333,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; (config, tunnel_engine, ctx) } @@ -3506,9 +3495,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); @@ -3607,9 +3594,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; (config, tunnel_engine, ctx) } @@ -3962,9 +3947,7 @@ network_policies: ancestors: Vec::new(), cmdline_paths: Vec::new(), secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let input = middleware_request_input( @@ -3996,9 +3979,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let req = crate::l7::provider::L7Request { action: "POST".into(), @@ -4187,9 +4168,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -4299,9 +4278,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -4514,9 +4491,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let request = L7RequestInfo { action: "WEBSOCKET_TEXT".into(), @@ -4591,9 +4566,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let mut request = L7RequestInfo { action: "POST".into(), @@ -4714,9 +4687,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let mut request = L7RequestInfo { action: "POST".into(), @@ -4779,9 +4750,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let mut request = L7RequestInfo { action: "POST".into(), @@ -4942,9 +4911,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); @@ -5041,9 +5008,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -5153,9 +5118,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver.map(Arc::new), - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -5278,9 +5241,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver.map(Arc::new), - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -5451,9 +5412,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); @@ -5544,9 +5503,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut app, mut relay_client) = tokio::io::duplex(8192); diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 07a8c641f1..2ca25e6e81 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -721,9 +721,9 @@ mod tests { ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + ..Default::default() }; let req = L7Request { action: "GET".to_string(), @@ -758,9 +758,9 @@ mod tests { ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + ..Default::default() }; let req = L7Request { action: "GET".to_string(), diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index e1f92e6ece..bb59c5227b 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -1273,9 +1273,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let (mut client_write, mut relay_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024); let (mut relay_write, mut upstream_read) = tokio::io::duplex(MAX_TEXT_MESSAGE_BYTES + 1024); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index c0cbe3005d..1ec762f1fa 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -5579,9 +5579,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::new( openshell_supervisor_middleware_builtins::services() @@ -5866,9 +5864,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; (config, tunnel_engine, ctx) } @@ -6041,9 +6037,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let query_params = std::collections::HashMap::new(); @@ -6084,9 +6078,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let query_params = std::collections::HashMap::new(); let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); @@ -8492,9 +8484,7 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, - dynamic_credentials: None, - token_grant_resolver: None, + ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::new( openshell_supervisor_middleware_builtins::services() From f7237a75fa540ca6bdb288a16d4c52166bbfe7ae Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Wed, 22 Jul 2026 18:26:42 +0200 Subject: [PATCH 2/2] refactor(supervisor): pass agent proposal state explicitly Signed-off-by: Evan Lezar --- crates/openshell-core/Cargo.toml | 4 - crates/openshell-core/src/proposals.rs | 93 ++---- crates/openshell-sandbox/Cargo.toml | 1 - crates/openshell-sandbox/src/lib.rs | 303 +++++++++++++----- .../openshell-sandbox/src/sidecar_control.rs | 95 +++++- .../openshell-supervisor-network/Cargo.toml | 1 - .../src/l7/middleware.rs | 8 +- .../src/l7/relay.rs | 50 +-- .../src/l7/rest.rs | 126 +++++++- .../src/policy_local.rs | 77 +++-- .../openshell-supervisor-network/src/proxy.rs | 31 +- .../openshell-supervisor-network/src/run.rs | 3 + .../openshell-supervisor-process/src/run.rs | 23 +- 13 files changed, 566 insertions(+), 249 deletions(-) diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 53735a6bb8..495d069b68 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -34,10 +34,6 @@ default = ["telemetry"] ## `--no-default-features` (plus any other features you need) for a build that ## contains no telemetry endpoint, no HTTP client, and no emission code at all. telemetry = ["dep:reqwest", "dep:chrono"] -## Expose proposals::test_helpers (`ProposalsFlagGuard`) to downstream test -## code in other crates. Enabled by openshell-sandbox and -## openshell-supervisor-network dev builds. -test-helpers = [] [build-dependencies] tonic-prost-build = { workspace = true } diff --git a/crates/openshell-core/src/proposals.rs b/crates/openshell-core/src/proposals.rs index fcb6b110ce..53586629ca 100644 --- a/crates/openshell-core/src/proposals.rs +++ b/crates/openshell-core/src/proposals.rs @@ -1,83 +1,50 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Process-wide flag controlling agent-driven policy proposals. +//! Shared state controlling agent-driven policy proposals. //! //! Initialised once during sandbox start from the `agent_policy_proposals_enabled` -//! setting and updated by the policy poll loop when the setting changes. Read -//! by the `policy.local` route handler and by the skills installer to gate the -//! agent-controlled mutation surface. Tests use [`test_helpers::ProposalsFlagGuard`] -//! to flip the flag through a serialized guard. +//! setting and updated by the policy poll loop or authoritative sidecar control +//! when the setting changes. Read by the `policy.local` route handler and by +//! the skills installer to gate the agent-controlled mutation surface. use std::sync::Arc; -use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; -/// Process-wide handle to the agent-proposals flag. +/// Shared handle for the agent-driven policy proposal surface. /// -/// Set once by `run_sandbox()` during start; subsequent attempts to set it are -/// ignored. The contained `AtomicBool` is updated by the policy poll loop. -pub static AGENT_PROPOSALS_ENABLED: OnceLock> = OnceLock::new(); - -/// Read the current value of the agent proposals feature flag. -/// -/// Returns `false` if the flag has not been initialized (e.g. during unit -/// tests), matching the documented default for the setting. -pub fn agent_proposals_enabled() -> bool { - AGENT_PROPOSALS_ENABLED - .get() - .is_some_and(|flag| flag.load(Ordering::Relaxed)) +/// Clones point at the same atomic value, so the sandbox orchestrator can pass +/// this into the process and network supervisors and then update it from the +/// settings poll loop or sidecar control. +#[derive(Clone, Debug)] +pub struct AgentProposals { + enabled: Arc, } -/// Test-only helpers shared across crates' test modules. -#[cfg(any(test, feature = "test-helpers"))] -pub mod test_helpers { - use std::sync::Arc; - use std::sync::LazyLock; - use std::sync::atomic::{AtomicBool, Ordering}; - use tokio::sync::MutexGuard; - - static PROPOSALS_FLAG_LOCK: LazyLock> = - LazyLock::new(|| tokio::sync::Mutex::new(())); - - /// Guard for tests that toggle the process-wide flag. - /// - /// Acquires a process-wide async mutex, swaps in the requested value, and - /// restores the previous value on drop. Hold the guard for the duration of - /// any code that reads `agent_proposals_enabled()`. - pub struct ProposalsFlagGuard { - prev: bool, - flag: Arc, - _lock: MutexGuard<'static, ()>, +impl AgentProposals { + #[must_use] + pub fn new(enabled: bool) -> Self { + Self { + enabled: Arc::new(AtomicBool::new(enabled)), + } } - impl ProposalsFlagGuard { - pub async fn set(enabled: bool) -> Self { - let lock = PROPOSALS_FLAG_LOCK.lock().await; - Self::with_lock(enabled, lock) - } + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } - pub fn set_blocking(enabled: bool) -> Self { - let lock = PROPOSALS_FLAG_LOCK.blocking_lock(); - Self::with_lock(enabled, lock) - } + pub fn set_enabled(&self, enabled: bool) { + self.enabled.store(enabled, Ordering::Relaxed); + } - fn with_lock(enabled: bool, lock: MutexGuard<'static, ()>) -> Self { - let flag = super::AGENT_PROPOSALS_ENABLED - .get_or_init(|| Arc::new(AtomicBool::new(false))) - .clone(); - let prev = flag.swap(enabled, Ordering::Relaxed); - Self { - prev, - flag, - _lock: lock, - } - } + pub fn swap_enabled(&self, enabled: bool) -> bool { + self.enabled.swap(enabled, Ordering::Relaxed) } +} - impl Drop for ProposalsFlagGuard { - fn drop(&mut self) { - self.flag.store(self.prev, Ordering::Relaxed); - } +impl Default for AgentProposals { + fn default() -> Self { + Self::new(false) } } diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index f9c23cf60f..6a51635b14 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -60,7 +60,6 @@ default = ["telemetry"] telemetry = ["openshell-core/telemetry"] [dev-dependencies] -openshell-core = { path = "../openshell-core", features = ["test-helpers"] } tempfile = "3" temp-env = "0.3" tokio-tungstenite = { workspace = true } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 347e1509b6..271f82710c 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -52,17 +52,9 @@ use openshell_ocsf::{ /// `run_sandbox()` startup via `openshell_ocsf::ctx::set_ctx`. pub(crate) use openshell_ocsf::ctx::ctx as ocsf_ctx; -/// Process-wide flag for the agent-driven policy proposal surface. -/// Set once during `run_sandbox()` startup and updated by the settings poll -/// loop when `agent_policy_proposals_enabled` changes. Read by the -/// `policy.local` route handler and the L7 deny body's `next_steps` builder -/// to gate the agent-controlled mutation surface. Exposed `pub(crate)` so -/// unit tests in sibling modules can flip the flag through a serialized -/// guard (see `policy_local::tests::ProposalsFlagGuard`). -pub(crate) use openshell_core::proposals::AGENT_PROPOSALS_ENABLED; - use openshell_core::denial::DenialEvent; use openshell_core::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; +use openshell_core::proposals::AgentProposals; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_supervisor_network::opa::OpaEngine; use openshell_supervisor_process::process::ProcessEnforcementMode; @@ -160,27 +152,34 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let (mut policy, opa_engine, retained_proto, middleware_registry_status, loaded_policy_origin) = - if let Some(bootstrap) = sidecar_bootstrap.as_ref() { - let (policy, opa_engine, retained_proto, loaded_policy_origin) = - load_policy_from_sidecar_bootstrap(bootstrap)?; - ( - policy, - opa_engine, - retained_proto, - MiddlewareRegistryStatus::Synchronized, - loaded_policy_origin, - ) - } else { - load_policy( - sandbox_id.clone(), - sandbox, - openshell_endpoint.clone(), - policy_rules, - policy_data, - ) - .await? - }; + let ( + mut policy, + opa_engine, + retained_proto, + middleware_registry_status, + loaded_policy_origin, + initial_agent_proposals_enabled, + ) = if let Some(bootstrap) = sidecar_bootstrap.as_ref() { + let (policy, opa_engine, retained_proto, loaded_policy_origin) = + load_policy_from_sidecar_bootstrap(bootstrap)?; + ( + policy, + opa_engine, + retained_proto, + MiddlewareRegistryStatus::Synchronized, + loaded_policy_origin, + bootstrap.agent_proposals_enabled, + ) + } else { + load_policy( + sandbox_id.clone(), + sandbox, + openshell_endpoint.clone(), + policy_rules, + policy_data, + ) + .await? + }; // Override the policy's process identity with the driver-resolved UID/GID // from the pod environment. The policy defaults to the name "sandbox" which @@ -290,25 +289,23 @@ pub async fn run_sandbox( let provider_env = provider_credentials.child_env_with_gcp_resolved(); (provider_credentials, provider_env) }; + + // Shared agent-proposals feature flag. Seed from the same initial settings + // snapshot that produced the policy so networking and process setup agree + // before the poll loop starts reconciling later changes. + let agent_proposals = AgentProposals::new(initial_agent_proposals_enabled); + let process_control_writer = process_control_connection .as_ref() .map(|connection| connection.writer.clone()); let mut process_control_closed = None; if let Some(connection) = process_control_connection { process_control_closed = Some(connection.closed); - spawn_sidecar_control_update_watcher(connection.updates, provider_credentials.clone()); - } - - // Initialize the agent-proposals feature flag. Default false until the - // initial settings fetch (or the poll loop) tells us otherwise. The flag - // gates the skill install, the policy.local route handler, and the L7 - // deny body's `next_steps` field — see `agent_proposals_enabled()`. - let proposals_enabled = Arc::new(std::sync::atomic::AtomicBool::new(false)); - if AGENT_PROPOSALS_ENABLED - .set(proposals_enabled.clone()) - .is_err() - { - debug!("agent proposals flag already initialized, keeping existing"); + spawn_sidecar_control_update_watcher( + connection.updates, + provider_credentials.clone(), + agent_proposals.clone(), + ); } // Shared PID: set after process spawn so the proxy can look up @@ -389,6 +386,7 @@ pub async fn run_sandbox( inference_routes.as_deref(), denial_tx, activity_tx, + agent_proposals.clone(), workspace_rx.clone(), ) .await?, @@ -422,6 +420,7 @@ pub async fn run_sandbox( policy_proto: proto.clone(), provider_env_revision: provider_credentials.snapshot().revision, provider_child_env: provider_env.clone(), + agent_proposals_enabled: agent_proposals.enabled(), proxy_ca_cert_path: ca_paths.as_ref().map(|paths| paths.0.clone()), proxy_ca_bundle_path: ca_paths.as_ref().map(|paths| paths.1.clone()), }, @@ -591,6 +590,7 @@ pub async fn run_sandbox( ocsf_enabled: poll_ocsf_enabled, provider_credentials: poll_provider_credentials, policy_local_ctx: poll_policy_local, + agent_proposals: agent_proposals.clone(), middleware_registry_status, sidecar_control_publisher: sidecar_control_publisher.clone(), workspace_tx, @@ -710,6 +710,7 @@ pub async fn run_sandbox( provider_credentials, provider_env, ca_file_paths, + agent_proposals.clone(), #[cfg(target_os = "linux")] netns.as_ref(), #[cfg(target_os = "linux")] @@ -870,11 +871,12 @@ fn load_policy_from_sidecar_bootstrap( fn spawn_sidecar_control_update_watcher( mut updates: tokio::sync::mpsc::UnboundedReceiver, provider_credentials: ProviderCredentialState, + agent_proposals: AgentProposals, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { while let Some(update) = updates.recv().await { match update { - sidecar_control::ControlUpdate::ProviderEnvUpdated { + sidecar_control::ControlUpdate::ProviderEnv { revision, provider_child_env, } => { @@ -895,7 +897,7 @@ fn spawn_sidecar_control_update_watcher( .build() ); } - sidecar_control::ControlUpdate::PolicyUpdated { + sidecar_control::ControlUpdate::Policy { policy_proto, policy_hash, config_revision, @@ -907,6 +909,19 @@ fn spawn_sidecar_control_update_watcher( "Received sidecar policy update for process supervisor" ); } + sidecar_control::ControlUpdate::AgentProposals { + enabled, + config_revision, + } => { + apply_agent_proposals_enabled( + &agent_proposals, + enabled, + "sidecar control", + Some(config_revision), + None, + skills::install_static_skills, + ); + } } } }) @@ -1831,6 +1846,7 @@ async fn load_policy( Option, MiddlewareRegistryStatus, LoadedPolicyOrigin, + bool, )> { // File mode: load OPA engine from rego rules + YAML data (dev override) if let (Some(policy_file), Some(data_file)) = (&policy_rules, &policy_data) { @@ -1879,6 +1895,7 @@ async fn load_policy( None, MiddlewareRegistryStatus::Synchronized, LoadedPolicyOrigin::LocalOverride, + false, )); } @@ -2060,6 +2077,7 @@ async fn load_policy( LoadedPolicyOrigin::Gateway { revision: loaded_policy_revision, }, + agent_proposals_enabled_from_settings(&snapshot.settings), )); } @@ -2504,6 +2522,7 @@ struct PolicyPollLoopContext { ocsf_enabled: Arc, provider_credentials: ProviderCredentialState, policy_local_ctx: Option>, + agent_proposals: AgentProposals, middleware_registry_status: MiddlewareRegistryStatus, sidecar_control_publisher: Option, workspace_tx: tokio::sync::watch::Sender, @@ -2623,6 +2642,14 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { InitialPollDisposition::Acknowledge(candidate) => { apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + apply_agent_proposals_enabled( + &ctx.agent_proposals, + agent_proposals_enabled_from_settings(&result.settings), + "initial settings poll", + Some(candidate.config_revision), + ctx.sidecar_control_publisher.as_ref(), + skills::install_static_skills, + ); current_config_revision = candidate.config_revision; current_policy_hash.clone_from(&candidate.policy_hash); current_middleware_services = result.supervisor_middleware_services; @@ -2639,6 +2666,14 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { InitialPollDisposition::Reconcile => pending_result = Some(result), InitialPollDisposition::TrackOnly => { apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); + apply_agent_proposals_enabled( + &ctx.agent_proposals, + agent_proposals_enabled_from_settings(&result.settings), + "initial settings poll", + Some(result.config_revision), + ctx.sidecar_control_publisher.as_ref(), + skills::install_static_skills, + ); current_config_revision = result.config_revision; current_policy_hash = result.policy_hash.clone(); current_middleware_services = result.supervisor_middleware_services; @@ -2913,34 +2948,16 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { // off picks up the surface without a recreate. We never uninstall on // a true→false transition: stale skill content on disk is harmless // because route_request and agent_next_steps both gate on the live - // atomic, so the agent that reads the skill will see 404s and an + // shared flag, so the agent that reads the skill will see 404s and an // empty `next_steps` array regardless. - if let Some(flag) = AGENT_PROPOSALS_ENABLED.get() { - let new_proposals = extract_bool_setting( - &result.settings, - openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY, - ) - .unwrap_or(false); - let prev_proposals = flag.swap(new_proposals, Ordering::Relaxed); - if new_proposals != prev_proposals { - info!( - agent_policy_proposals_enabled = new_proposals, - "agent-driven policy proposals toggled" - ); - if new_proposals && !prev_proposals { - match skills::install_static_skills() { - Ok(installed) => info!( - path = %installed.policy_advisor.display(), - "Installed sandbox agent skill on toggle-on" - ), - Err(error) => warn!( - error = %error, - "Failed to install sandbox agent skill on toggle-on" - ), - } - } - } - } + apply_agent_proposals_enabled( + &ctx.agent_proposals, + agent_proposals_enabled_from_settings(&result.settings), + "settings poll", + Some(result.config_revision), + ctx.sidecar_control_publisher.as_ref(), + skills::install_static_skills, + ); current_config_revision = result.config_revision; if !reloads_gateway_policy { @@ -2979,6 +2996,52 @@ fn extract_bool_setting( }) } +fn agent_proposals_enabled_from_settings( + settings: &std::collections::HashMap, +) -> bool { + extract_bool_setting( + settings, + openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY, + ) + .unwrap_or(false) +} + +fn apply_agent_proposals_enabled( + agent_proposals: &AgentProposals, + enabled: bool, + source: &'static str, + config_revision: Option, + sidecar_control_publisher: Option<&sidecar_control::Publisher>, + install_static_skills: impl FnOnce() -> Result, +) { + let previously_enabled = agent_proposals.swap_enabled(enabled); + if enabled == previously_enabled { + return; + } + + info!( + agent_policy_proposals_enabled = enabled, + source, config_revision, "agent-driven policy proposals toggled" + ); + + if let (Some(publisher), Some(config_revision)) = (sidecar_control_publisher, config_revision) { + publisher.publish_agent_proposals(enabled, config_revision); + } + + if enabled && !previously_enabled { + match install_static_skills() { + Ok(installed) => info!( + path = %installed.policy_advisor.display(), + "Installed sandbox agent skill on toggle-on" + ), + Err(error) => warn!( + error = %error, + "Failed to install sandbox agent skill on toggle-on" + ), + } + } +} + /// Log individual setting changes between two snapshots. fn log_setting_changes( old: &std::collections::HashMap, @@ -3060,7 +3123,7 @@ mod tests { use openshell_core::policy::{ FilesystemPolicy, LandlockPolicy, NetworkMode, NetworkPolicy, ProcessPolicy, ProxyPolicy, }; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; fn proxy_policy(http_addr: Option) -> SandboxPolicy { SandboxPolicy { @@ -3132,9 +3195,13 @@ mod tests { 1, std::collections::HashMap::from([("TOKEN".to_string(), "old".to_string())]), ); - let handle = spawn_sidecar_control_update_watcher(rx, provider_credentials.clone()); + let handle = spawn_sidecar_control_update_watcher( + rx, + provider_credentials.clone(), + AgentProposals::default(), + ); - tx.send(sidecar_control::ControlUpdate::ProviderEnvUpdated { + tx.send(sidecar_control::ControlUpdate::ProviderEnv { revision: 2, provider_child_env: std::collections::HashMap::from([( "TOKEN".to_string(), @@ -3160,7 +3227,7 @@ mod tests { Some("new") ); - tx.send(sidecar_control::ControlUpdate::ProviderEnvUpdated { + tx.send(sidecar_control::ControlUpdate::ProviderEnv { revision: 1, provider_child_env: std::collections::HashMap::from([( "TOKEN".to_string(), @@ -3180,6 +3247,72 @@ mod tests { handle.abort(); } + #[tokio::test] + async fn sidecar_control_agent_proposals_update_flips_shared_state() { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let provider_credentials = + ProviderCredentialState::from_child_env_snapshot(0, std::collections::HashMap::new()); + let agent_proposals = AgentProposals::new(true); + let handle = + spawn_sidecar_control_update_watcher(rx, provider_credentials, agent_proposals.clone()); + + tx.send(sidecar_control::ControlUpdate::AgentProposals { + enabled: false, + config_revision: 5, + }) + .unwrap(); + + timeout(Duration::from_secs(1), async { + loop { + if !agent_proposals.enabled() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + handle.abort(); + } + + #[test] + fn apply_agent_proposals_enabled_installs_only_on_false_to_true() { + let agent_proposals = AgentProposals::default(); + let installs = AtomicUsize::new(0); + + apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(1), None, || { + installs.fetch_add(1, Ordering::Relaxed); + Ok(skills::InstalledSkills { + policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + agents: None, + }) + }); + assert!(agent_proposals.enabled()); + assert_eq!(installs.load(Ordering::Relaxed), 1); + + apply_agent_proposals_enabled(&agent_proposals, true, "test", Some(2), None, || { + installs.fetch_add(1, Ordering::Relaxed); + Ok(skills::InstalledSkills { + policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + agents: None, + }) + }); + assert_eq!(installs.load(Ordering::Relaxed), 1); + + apply_agent_proposals_enabled(&agent_proposals, false, "test", Some(3), None, || { + installs.fetch_add(1, Ordering::Relaxed); + Ok(skills::InstalledSkills { + policy_advisor: std::path::PathBuf::from("/tmp/policy_advisor.md"), + policy_advisor_skill: std::path::PathBuf::from("/tmp/SKILL.md"), + agents: None, + }) + }); + assert!(!agent_proposals.enabled()); + assert_eq!(installs.load(Ordering::Relaxed), 1); + } + #[test] fn apply_ocsf_json_setting_enables_from_initial_settings_snapshot() { let enabled = AtomicBool::new(false); @@ -3201,6 +3334,24 @@ mod tests { assert!(!enabled.load(Ordering::Relaxed)); } + #[test] + fn agent_proposals_setting_enables_from_initial_settings_snapshot() { + let mut settings = std::collections::HashMap::new(); + settings.insert( + openshell_core::settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + effective_bool(true), + ); + + assert!(agent_proposals_enabled_from_settings(&settings)); + } + + #[test] + fn agent_proposals_setting_defaults_false_when_unset() { + let settings = std::collections::HashMap::new(); + + assert!(!agent_proposals_enabled_from_settings(&settings)); + } + // ---- Policy disk discovery tests ---- #[test] diff --git a/crates/openshell-sandbox/src/sidecar_control.rs b/crates/openshell-sandbox/src/sidecar_control.rs index c98dbc33bf..8cc2854e41 100644 --- a/crates/openshell-sandbox/src/sidecar_control.rs +++ b/crates/openshell-sandbox/src/sidecar_control.rs @@ -25,6 +25,7 @@ pub struct BootstrapData { pub policy_proto: openshell_core::proto::SandboxPolicy, pub provider_env_revision: u64, pub provider_child_env: HashMap, + pub agent_proposals_enabled: bool, pub proxy_ca_cert_path: Option, pub proxy_ca_bundle_path: Option, } @@ -44,15 +45,19 @@ pub struct ExpectedPeer { #[derive(Debug, Clone)] pub enum ControlUpdate { - ProviderEnvUpdated { + ProviderEnv { revision: u64, provider_child_env: HashMap, }, - PolicyUpdated { + Policy { policy_proto: Box, policy_hash: String, config_revision: u64, }, + AgentProposals { + enabled: bool, + config_revision: u64, + }, } #[derive(Clone)] @@ -95,6 +100,21 @@ impl Publisher { config_revision, }); } + + pub fn publish_agent_proposals(&self, enabled: bool, config_revision: u64) { + { + let mut state = self.state.write().expect("sidecar control state poisoned"); + if state.agent_proposals_enabled == enabled { + return; + } + state.agent_proposals_enabled = enabled; + } + + let _ = self.updates.send(WireServerMessage::AgentProposalsUpdated { + enabled, + config_revision, + }); + } } pub struct ServerHandle { @@ -145,6 +165,7 @@ enum WireServerMessage { policy_proto: Vec, provider_env_revision: u64, provider_child_env: HashMap, + agent_proposals_enabled: bool, proxy_ca_cert_path: Option, proxy_ca_bundle_path: Option, }, @@ -157,6 +178,10 @@ enum WireServerMessage { policy_hash: String, config_revision: u64, }, + AgentProposalsUpdated { + enabled: bool, + config_revision: u64, + }, } impl BootstrapData { @@ -166,6 +191,7 @@ impl BootstrapData { policy_proto: self.policy_proto.encode_to_vec(), provider_env_revision: self.provider_env_revision, provider_child_env: self.provider_child_env.clone(), + agent_proposals_enabled: self.agent_proposals_enabled, proxy_ca_cert_path: self .proxy_ca_cert_path .as_ref() @@ -186,6 +212,7 @@ impl TryFrom for BootstrapData { policy_proto, provider_env_revision, provider_child_env, + agent_proposals_enabled, proxy_ca_cert_path, proxy_ca_bundle_path, } = message @@ -203,6 +230,7 @@ impl TryFrom for BootstrapData { policy_proto, provider_env_revision, provider_child_env, + agent_proposals_enabled, proxy_ca_cert_path: proxy_ca_cert_path.map(PathBuf::from), proxy_ca_bundle_path: proxy_ca_bundle_path.map(PathBuf::from), }) @@ -217,7 +245,7 @@ impl TryFrom for ControlUpdate { WireServerMessage::ProviderEnvUpdated { revision, provider_child_env, - } => Ok(Self::ProviderEnvUpdated { + } => Ok(Self::ProviderEnv { revision, provider_child_env, }), @@ -230,12 +258,19 @@ impl TryFrom for ControlUpdate { openshell_core::proto::SandboxPolicy::decode(policy_proto.as_slice()) .into_diagnostic() .wrap_err("failed to decode sidecar policy update")?; - Ok(Self::PolicyUpdated { + Ok(Self::Policy { policy_proto: Box::new(policy_proto), policy_hash, config_revision, }) } + WireServerMessage::AgentProposalsUpdated { + enabled, + config_revision, + } => Ok(Self::AgentProposals { + enabled, + config_revision, + }), WireServerMessage::BootstrapResponse { .. } => Err(miette::miette!( "unexpected sidecar bootstrap response after initial handshake" )), @@ -407,13 +442,16 @@ async fn handle_connection( } } + // Subscribe before taking the bootstrap snapshot so an update can neither + // be missed between the snapshot and the live update stream nor omitted + // from the snapshot itself. + let mut update_rx = updates.subscribe(); let bootstrap = { let state = state.read().expect("sidecar control state poisoned"); state.to_wire() }; write_json_line(&mut writer, &bootstrap).await?; - let mut update_rx = updates.subscribe(); loop { tokio::select! { line = lines.next_line() => { @@ -583,6 +621,7 @@ mod tests { }, provider_env_revision: 3, provider_child_env: env.clone(), + agent_proposals_enabled: true, proxy_ca_cert_path: Some(PathBuf::from("/tmp/ca.pem")), proxy_ca_bundle_path: Some(PathBuf::from("/tmp/bundle.pem")), }; @@ -595,6 +634,7 @@ mod tests { assert_eq!(received.policy_proto.version, 7); assert_eq!(received.provider_env_revision, 3); assert_eq!(received.provider_child_env, env); + assert!(received.agent_proposals_enabled); assert_eq!( received.proxy_ca_cert_path, Some(PathBuf::from("/tmp/ca.pem")) @@ -605,6 +645,46 @@ mod tests { ); } + #[tokio::test] + async fn agent_proposals_update_is_delivered_to_process_client() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let server = spawn_server( + &socket, + BootstrapData { + policy_proto: SandboxPolicy::default(), + provider_env_revision: 0, + provider_child_env: HashMap::new(), + agent_proposals_enabled: false, + proxy_ca_cert_path: None, + proxy_ca_bundle_path: None, + }, + current_peer(), + ) + .unwrap(); + let publisher = server.publisher(); + let (_bootstrap, mut connection) = connect_process_client(&socket, Duration::from_secs(1)) + .await + .unwrap(); + + publisher.publish_agent_proposals(true, 9); + + let update = tokio::time::timeout(Duration::from_secs(1), connection.updates.recv()) + .await + .unwrap() + .unwrap(); + match update { + ControlUpdate::AgentProposals { + enabled, + config_revision, + } => { + assert!(enabled); + assert_eq!(config_revision, 9); + } + other => panic!("unexpected sidecar update: {other:?}"), + } + } + #[tokio::test] async fn entrypoint_started_is_delivered_to_server() { let dir = tempfile::tempdir().unwrap(); @@ -615,6 +695,7 @@ mod tests { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, provider_child_env: HashMap::new(), + agent_proposals_enabled: false, proxy_ca_cert_path: None, proxy_ca_bundle_path: None, }, @@ -655,6 +736,7 @@ mod tests { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, provider_child_env: HashMap::new(), + agent_proposals_enabled: false, proxy_ca_cert_path: None, proxy_ca_bundle_path: None, }, @@ -688,6 +770,7 @@ mod tests { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, provider_child_env: HashMap::new(), + agent_proposals_enabled: false, proxy_ca_cert_path: None, proxy_ca_bundle_path: None, }, @@ -716,6 +799,7 @@ mod tests { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, provider_child_env: HashMap::new(), + agent_proposals_enabled: false, proxy_ca_cert_path: None, proxy_ca_bundle_path: None, }, @@ -745,6 +829,7 @@ mod tests { policy_proto: SandboxPolicy::default(), provider_env_revision: 0, provider_child_env: HashMap::new(), + agent_proposals_enabled: false, proxy_ca_cert_path: None, proxy_ca_bundle_path: None, }, diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 1449276f4f..008bf1104b 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -50,7 +50,6 @@ uuid = { workspace = true } webpki-roots = { workspace = true } [dev-dependencies] -openshell-core = { path = "../openshell-core", features = ["test-helpers"] } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } tempfile = "3" tonic = { workspace = true } diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 03366bced6..28f91c3bb7 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -215,11 +215,7 @@ pub async fn send_middleware_rejection_response, redacted_target: &str, ) -> Result<()> { - let context = Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }); + let context = Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)); if let Some(denial) = denial { crate::l7::rest::send_middleware_deny_response( req, @@ -600,8 +596,6 @@ mod tests { #[tokio::test] async fn middleware_failure_uses_platform_response_without_policy_guidance() { - let _proposals = - openshell_core::proposals::test_helpers::ProposalsFlagGuard::set(true).await; let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index dd34a373c8..fa2eab4ad7 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -63,6 +63,8 @@ pub struct L7EvalContext { /// Dynamic token grant resolver for endpoint-bound credentials. pub(crate) token_grant_resolver: Option>, + /// Shared feature state for agent-driven policy proposals. + pub(crate) agent_proposals: openshell_core::proposals::AgentProposals, } #[derive(Default)] @@ -177,11 +179,13 @@ where engine_type_for_protocol(config.protocol), ); crate::l7::rest::RestProvider::default() - .deny( + .deny_with_redacted_target( req, &ctx.policy_name, crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, client, + None, + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; Ok(true) @@ -297,11 +301,13 @@ where let Some(config) = select_l7_config_for_path(configs, &req.target) else { crate::l7::rest::RestProvider::default() - .deny( + .deny_with_redacted_target( &req, &ctx.policy_name, "no L7 endpoint path matched request", client, + None, + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -417,11 +423,7 @@ where "websocket endpoint requires a valid WebSocket upgrade request", client, Some(&redacted_target), - Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -552,11 +554,7 @@ where &reason, client, Some(&redacted_target), - Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -885,11 +883,7 @@ where "websocket endpoint requires a valid WebSocket upgrade request", client, Some(&redacted_target), - Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -1068,11 +1062,7 @@ where &reason, client, Some(&redacted_target), - Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -1300,11 +1290,7 @@ where &reason, client, Some(&redacted_target), - Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -1536,11 +1522,7 @@ where &reason, client, Some(&redacted_target), - Some(crate::l7::rest::DenyResponseContext { - host: Some(&ctx.host), - port: Some(ctx.port), - binary: Some(&ctx.binary_path), - }), + Some(crate::l7::rest::DenyResponseContext::from_l7_context(ctx)), ) .await?; return Ok(()); @@ -2295,9 +2277,9 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + ..Default::default() }; (config, tunnel_engine, ctx, fixture) @@ -2403,9 +2385,9 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + ..Default::default() }; (generation_guard, ctx, fixture) diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 0c1346bf2e..fd9d373f81 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -130,6 +130,19 @@ pub(crate) struct DenyResponseContext<'a> { pub(crate) host: Option<&'a str>, pub(crate) port: Option, pub(crate) binary: Option<&'a str>, + pub(crate) agent_proposals_enabled: bool, +} + +impl<'a> DenyResponseContext<'a> { + #[must_use] + pub(crate) fn from_l7_context(ctx: &'a crate::l7::relay::L7EvalContext) -> Self { + Self { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + agent_proposals_enabled: ctx.agent_proposals.enabled(), + } + } } impl RestProvider { @@ -2360,9 +2373,10 @@ fn deny_response_body( // module; this side picks up the change automatically. body.insert( "next_steps".to_string(), - crate::policy_local::agent_next_steps(), + crate::policy_local::agent_next_steps(context.agent_proposals_enabled), ); - if let Some(guidance) = crate::policy_local::agent_guidance() { + if let Some(guidance) = crate::policy_local::agent_guidance_for(context.agent_proposals_enabled) + { body.insert("agent_guidance".to_string(), serde_json::json!(guidance)); } @@ -3183,6 +3197,7 @@ mod tests { use super::*; use crate::opa::OpaEngine; use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; + use openshell_core::proposals::AgentProposals; use openshell_core::secrets::SecretResolver; use std::pin::Pin; use std::sync::Arc; @@ -3350,6 +3365,31 @@ mod tests { .expect("WebSocket frame should arrive") } + async fn policy_local_json_response( + ctx: Arc, + ) -> serde_json::Value { + let (mut client, mut server) = tokio::io::duplex(4096); + let task = tokio::spawn(async move { + crate::policy_local::handle_forward_request( + ctx.as_ref(), + "GET", + "/v1/policy/current", + b"GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n\r\n", + &mut server, + ) + .await + .unwrap(); + }); + + let mut received = Vec::new(); + client.read_to_end(&mut received).await.unwrap(); + task.await.unwrap(); + + let response = String::from_utf8(received).unwrap(); + let (_, body) = response.split_once("\r\n\r\n").unwrap(); + serde_json::from_str(body).unwrap() + } + fn masked_frame_with_rsv(opcode: u8, rsv: u8, payload: &[u8]) -> Vec { let mask_key = [0x37, 0xfa, 0x21, 0x3d]; let mut frame = Vec::new(); @@ -3565,9 +3605,6 @@ mod tests { #[test] fn deny_response_body_is_agent_readable_and_redacted() { - // Agent-readable next_steps is gated on the proposals feature flag. - let _proposals = - openshell_core::proposals::test_helpers::ProposalsFlagGuard::set_blocking(true); let req = L7Request { action: "PUT".to_string(), target: "/repos/NVIDIA/OpenShell/contents/README.md?access_token=secret-token" @@ -3586,6 +3623,7 @@ mod tests { host: Some("api.github.com"), port: Some(443), binary: Some("/usr/bin/gh"), + agent_proposals_enabled: true, }), ); @@ -3632,8 +3670,6 @@ mod tests { #[test] fn deny_response_body_omits_agent_guidance_when_policy_advisor_is_off() { - let _proposals = - openshell_core::proposals::test_helpers::ProposalsFlagGuard::set_blocking(false); let req = L7Request { action: "GET".to_string(), target: "/gists".to_string(), @@ -3651,6 +3687,7 @@ mod tests { host: Some("api.github.com"), port: Some(443), binary: Some("/usr/bin/gh"), + agent_proposals_enabled: false, }), ); @@ -3662,10 +3699,76 @@ mod tests { ); } + #[tokio::test] + async fn shared_agent_proposals_state_gates_policy_local_and_l7_next_steps() { + let agent_proposals = AgentProposals::new(false); + let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new()); + let policy_local_ctx = Arc::new(crate::policy_local::PolicyLocalContext::new( + Some(openshell_core::proto::SandboxPolicy { + version: 1, + ..Default::default() + }), + None, + None, + agent_proposals.clone(), + workspace_rx, + )); + let l7_ctx = crate::l7::relay::L7EvalContext { + host: "api.github.com".to_string(), + port: 443, + binary_path: "/usr/bin/gh".to_string(), + agent_proposals: agent_proposals.clone(), + ..Default::default() + }; + let req = L7Request { + action: "POST".to_string(), + target: "/user/repos".to_string(), + query_params: HashMap::new(), + raw_header: Vec::new(), + body_length: BodyLength::None, + }; + + let policy_body = policy_local_json_response(policy_local_ctx.clone()).await; + assert_eq!(policy_body["error"], "feature_disabled"); + let deny_body = deny_response_body( + &req, + "github-readonly", + "no matching L7 allow rule", + None, + Some(DenyResponseContext::from_l7_context(&l7_ctx)), + ); + assert_eq!(deny_body["next_steps"], serde_json::json!([])); + assert!(deny_body.get("agent_guidance").is_none()); + + agent_proposals.set_enabled(true); + + let policy_body = policy_local_json_response(policy_local_ctx).await; + assert_eq!(policy_body["format"], "yaml"); + assert!( + policy_body["policy_yaml"] + .as_str() + .unwrap() + .contains("version: 1") + ); + let deny_body = deny_response_body( + &req, + "github-readonly", + "no matching L7 allow rule", + None, + Some(DenyResponseContext::from_l7_context(&l7_ctx)), + ); + assert_eq!(deny_body["next_steps"][0]["action"], "read_skill"); + assert_eq!(deny_body["next_steps"][3]["action"], "submit_proposal"); + assert!( + deny_body["agent_guidance"] + .as_str() + .unwrap() + .contains("http://policy.local/v1/proposals") + ); + } + #[test] fn middleware_deny_response_identifies_config_without_policy_remediation() { - let _proposals = - openshell_core::proposals::test_helpers::ProposalsFlagGuard::set_blocking(true); let req = L7Request { action: "POST".to_string(), target: "/v1/messages?token=secret-token".to_string(), @@ -3687,6 +3790,7 @@ mod tests { host: Some("api.example.com"), port: Some(443), binary: Some("/usr/bin/curl"), + agent_proposals_enabled: true, }), ); @@ -3705,9 +3809,6 @@ mod tests { #[tokio::test] async fn send_deny_response_writes_structured_json_403() { - // Agent-readable next_steps is gated on the proposals feature flag. - let _proposals = - openshell_core::proposals::test_helpers::ProposalsFlagGuard::set(true).await; let (mut client, mut server) = tokio::io::duplex(4096); let send = tokio::spawn(async move { let req = L7Request { @@ -3727,6 +3828,7 @@ mod tests { host: Some("api.github.com"), port: Some(443), binary: Some("/usr/bin/gh"), + agent_proposals_enabled: true, }), ) .await diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 53c038dec1..e915c18c9b 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -4,6 +4,7 @@ //! Sandbox-local policy advisor HTTP API. use miette::{IntoDiagnostic, Result}; +use openshell_core::proposals::AgentProposals; use openshell_core::proto::{ L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, SandboxPolicy as ProtoSandboxPolicy, @@ -85,6 +86,7 @@ const MAX_DENIAL_LINE_BYTES: usize = 4096; #[derive(Debug)] pub struct PolicyLocalContext { current_policy: Arc>>, + agent_proposals: AgentProposals, gateway_endpoint: Option, sandbox_name: Option, shorthand_log_dir: PathBuf, @@ -96,12 +98,14 @@ impl PolicyLocalContext { current_policy: Option, gateway_endpoint: Option, sandbox_name: Option, + agent_proposals: AgentProposals, workspace_rx: tokio::sync::watch::Receiver, ) -> Self { Self::with_log_dir( current_policy, gateway_endpoint, sandbox_name, + agent_proposals, PathBuf::from(LOG_DIR), workspace_rx, ) @@ -111,11 +115,13 @@ impl PolicyLocalContext { current_policy: Option, gateway_endpoint: Option, sandbox_name: Option, + agent_proposals: AgentProposals, shorthand_log_dir: PathBuf, workspace_rx: tokio::sync::watch::Receiver, ) -> Self { Self { current_policy: Arc::new(RwLock::new(current_policy)), + agent_proposals, gateway_endpoint, sandbox_name, shorthand_log_dir, @@ -126,6 +132,16 @@ impl PolicyLocalContext { pub async fn set_current_policy(&self, policy: ProtoSandboxPolicy) { *self.current_policy.write().await = Some(policy); } + + #[must_use] + pub fn agent_proposals(&self) -> AgentProposals { + self.agent_proposals.clone() + } + + #[must_use] + pub fn agent_proposals_enabled(&self) -> bool { + self.agent_proposals.enabled() + } } pub async fn handle_forward_request( @@ -154,7 +170,7 @@ async fn route_request( // when the flag is off — including the diagnostic `current_policy` and // `denials` routes. The skill is also not installed in that mode, so a // disabled sandbox has no entry point into this API at all. - if !openshell_core::proposals::agent_proposals_enabled() { + if !ctx.agent_proposals_enabled() { return ( 404, serde_json::json!({ @@ -223,12 +239,12 @@ fn not_found_payload(path: &str) -> (u16, serde_json::Value) { /// the deny body and the actual route table from drifting — adding or /// renaming a route only requires touching the route constants above. /// -/// Returns an empty array when `agent_proposals_enabled()` is false so a +/// Returns an empty array when agent policy proposals are disabled so a /// disabled sandbox doesn't advertise a surface that 404s. The deny body /// caller still emits the field (with `[]`) so the wire shape is stable. #[must_use] -pub fn agent_next_steps() -> serde_json::Value { - if !openshell_core::proposals::agent_proposals_enabled() { +pub fn agent_next_steps(agent_proposals_enabled: bool) -> serde_json::Value { + if !agent_proposals_enabled { return serde_json::json!([]); } let host = POLICY_LOCAL_HOST; @@ -258,8 +274,8 @@ pub fn agent_next_steps() -> serde_json::Value { /// Build the optional natural-language guidance embedded in L7 deny bodies. #[must_use] -pub fn agent_guidance() -> Option<&'static str> { - openshell_core::proposals::agent_proposals_enabled().then_some(AGENT_GUIDANCE) +pub fn agent_guidance_for(agent_proposals_enabled: bool) -> Option<&'static str> { + agent_proposals_enabled.then_some(AGENT_GUIDANCE) } async fn current_policy_response(ctx: &PolicyLocalContext) -> (u16, serde_json::Value) { @@ -1355,6 +1371,7 @@ struct L7DenyRuleJson { #[cfg(test)] mod tests { use super::*; + use openshell_core::proposals::AgentProposals; fn test_workspace_rx() -> tokio::sync::watch::Receiver { tokio::sync::watch::channel(String::new()).1 @@ -1505,6 +1522,7 @@ mod tests { None, None, None, + AgentProposals::new(true), dir.path().to_path_buf(), test_workspace_rx(), ); @@ -1542,6 +1560,7 @@ mod tests { None, None, None, + AgentProposals::new(true), dir.path().to_path_buf(), test_workspace_rx(), ); @@ -1558,6 +1577,7 @@ mod tests { None, None, None, + AgentProposals::new(true), dir.path().to_path_buf(), test_workspace_rx(), ); @@ -1627,6 +1647,7 @@ mod tests { None, None, None, + AgentProposals::new(true), dir.path().to_path_buf(), test_workspace_rx(), ); @@ -1638,12 +1659,9 @@ mod tests { assert!(surfaced.ends_with("...[truncated]")); } - use openshell_core::proposals::test_helpers::ProposalsFlagGuard; - #[test] fn agent_next_steps_returns_empty_when_flag_off() { - let _guard = ProposalsFlagGuard::set_blocking(false); - let steps = agent_next_steps(); + let steps = agent_next_steps(false); let arr = steps.as_array().expect("agent_next_steps is an array"); assert!( arr.is_empty(), @@ -1653,8 +1671,7 @@ mod tests { #[test] fn agent_next_steps_returns_full_array_when_flag_on() { - let _guard = ProposalsFlagGuard::set_blocking(true); - let steps = agent_next_steps(); + let steps = agent_next_steps(true); let arr = steps.as_array().expect("agent_next_steps is an array"); assert_eq!(arr.len(), 4, "expected 4 next_steps when feature is on"); let actions: Vec<&str> = arr @@ -1667,14 +1684,12 @@ mod tests { #[test] fn agent_guidance_is_absent_when_flag_off() { - let _guard = ProposalsFlagGuard::set_blocking(false); - assert!(agent_guidance().is_none()); + assert!(agent_guidance_for(false).is_none()); } #[test] fn agent_guidance_points_to_policy_advisor_when_flag_on() { - let _guard = ProposalsFlagGuard::set_blocking(true); - let guidance = agent_guidance().expect("guidance when proposals are enabled"); + let guidance = agent_guidance_for(true).expect("guidance when proposals are enabled"); assert!(guidance.contains("do not stop")); assert!(guidance.contains("/etc/openshell/skills/policy_advisor.md")); assert!(guidance.contains("http://policy.local/v1/proposals")); @@ -1683,7 +1698,6 @@ mod tests { #[tokio::test] async fn route_request_returns_feature_disabled_when_flag_off() { - let _guard = ProposalsFlagGuard::set(false).await; let ctx = PolicyLocalContext::new( Some(ProtoSandboxPolicy { version: 1, @@ -1691,6 +1705,7 @@ mod tests { }), None, None, + AgentProposals::new(false), test_workspace_rx(), ); @@ -1711,7 +1726,6 @@ mod tests { #[tokio::test] async fn current_policy_route_returns_yaml_envelope() { - let _guard = ProposalsFlagGuard::set(true).await; let ctx = PolicyLocalContext::new( Some(ProtoSandboxPolicy { version: 1, @@ -1719,6 +1733,7 @@ mod tests { }), None, None, + AgentProposals::new(true), test_workspace_rx(), ); @@ -1812,8 +1827,13 @@ mod tests { #[tokio::test] async fn proposal_routes_reject_malformed_paths() { - let _guard = ProposalsFlagGuard::set(true).await; - let ctx = PolicyLocalContext::new(None, None, None, test_workspace_rx()); + let ctx = PolicyLocalContext::new( + None, + None, + None, + AgentProposals::new(true), + test_workspace_rx(), + ); // Empty chunk_id after the prefix is 404, not a wildcard list. let (status, _) = route_request(&ctx, "GET", "/v1/proposals/", &[]).await; @@ -1833,11 +1853,11 @@ mod tests { #[tokio::test] async fn proposal_status_route_returns_503_when_no_gateway() { - let _guard = ProposalsFlagGuard::set(true).await; let ctx = PolicyLocalContext::new( None, None, Some("test-sandbox".to_string()), + AgentProposals::new(true), test_workspace_rx(), ); @@ -1848,11 +1868,11 @@ mod tests { #[tokio::test] async fn proposal_wait_route_returns_503_when_no_gateway() { - let _guard = ProposalsFlagGuard::set(true).await; let ctx = PolicyLocalContext::new( None, None, Some("test-sandbox".to_string()), + AgentProposals::new(true), test_workspace_rx(), ); @@ -1864,11 +1884,11 @@ mod tests { #[tokio::test] async fn proposal_routes_return_feature_disabled_when_flag_off() { - let _guard = ProposalsFlagGuard::set(false).await; let ctx = PolicyLocalContext::new( None, None, Some("test-sandbox".to_string()), + AgentProposals::new(false), test_workspace_rx(), ); @@ -1950,6 +1970,7 @@ mod tests { Some(policy_with_rule(proposed.clone())), None, None, + AgentProposals::new(false), test_workspace_rx(), ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); @@ -1977,7 +1998,13 @@ mod tests { version: 1, ..Default::default() }; - let ctx = PolicyLocalContext::new(Some(initial), None, None, test_workspace_rx()); + let ctx = PolicyLocalContext::new( + Some(initial), + None, + None, + AgentProposals::new(false), + test_workspace_rx(), + ); // Concurrently, an unrelated rule lands. We must not return. let unrelated_load = { @@ -2028,6 +2055,7 @@ mod tests { }), None, None, + AgentProposals::new(false), test_workspace_rx(), ); @@ -2066,6 +2094,7 @@ mod tests { }), None, None, + AgentProposals::new(false), test_workspace_rx(), ); let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(300); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 1ec762f1fa..d29296a8f3 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -239,6 +239,9 @@ impl ProxyHandle { host-gateway aliases exempt from SSRF always-blocked check" ); } + let agent_proposals = policy_local_ctx + .as_ref() + .map_or_else(Default::default, |ctx| ctx.agent_proposals()); let join = tokio::spawn(async move { // Wait for the OPA engine's symlink resolution reload to complete @@ -274,6 +277,7 @@ impl ProxyHandle { let tls = tls_state.clone(); let inf = inference_ctx.clone(); let policy_local = policy_local_ctx.clone(); + let proposals = agent_proposals.clone(); let gw = trusted_host_gateway.clone(); let resolver = provider_credentials .as_ref() @@ -295,6 +299,7 @@ impl ProxyHandle { tls, inf, policy_local, + proposals, gw, resolver, dynamic_credentials, @@ -626,6 +631,7 @@ async fn handle_tcp_connection( tls_state: Option>, inference_ctx: Option>, policy_local_ctx: Option>, + agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, secret_resolver: Option>, dynamic_credentials: Option< @@ -694,6 +700,7 @@ async fn handle_tcp_connection( identity_cache, entrypoint_pid, policy_local_ctx, + agent_proposals, trusted_host_gateway, secret_resolver, dynamic_credentials, @@ -1242,6 +1249,7 @@ async fn handle_tcp_connection( token_grant_resolver: dynamic_credentials .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), + agent_proposals, }; if effective_tls_skip { @@ -3542,6 +3550,7 @@ async fn handle_forward_proxy( identity_cache: Arc, entrypoint_pid: Arc, policy_local_ctx: Option>, + agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, secret_resolver: Option>, dynamic_credentials: Option< @@ -3821,6 +3830,7 @@ async fn handle_forward_proxy( token_grant_resolver: dynamic_credentials .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), + agent_proposals, }; let mut l7_activity_pending = false; @@ -5035,6 +5045,7 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { )] mod tests { use super::*; + use openshell_core::proposals::AgentProposals; use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; @@ -5071,6 +5082,7 @@ network_policies: {} None, None, None, + AgentProposals::default(), Arc::new(None), None, None, @@ -5806,9 +5818,9 @@ network_policies: ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, - activity_tx: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), + ..Default::default() }; (ctx, fixture) @@ -9386,14 +9398,15 @@ network_policies: engine, cache, entrypoint_pid, - None, // tls_state — ephemeral CA unavailable - None, // inference_ctx - None, // policy_local_ctx - Arc::new(None), // trusted_host_gateway - None, // secret_resolver - None, // dynamic_credentials - Some(denial_tx), // denial_tx — positive allow/deny signal - None, // activity_tx + None, // tls_state — ephemeral CA unavailable + None, // inference_ctx + None, // policy_local_ctx + AgentProposals::default(), // agent_proposals + Arc::new(None), // trusted_host_gateway + None, // secret_resolver + None, // dynamic_credentials + Some(denial_tx), // denial_tx — positive allow/deny signal + None, // activity_tx )), ) .await diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 9284a55ca1..f8f38a440b 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -26,6 +26,7 @@ use openshell_ocsf::{ use openshell_core::activity::ActivitySender; use openshell_core::denial::DenialEvent; +use openshell_core::proposals::AgentProposals; use tokio::sync::mpsc::UnboundedSender; use crate::identity::BinaryIdentityCache; @@ -86,6 +87,7 @@ pub async fn run_networking( #[allow(unused_variables)] inference_routes: Option<&str>, denial_tx: Option>, activity_tx: Option, + agent_proposals: AgentProposals, workspace_rx: tokio::sync::watch::Receiver, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll @@ -97,6 +99,7 @@ pub async fn run_networking( sandbox_name .map(str::to_string) .or_else(|| sandbox_id.map(str::to_string)), + agent_proposals, workspace_rx, )); diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 5a06d97a8f..3cb29d1e6b 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -24,6 +24,7 @@ use openshell_ocsf::{ #[cfg(target_os = "linux")] use crate::netns::NetworkNamespace; use openshell_core::policy::{NetworkMode, SandboxPolicy}; +use openshell_core::proposals::AgentProposals; use openshell_core::provider_credentials::ProviderCredentialState; #[cfg(target_os = "linux")] @@ -64,6 +65,7 @@ pub async fn run_process( provider_credentials: ProviderCredentialState, provider_env: std::collections::HashMap, ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, + agent_proposals: AgentProposals, #[cfg(target_os = "linux")] netns: Option<&NetworkNamespace>, #[cfg(target_os = "linux")] bypass_denial_tx: Option< tokio::sync::mpsc::UnboundedSender, @@ -99,7 +101,7 @@ pub async fn run_process( // proposals flag is on at startup, rather than waiting for the policy // poll loop's first tick. In offline/file-mode there is no gateway, so // the flag stays at its default (false) and no skill is installed. - install_initial_agent_skill(sandbox_id, openshell_endpoint).await; + install_initial_agent_skill(sandbox_id, openshell_endpoint, &agent_proposals).await; // Install the supervisor seccomp prelude before spawning any workload-side // tasks. By this point the orchestrator has finished privileged startup @@ -416,17 +418,12 @@ fn ssh_proxy_url_for_policy( /// /// Best-effort: any failure (no gateway, RPC error, install failure) is /// logged but does not fail sandbox startup. -async fn install_initial_agent_skill(sandbox_id: Option<&str>, openshell_endpoint: Option<&str>) { +async fn install_initial_agent_skill( + sandbox_id: Option<&str>, + openshell_endpoint: Option<&str>, + agent_proposals: &AgentProposals, +) { use openshell_core::proto::setting_value; - use std::sync::atomic::Ordering; - - let Some(flag) = openshell_core::proposals::AGENT_PROPOSALS_ENABLED.get() else { - // The orchestrator is responsible for setting the OnceLock before - // calling run_process. If it isn't set, behave as if the flag is - // off and skip the install. - tracing::debug!("AGENT_PROPOSALS_ENABLED not initialized; skipping skill install"); - return; - }; if let (Some(id), Some(endpoint)) = (sandbox_id, openshell_endpoint) && let Ok(client) = @@ -443,10 +440,10 @@ async fn install_initial_agent_skill(sandbox_id: Option<&str>, openshell_endpoin _ => None, }) .unwrap_or(false); - flag.store(initial, Ordering::Relaxed); + agent_proposals.set_enabled(initial); } - if openshell_core::proposals::agent_proposals_enabled() { + if agent_proposals.enabled() { match crate::skills::install_static_skills() { Ok(installed) => info!( path = %installed.policy_advisor.display(),