Skip to content
Merged
10 changes: 10 additions & 0 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ be supplied by a gateway-global policy because it is applied at startup. When a
global dynamic policy is active, effective-policy reads retain the UI block from
the sandbox's creation policy.

Live policy updates are also capability-negotiated. A driver that reports
`supports_live_policy_updates = true` must apply every gateway mutation that can
change an existing sandbox's effective dynamic policy. A driver that reports
`false` causes the gateway to reject those mutations before persistence and to
serialize sandbox creation with global policy and provider changes. Rejected
mutations include direct replacement or merge, global policy changes while a
sandbox exists, policy-advisor approval or undo, provider attach or detach, and
updates to a provider profile used by a running sandbox. An omitted capability
retains the legacy `true` behavior for compatibility with existing drivers.

The gateway records driver identity and version from the startup capability
response. Elevated gateway info reports that initialized driver snapshot instead
of re-querying drivers on each request.
Expand Down
41 changes: 38 additions & 3 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5171,16 +5171,50 @@ where
} else {
config.version
};
let (status, active_version) = if policy_source == PolicySource::Global {
// Drivers without live policy updates reject global policy mutations
// while any sandbox exists, so this sandbox necessarily received the
// reported global version at startup. Other drivers retain the
// synchronous global-policy contract. Do not query global history here:
// sandbox policy reads are workspace-readable, while that endpoint is
// platform-admin scoped.
(PolicyStatus::Loaded, version)
Comment thread
prekshivyas marked this conversation as resolved.
} else {
let status_response = client
.get_sandbox_policy_status(GetSandboxPolicyStatusRequest {
name: name.to_string(),
version,
global: false,
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
})
.await
.into_diagnostic()?
.into_inner();
let revision = status_response
.revision
.as_ref()
.ok_or_else(|| miette!("policy version {version} is missing from policy history"))?;
(
PolicyStatus::try_from(revision.status).unwrap_or(PolicyStatus::Unspecified),
status_response.active_version,
)
};

match output {
"json" => {
let mut obj = serde_json::Map::new();
obj.insert("scope".to_string(), serde_json::json!("sandbox"));
obj.insert("sandbox".to_string(), serde_json::json!(name));
obj.insert("version".to_string(), serde_json::json!(version));
obj.insert("active_version".to_string(), serde_json::json!(version));
obj.insert(
"active_version".to_string(),
serde_json::json!(active_version),
);
obj.insert("hash".to_string(), serde_json::json!(config.policy_hash));
obj.insert("status".to_string(), serde_json::json!("effective"));
obj.insert(
"status".to_string(),
serde_json::json!(policy_status_json_name(status)),
);
obj.insert(
"config_revision".to_string(),
serde_json::json!(config.config_revision),
Expand Down Expand Up @@ -5211,8 +5245,9 @@ where
}
"table" => {
writeln!(stdout, "Version: {version}").into_diagnostic()?;
writeln!(stdout, "Active: {active_version}").into_diagnostic()?;
writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?;
writeln!(stdout, "Status: Effective").into_diagnostic()?;
writeln!(stdout, "Status: {status:?}").into_diagnostic()?;
writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?;
writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?;
if config.global_policy_version > 0 {
Expand Down
88 changes: 83 additions & 5 deletions crates/openshell-cli/tests/sandbox_name_fallback_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use openshell_core::proto::{
ServiceStatus, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::{Mutex, mpsc};
Expand All @@ -38,6 +39,7 @@ use tonic::{Response, Status};
#[derive(Clone, Default)]
struct SandboxState {
last_get_name: Arc<Mutex<Option<String>>>,
global_policy: Arc<AtomicBool>,
}

#[derive(Clone, Default)]
Expand Down Expand Up @@ -191,6 +193,7 @@ impl OpenShell for TestOpenShell {
req.sandbox_id, "test-id",
"GetSandboxConfig should pass the id from GetSandbox"
);
let global_policy = self.state.global_policy.load(Ordering::Relaxed);
Ok(Response::new(GetSandboxConfigResponse {
policy: Some(SandboxPolicy {
version: 9,
Expand Down Expand Up @@ -233,7 +236,12 @@ impl OpenShell for TestOpenShell {
version: 9,
policy_hash: "sha256:effective-policy".to_string(),
config_revision: 42,
policy_source: openshell_core::proto::PolicySource::Sandbox.into(),
policy_source: if global_policy {
openshell_core::proto::PolicySource::Global.into()
} else {
openshell_core::proto::PolicySource::Sandbox.into()
},
global_policy_version: if global_policy { 4 } else { 0 },
..Default::default()
}))
}
Expand Down Expand Up @@ -460,9 +468,22 @@ impl OpenShell for TestOpenShell {
) -> Result<Response<GetSandboxPolicyStatusResponse>, Status> {
let req = request.into_inner();
assert_eq!(req.name, "my-sandbox");
assert_eq!(req.version, 3);
assert!(!req.global);

if req.version == 9 {
return Ok(Response::new(GetSandboxPolicyStatusResponse {
revision: Some(SandboxPolicyRevision {
version: 9,
policy_hash: "sha256:effective-policy".to_string(),
status: PolicyStatus::Pending.into(),
..Default::default()
}),
active_version: 7,
}));
}

assert_eq!(req.version, 3);

let policy = SandboxPolicy {
version: 7,
network_policies: std::iter::once((
Expand Down Expand Up @@ -843,9 +864,9 @@ async fn policy_get_full_json_cli_prints_policy_payload() {
assert_eq!(json["scope"], "sandbox");
assert_eq!(json["sandbox"], "my-sandbox");
assert_eq!(json["version"], 9);
assert_eq!(json["active_version"], 9);
assert_eq!(json["active_version"], 7);
assert_eq!(json["hash"], "sha256:effective-policy");
assert_eq!(json["status"], "effective");
assert_eq!(json["status"], "pending");
assert_eq!(json["config_revision"], 42);
assert_eq!(json["policy_source"], "sandbox");
assert_eq!(
Expand Down Expand Up @@ -891,7 +912,7 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() {
serde_json::from_slice(&stdout).expect("stdout should be valid JSON");
assert_eq!(json["scope"], "sandbox");
assert_eq!(json["sandbox"], "my-sandbox");
assert_eq!(json["status"], "effective");
assert_eq!(json["status"], "pending");
assert!(
json["policy"]["network_policies"]
.get("_provider_api")
Expand All @@ -904,6 +925,63 @@ async fn policy_get_base_json_cli_prints_round_trippable_policy_payload() {
);
}

#[tokio::test]
async fn policy_get_latest_table_reports_persisted_status_and_active_version() {
let ts = run_server().await;
let mut stdout = Vec::new();
let mut stderr = Vec::new();

run::sandbox_policy_get_to_writer(
&ts.endpoint,
"my-sandbox",
0,
run::PolicyGetView::Metadata,
"table",
"default",
&ts.tls,
(&mut stdout, &mut stderr),
)
.await
.expect("policy get should succeed");

assert!(stderr.is_empty());
let output = String::from_utf8(stdout).expect("table output should be UTF-8");
assert!(output.contains("Version: 9"), "{output}");
assert!(output.contains("Active: 7"), "{output}");
assert!(output.contains("Status: Pending"), "{output}");
}

#[tokio::test]
async fn policy_get_global_effective_policy_remains_workspace_readable() {
let ts = run_server().await;
ts.openshell
.state
.global_policy
.store(true, Ordering::Relaxed);
let mut stdout = Vec::new();
let mut stderr = Vec::new();

run::sandbox_policy_get_to_writer(
&ts.endpoint,
"my-sandbox",
0,
run::PolicyGetView::Metadata,
"json",
"default",
&ts.tls,
(&mut stdout, &mut stderr),
)
.await
.expect("workspace-readable global effective policy should not query admin-only history");

assert!(stderr.is_empty());
let json: serde_json::Value = serde_json::from_slice(&stdout).expect("valid JSON");
assert_eq!(json["version"], 4);
assert_eq!(json["active_version"], 4);
assert_eq!(json["status"], "loaded");
assert_eq!(json["policy_source"], "global");
}

#[tokio::test]
async fn policy_get_explicit_revision_uses_stored_policy_status() {
let ts = run_server().await;
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ impl DockerComputeDriver {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-kubernetes/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ impl KubernetesComputeDriver {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
})
}

Expand Down
13 changes: 13 additions & 0 deletions crates/openshell-driver-mxc/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ impl MxcComputeBackend {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: self.config.backend == MxcBackend::ProcessContainer,
supports_live_policy_updates: Some(false),
}
}

Expand Down Expand Up @@ -2424,12 +2425,24 @@ mod lifecycle_tests {
fn ui_policy_capability_tracks_configured_backend() {
let process_container = MxcComputeBackend::new_mocked(MxcComputeConfig::default());
assert!(process_container.capabilities().supports_ui_policy);
assert_eq!(
process_container
.capabilities()
.supports_live_policy_updates,
Some(false)
);

let isolation_session = MxcComputeBackend::new_mocked(MxcComputeConfig {
backend: MxcBackend::IsolationSession,
..Default::default()
});
assert!(!isolation_session.capabilities().supports_ui_policy);
assert_eq!(
isolation_session
.capabilities()
.supports_live_policy_updates,
Some(false)
);
}

fn driver_sandbox_with_command(id: &str, cwd: &str, command: Vec<String>) -> DriverSandbox {
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-podman/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ impl PodmanComputeDriver {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-vm/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ impl VmDriver {
.into_owned(),
rootfs_tar_max_bytes: self.config.rootfs_tar_max_bytes(),
supports_ui_policy: false,
supports_live_policy_updates: None,
}
}

Expand Down
25 changes: 24 additions & 1 deletion crates/openshell-server/src/compute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,9 @@ pub struct ComputeDriverInfoSnapshot {
/// Whether this configured driver instance completely enforces the portable
/// UI policy contract.
pub supports_ui_policy: bool,
/// Whether this driver can apply policy changes after sandbox creation.
/// `None` preserves the behavior of older external drivers.
pub supports_live_policy_updates: Option<bool>,
}

/// Interval between store-vs-backend reconciliation sweeps.
Expand Down Expand Up @@ -743,6 +746,7 @@ impl ComputeRuntime {
rootfs_tar_staging_dir: capabilities.rootfs_tar_staging_dir,
rootfs_tar_max_bytes: capabilities.rootfs_tar_max_bytes,
supports_ui_policy: capabilities.supports_ui_policy,
supports_live_policy_updates: capabilities.supports_live_policy_updates,
};
let default_image = capabilities.default_image;
let gateway_listener_requirements = match driver
Expand Down Expand Up @@ -924,6 +928,15 @@ impl ComputeRuntime {
self.driver_info.supports_sandbox_authentication
}

/// Whether operator-authored policy updates can reach an already-created
/// sandbox. Unspecified preserves compatibility with older drivers.
#[must_use]
pub(crate) fn supports_live_policy_updates(&self) -> bool {
self.driver_info
.supports_live_policy_updates
.unwrap_or(true)
}

pub(crate) async fn authenticate_sandbox(&self, credential: &str) -> Result<String, Status> {
if !self.supports_sandbox_authentication() {
return Err(Status::unimplemented(
Expand Down Expand Up @@ -5001,6 +5014,7 @@ impl ComputeDriver for NoopTestDriver {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
},
))
}
Expand Down Expand Up @@ -5126,7 +5140,12 @@ pub async fn new_test_runtime(store: Arc<Store>) -> ComputeRuntime {

#[cfg(test)]
pub async fn new_test_runtime_for_driver(store: Arc<Store>, driver_name: &str) -> ComputeRuntime {
new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await
let mut runtime =
new_test_runtime_with_driver(store, driver_name, Arc::new(NoopTestDriver::default())).await;
if driver_name == "mxc" {
runtime.driver_info.supports_live_policy_updates = Some(false);
}
runtime
}

#[cfg(test)]
Expand All @@ -5149,6 +5168,7 @@ pub async fn new_test_runtime_with_driver(
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
},
telemetry_compute_driver: TelemetryComputeDriver::custom(),
driver_process: None,
Expand Down Expand Up @@ -5473,6 +5493,7 @@ mod tests {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
}))
}

Expand Down Expand Up @@ -5839,6 +5860,7 @@ mod tests {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
}))
}

Expand Down Expand Up @@ -6053,6 +6075,7 @@ mod tests {
rootfs_tar_staging_dir: String::new(),
rootfs_tar_max_bytes: 0,
supports_ui_policy: false,
supports_live_policy_updates: None,
},
telemetry_compute_driver: TelemetryComputeDriver::custom(),
driver_process: None,
Expand Down
Loading
Loading