From c05e2fa16540d4bb79c34e9887de4d9adf8d8ae5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 12 Oct 2026 15:52:10 +0300 Subject: [PATCH 1/2] feat(sandbox): add trusted binding startup gate Foundation for task 34.01.03; resource-side prepare/bind/retire remains unimplemented and warm allocation remains disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3ed4c100-7879-4cbd-a298-d6b0f6aadeba Signed-off-by: Your Name --- Cargo.lock | 1 + architecture/sandbox.md | 19 ++ crates/openshell-sandbox/Cargo.toml | 2 +- crates/openshell-sandbox/src/binding_gate.rs | 276 ++++++++++++++++++ crates/openshell-sandbox/src/lib.rs | 2 + crates/openshell-sandbox/src/main.rs | 4 + .../src/process.rs | 2 + 7 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 crates/openshell-sandbox/src/binding_gate.rs diff --git a/Cargo.lock b/Cargo.lock index 011730760d..5baf7e0a61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7877,6 +7877,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/architecture/sandbox.md b/architecture/sandbox.md index fde042b6af..a4f72e4a2d 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -35,6 +35,25 @@ only when the set is already empty; any other outcome fails the spawn. 6. It opens a supervisor session back to the gateway for connect, exec, file sync, config polling, and log push. +### Trusted binding startup gate (experimental) + +On Linux, the internal `await-binding` command can hold supervisor startup +behind a read-only projected, private root-owned binding grant. The grant +must match the prepared slot, generation, pod UID and sandbox ID and carry +a nonempty allocation ID. No supervisor, tenant credential acquisition or +workload child starts while the grant is absent. Invalid grants fail closed; +waiting is bounded. The accepted grant supplies the exact main-process argv +and serialized workload environment before the gate replaces itself with +the normal supervisor. Workload environment is applied to the child, not +the privileged supervisor's environment. + +The gate is not an allocator or authorization API. Its resource owner must +first commit single-use ownership with resource-side fencing, initialize +the exclusive workspace with one writer, and publish the grant only after +policy, credentials and ordinary sandbox ownership are established. +Preparation, binding and retirement APIs are not implemented by this gate; +it does not enable a warm pool or prove retained-resource customer readiness. + ## Isolation Layers OpenShell uses overlapping controls rather than a single sandbox primitive: diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index c653db84dd..e610fbbacc 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -50,7 +50,7 @@ prost = { workspace = true } # Logging tracing = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["serde"] } tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } diff --git a/crates/openshell-sandbox/src/binding_gate.rs b/crates/openshell-sandbox/src/binding_gate.rs new file mode 100644 index 0000000000..08705d8fe5 --- /dev/null +++ b/crates/openshell-sandbox/src/binding_gate.rs @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Trusted, pre-supervisor startup gate. The resource owner must publish a grant +//! only after its durable, single-use resource binding has committed. + +use clap::Parser; +use miette::{IntoDiagnostic, Result}; +use openshell_core::sandbox_env::{self, MainProcessConfig}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +pub const SUBCOMMAND: &str = "await-binding"; +pub const GRANT_VERSION: u32 = 1; +const MAX_GRANT_BYTES: u64 = 1024 * 1024; +const MAX_COMMAND_ARGS: usize = 128; +const MAX_ARGUMENT_BYTES: usize = 4096; +const MAX_ENVIRONMENT_ENTRIES: usize = 256; +const MAX_WAIT_SECONDS: u64 = 86400; + +#[derive(Parser)] +struct GateArgs { + #[arg(long)] + slot_id: Uuid, + #[arg(long)] + generation: u64, + #[arg(long)] + pod_uid: Uuid, + #[arg(long)] + sandbox_id: Uuid, + /// Root-owned, owner-readable-only file on a read-only projected volume. + #[arg(long)] + grant: PathBuf, + #[arg(long, default_value_t = MAX_WAIT_SECONDS)] + wait_seconds: u64, + /// Normal supervisor flags; no workload command override. + #[arg(last = true)] + supervisor_args: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BoundLaunchGrant { + pub version: u32, + pub slot_id: Uuid, + pub generation: u64, + pub pod_uid: Uuid, + pub sandbox_id: Uuid, + pub allocation_id: Uuid, + pub main_process: MainProcessConfig, + pub environment: HashMap, +} + +impl BoundLaunchGrant { + fn validate(&self, expected: &GateArgs) -> Result<()> { + if self.version != GRANT_VERSION + || self.slot_id != expected.slot_id + || self.generation != expected.generation + || self.pod_uid != expected.pod_uid + || self.sandbox_id != expected.sandbox_id + || self.allocation_id.is_nil() + { + return Err(miette::miette!( + "binding grant identity or version mismatch" + )); + } + if self.main_process.version != MainProcessConfig::VERSION + || self.main_process.command.is_empty() + || self.main_process.command.len() > MAX_COMMAND_ARGS + || self.main_process.command[0].is_empty() + || self + .main_process + .command + .iter() + .any(|arg| arg.len() > MAX_ARGUMENT_BYTES || arg.contains('\0')) + { + return Err(miette::miette!("invalid bound main process")); + } + if self.environment.len() > MAX_ENVIRONMENT_ENTRIES + || self + .environment + .iter() + .any(|(key, value)| !valid_environment_key(key) || value.contains('\0')) + { + return Err(miette::miette!("invalid bound workload environment")); + } + Ok(()) + } +} + +fn valid_environment_key(key: &str) -> bool { + if key.is_empty() || key.starts_with("OPENSHELL_") { + return false; + } + let mut bytes = key.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + (first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn read_grant(path: &Path, expected: &GateArgs) -> Result> { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let file = match std::fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NONBLOCK) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).into_diagnostic(), + }; + let metadata = file.metadata().into_diagnostic()?; + // Follow the projected volume's atomic symlink, then check the opened inode. + if !metadata.is_file() + || metadata.uid() != 0 + || metadata.mode() & 0o7777 != 0o400 + || metadata.len() > MAX_GRANT_BYTES + { + return Err(miette::miette!( + "binding grant must be a private root-owned file" + )); + } + let mut bytes = Vec::new(); + file.take(MAX_GRANT_BYTES + 1) + .read_to_end(&mut bytes) + .into_diagnostic()?; + if bytes.len() as u64 > MAX_GRANT_BYTES { + return Err(miette::miette!("binding grant exceeds size limit")); + } + let grant: BoundLaunchGrant = serde_json::from_slice(&bytes) + .map_err(|_| miette::miette!("invalid binding grant document"))?; + grant.validate(expected)?; + Ok(Some(grant)) +} + +/// Wait without starting a supervisor, acquiring tenant credentials, or launching +/// workload processes; then replace the gate with the normal supervisor. +pub fn run(arguments: &[String]) -> Result<()> { + use std::os::unix::process::CommandExt; + + let args = GateArgs::try_parse_from( + std::iter::once(SUBCOMMAND.to_string()).chain(arguments.iter().cloned()), + ) + .into_diagnostic()?; + if !nix::unistd::geteuid().is_root() + || !args.grant.is_absolute() + || args.slot_id.is_nil() + || args.pod_uid.is_nil() + || args.sandbox_id.is_nil() + || args.generation == 0 + || args.wait_seconds == 0 + || args.wait_seconds > MAX_WAIT_SECONDS + { + return Err(miette::miette!( + "invalid trusted binding gate configuration" + )); + } + let deadline = Instant::now() + Duration::from_secs(args.wait_seconds); + eprintln!("Waiting for committed workload binding"); + loop { + if Instant::now() >= deadline { + return Err(miette::miette!("workload binding deadline exceeded")); + } + if let Some(grant) = read_grant(&args.grant, &args)? { + let mut command = + std::process::Command::new(std::env::current_exe().into_diagnostic()?); + command + .args(&args.supervisor_args) + .env(sandbox_env::SANDBOX_ID, grant.sandbox_id.to_string()) + .env( + sandbox_env::MAIN_PROCESS_SPEC, + serde_json::to_string(&grant.main_process).into_diagnostic()?, + ) + .env( + sandbox_env::USER_ENVIRONMENT, + serde_json::to_string(&grant.environment).into_diagnostic()?, + ); + return Err(command.exec()).into_diagnostic(); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn expected() -> GateArgs { + GateArgs { + slot_id: Uuid::from_u128(1), + generation: 3, + pod_uid: Uuid::from_u128(2), + sandbox_id: Uuid::from_u128(3), + grant: PathBuf::from("/run/openshell-binding/grant.json"), + wait_seconds: 60, + supervisor_args: Vec::new(), + } + } + + fn grant() -> BoundLaunchGrant { + let expected = expected(); + BoundLaunchGrant { + version: GRANT_VERSION, + slot_id: expected.slot_id, + generation: expected.generation, + pod_uid: expected.pod_uid, + sandbox_id: expected.sandbox_id, + allocation_id: Uuid::from_u128(4), + main_process: MainProcessConfig { + version: MainProcessConfig::VERSION, + command: vec!["/app/agent".to_string(), "argument with spaces".to_string()], + tty: false, + }, + environment: HashMap::from([("AGENT_ID".to_string(), "tenant-agent".to_string())]), + } + } + + #[test] + fn accepts_exact_binding_and_lossless_process_configuration() { + let original = grant(); + let bytes = serde_json::to_vec(&original).unwrap(); + let decoded: BoundLaunchGrant = serde_json::from_slice(&bytes).unwrap(); + decoded.validate(&expected()).unwrap(); + assert_eq!(decoded.main_process, original.main_process); + assert_eq!(decoded.environment, original.environment); + } + + #[test] + fn rejects_stale_or_different_resources_and_missing_allocation() { + let changes: [fn(&mut BoundLaunchGrant); 6] = [ + |grant: &mut BoundLaunchGrant| grant.slot_id = Uuid::from_u128(9), + |grant: &mut BoundLaunchGrant| grant.generation += 1, + |grant: &mut BoundLaunchGrant| grant.pod_uid = Uuid::from_u128(9), + |grant: &mut BoundLaunchGrant| grant.sandbox_id = Uuid::from_u128(9), + |grant: &mut BoundLaunchGrant| grant.allocation_id = Uuid::nil(), + |grant: &mut BoundLaunchGrant| grant.version += 1, + ]; + for change in changes { + let mut candidate = grant(); + change(&mut candidate); + assert!(candidate.validate(&expected()).is_err()); + } + } + + #[test] + fn rejects_supervisor_environment_override_and_invalid_process() { + for key in [ + "OPENSHELL_SANDBOX_TOKEN", + "OPENSHELL_MAIN_PROCESS_SPEC", + "A=B", + "", + ] { + let mut candidate = grant(); + candidate + .environment + .insert(key.to_string(), "value".to_string()); + assert!(candidate.validate(&expected()).is_err()); + } + for command in [ + vec![], + vec![String::new()], + vec!["/app/agent\0".to_string()], + ] { + let mut candidate = grant(); + candidate.main_process.command = command; + assert!(candidate.validate(&expected()).is_err()); + } + } +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 6b7ea8c3aa..48a9bc9198 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -6,6 +6,8 @@ //! This crate provides process sandboxing and monitoring capabilities. mod activity_aggregator; +#[cfg(target_os = "linux")] +pub mod binding_gate; mod denial_aggregator; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod google_cloud_metadata; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 0ed8d9a086..2ffacf6b71 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -520,6 +520,10 @@ fn main() -> Result<()> { // sandbox flags. Kubernetes init containers invoke this path to seed an // emptyDir volume that the agent container then executes from. let raw_args: Vec = std::env::args().collect(); + #[cfg(target_os = "linux")] + if raw_args.get(1).map(String::as_str) == Some(openshell_sandbox::binding_gate::SUBCOMMAND) { + return openshell_sandbox::binding_gate::run(&raw_args[2..]); + } if raw_args.get(1).map(String::as_str) == Some(COPY_SELF_SUBCOMMAND) { let dest = raw_args.get(2).ok_or_else(|| { miette::miette!("usage: openshell-sandbox {COPY_SELF_SUBCOMMAND} ") diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index a186991990..2934e60f7b 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -782,6 +782,7 @@ impl ProcessHandle { // supervisor credentials from its inherited environment. strip_supervisor_only_env(&mut cmd); + inject_provider_env(&mut cmd, &configured_user_environment()); inject_provider_env(&mut cmd, provider_env); apply_canonical_process_environment( &mut cmd, @@ -978,6 +979,7 @@ impl ProcessHandle { // inherited environment. strip_supervisor_only_env(&mut cmd); + inject_provider_env(&mut cmd, &configured_user_environment()); inject_provider_env(&mut cmd, provider_env); apply_canonical_process_environment( &mut cmd, From 739914740727bb6951dcdd816b487f97ace443c5 Mon Sep 17 00:00:00 2001 From: Aviv Kabesa Date: Sun, 25 Oct 2026 13:42:55 +0200 Subject: [PATCH 2/2] feat(prewarm): implement single-use retained-pod lifecycle Publish native prepare/observe/bind/retire with durable one-shot CREATE tickets, resource-side allocation fencing, exact retained identities and held-init single-writer initialization. Preserve ordinary tenant lifecycle and fail closed on changed native profiles or unsupported assistant-v2 and staging sidecar contracts. Keep the capability disabled and document acceptance gaps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78c54bc7-92e0-4d2a-9d48-3a49da0cf391 Signed-off-by: Aviv Kabesa --- .../skills/debug-openshell-cluster/SKILL.md | 20 + Cargo.lock | 8 + architecture/compute-runtimes.md | 100 + architecture/sandbox.md | 80 +- .../tests/ensure_providers_integration.rs | 28 + .../openshell-cli/tests/mtls_integration.rs | 28 + .../tests/provider_commands_integration.rs | 28 + .../sandbox_create_lifecycle_integration.rs | 28 + .../sandbox_name_fallback_integration.rs | 28 + crates/openshell-core/src/driver_utils.rs | 8 + .../openshell-driver-kubernetes/src/config.rs | 22 + .../openshell-driver-kubernetes/src/driver.rs | 113 +- .../openshell-driver-kubernetes/src/main.rs | 1 + crates/openshell-sandbox/Cargo.toml | 1 + crates/openshell-sandbox/src/binding_gate.rs | 84 +- .../src/binding_workspace.rs | 905 ++++++++ crates/openshell-sandbox/src/lib.rs | 2 + crates/openshell-sandbox/src/main.rs | 10 + crates/openshell-sdk/tests/client_mock.rs | 28 + crates/openshell-server/Cargo.toml | 3 +- .../src/auth/descriptor_authz.rs | 16 + crates/openshell-server/src/compute/mod.rs | 127 +- crates/openshell-server/src/grpc/mod.rs | 29 + crates/openshell-server/src/grpc/policy.rs | 11 + crates/openshell-server/src/grpc/prewarm.rs | 178 ++ .../src/grpc/prewarm/native.rs | 2052 +++++++++++++++++ .../src/grpc/prewarm/native/resource_tests.rs | 452 ++++ .../src/grpc/prewarm/native/tenant.rs | 983 ++++++++ crates/openshell-server/src/grpc/sandbox.rs | 36 +- crates/openshell-server/tests/common/mod.rs | 28 + .../tests/supervisor_relay_integration.rs | 28 + docs/reference/gateway-config.mdx | 53 + docs/reference/sandbox-compute-drivers.mdx | 37 + proto/openshell.proto | 47 + sdk/go/proto/openshellv1/openshell.pb.go | 604 +++-- sdk/go/proto/openshellv1/openshell_grpc.pb.go | 158 ++ 36 files changed, 6103 insertions(+), 261 deletions(-) create mode 100644 crates/openshell-sandbox/src/binding_workspace.rs create mode 100644 crates/openshell-server/src/grpc/prewarm.rs create mode 100644 crates/openshell-server/src/grpc/prewarm/native.rs create mode 100644 crates/openshell-server/src/grpc/prewarm/native/resource_tests.rs create mode 100644 crates/openshell-server/src/grpc/prewarm/native/tenant.rs diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 4c72179f58..d3117ef2c0 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -476,6 +476,26 @@ kubectl -n openshell get configmap openshell-config -o jsonpath='{.data.gateway\ kubectl -n get sandbox -o jsonpath='{.spec.template.spec.serviceAccountName}{"\n"}' ``` +For the experimental native prewarm RPCs, check the explicit Kubernetes +`prewarm` approval section first. New preparation/binding stay disabled without +it; ordinary cold creation is unaffected. The supported native profile requires +shared namespace, combined topology, explicit UID/GID 1000, digest-pinned +agent/recipe/supervisor images and no provider SPIFFE mount. The recipe requires +image-volume support. A changed native driver profile cannot be adopted into an +existing receipt. Restore its original profile rather than recreating resources. +Indeterminate CREATE tickets remain quarantined until the original resource is +discovered; do not delete permanent fence records or report absence from a timeout. +See `docs/reference/gateway-config.mdx` for additional platform-only RBAC. + +The staging sidecar profile is not native-prewarm compatible: its operator +bootstrap, dual-supervisor release and `fsGroup`-affected Secret permissions need +a reviewed private-grant handoff. Never remove `fsGroup`, weaken root-only grants +or switch staging topology as a debugging workaround. Native v1's legacy agent +launch also is not current assistant acceptance. Reserved assistant schema +version 2 returns `UNIMPLEMENTED` before tenant or resource mutation. Do not +substitute a legacy recipe to bypass it. Keep all customer pools disabled +until the actual artifact/profile/storage and joined customer journey pass. + If `topology = "sidecar"` is rendered under `[openshell.drivers.kubernetes]`, sandbox pods should have an `openshell-network-init` init container running `--mode=network-init`, an `agent` container running diff --git a/Cargo.lock b/Cargo.lock index 5baf7e0a61..9736dc1028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4293,6 +4293,7 @@ dependencies = [ "rustls 0.23.38", "serde", "serde_json", + "sha2 0.10.9", "temp-env", "tempfile", "tokio", @@ -4398,6 +4399,7 @@ dependencies = [ "rustix 1.1.4", "rustls 0.23.38", "rustls-pemfile", + "ryu-js", "serde", "serde_json", "sha2 0.10.9", @@ -6077,6 +6079,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "salsa20" version = "0.11.0" diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 9212eba25b..0548689163 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -19,6 +19,101 @@ Each runtime receives a sandbox spec from the gateway and is responsible for: - Reporting lifecycle and platform events back to the gateway. - Cleaning up runtime-owned resources. +### Native prewarm status + +`PreparePrewarmSlot`, `ObservePrewarmSlot`, `BindPrewarmSlot` and +`RetirePrewarmSlot` provide the opt-in native prewarm v1 transport on `openshell.v1.OpenShell`. +They use `PrewarmSlotRequest.request_json` and +`PrewarmSlotResponse.evidence_json`. The request is marked secret in protobuf +metadata. Requests are bounded to 1 MiB of UTF-8, and the evidence limit is +also 1 MiB. All four methods require a user/service platform-admin principal; +observation requires `sandbox:read`, and mutations require `sandbox:write` when +scope enforcement is enabled. Sandbox callbacks cannot invoke them. + +Only the in-tree Kubernetes backend implements these methods. New preparation +and binding require an explicit approved immutable-artifact configuration. +Disabled/other backends return `UNIMPLEMENTED`; unsupported placement, topology, +artifacts and initial power states never fall back to cold creation. + +The warming boundary is **held init**, not an already-running supervisor. Prepare +creates a real suspended AgentSandbox CR, one direct Pod and a blank exclusive +RWO PVC. Its receipt contains their actual Kubernetes UIDs. There is no ordinary +tenant Sandbox row, organization or credential in the idle runtime. The inert +init container proves the claim is blank and waits for an identity-bound grant. +Ordinary SA/mTLS mounts remain unavailable to that init container. + +A permanent generic database reservation grants one attempt to create the +permanent fencing ConfigMap. ConfigMap resource-version CAS, not a process-local +lock, linearizes allocation versus retirement. Every CREATE has a durable, +single-attempt ticket. Retries discover the original resource and validate its +UID/ownership; they never replay an issued CREATE. An ambiguous transport outcome, +including a crash after claiming a ticket but before sending the request, is +**quarantined** until that original creation becomes discoverable. There is no +timeout that converts uncertainty into absence. Definitive API rejection permits +retirement, but not recreation. Permanent reservations and ConfigMaps are never +garbage-collected by these methods. + +Bind commits one allocation, stamps Pod/PVC organization ownership, and uses the +ordinary workspace, provider-profile, credential-driver, policy and Sandbox +validation/persistence paths with the reserved workload UUID. Credentials do not +cross into privileged supervisor environment variables. One writer initializes +the retained PVC before the main container's persistence subPath is resolved; +see [Sandbox](sandbox.md). Only a matching successful init termination receipt +permits adoption by the original Sandbox CR and publication of the startup grant. +Observe can resume this handoff without replaying credential writes or requiring +the original request secrets. Exact-allocation retries retain the original IDs. +Before allocation CAS, all three resource UIDs must still match and none may be +terminating. Reservations also freeze a hash of the native driver configuration. +Preparation, binding and a pending adoption handoff reject profile drift rather +than reinterpreting retained resources with a different supervisor, bootstrap, +identity or storage configuration. Approval settings are checked separately; +disabling new preparation does not invalidate retirement. + +Unused-slot retirement fences future creation first, settles outstanding +creation tickets, removes the controller and Pod, observes compute absence, and +only then removes storage. Allocated slots cannot return to inventory; ordinary +tenant lifecycle owns them after handoff. A missing or changed retained resource +is not a replacement warm hit. Partial or indeterminate provisioning produces an +explicit error, never success-shaped absence. + +This implementation and native resource-fixture checks are not approved-artifact +or customer readiness evidence. Bound results require a real supervisor session, +loaded policy, current providers, and the agent's authenticated `/health/ready` +probe before advertising a runtime endpoint. Do not enable a customer pool until +the maintained agent/recipe/supervisor artifacts and actual create/open journey +have been verified. + +Native v1 currently binds the gateway's legacy agent launch contract, not the +current assistant lifecycle. The historical gateway adapter supplied this draft +transport contract; it is not evidence of a current gateway consumer, allocator +or product integration. Schema version 2 is reserved for the incompatible +no-recipe assistant contract and returns `UNIMPLEMENTED` before any identity, +resource or tenant mutation. Supporting it requires the ordinary assistant +command, data-directory initialization and readiness mapping, not a fabricated +legacy recipe. A current-assistant consumer cannot enable allocation until that +native mapping and joined customer behavior are verified. + +The replacement gateway contract is the separate draft +`globster-monday/globster-workload-gateway#250` at +`bec528d52f978204f4acedd78a811823dae8e1a8`, not a published provider capability. +Its `src/domain/prewarm-slots.ts` and `openapi/workload-gateway.v1.json` own the +recipe-free assistant v2 contract; `docs/prewarm-native-contract.md` records the +native handoff requirements. Preparation includes explicit immutable release +`serviceVersion` and uses the `globster/prewarm-preparation/v2\0` hash domain. +The native regression checks that settled golden vector and rejects its v2 +identity. It does not implement assistant initialization or approve an artifact. + +The supported native profile is shared-namespace, combined topology with +explicit UID/GID 1000 and no provider SPIFFE mount. It is not compatible with the +checked-in staging sidecar profile. That profile retains an operator-owned +process bootstrap, separate network supervisor and pod `fsGroup`; its Secret +projection permissions do not satisfy the gate's strict root-only mode-`0400` +contract. Supporting it requires a reviewed private-grant transport and ordered +dual-supervisor handoff that preserves the bootstrap, identity drop and original +Pod/PVC. Do not remove `fsGroup`, relax grant permissions or switch staging to +combined topology as a workaround. No compatible immutable lifecycle artifact +or actual staging storage acceptance is established by this implementation. + Drivers report **backend state only**. A driver snapshot with `Ready=True` means the underlying compute resource (container, pod, VM) is healthy and running — nothing more. Drivers must not gate on supervisor session state or hold @@ -155,6 +250,11 @@ compute to zero, and VM retains its launch request and writable overlay beside a stop marker. Delete remains a separate operation that removes these resources. +Kubernetes ignores conditions whose `observedGeneration` predates the current +Sandbox generation. A retained `Suspended` condition must not override readiness +after adoption or restart; older controllers without generation fields retain +their existing condition behavior. + On graceful gateway shutdown, persisted running intent for Docker, Podman, and VM is stopped through the shared `StopSandbox` RPC before any gateway-managed driver process exits. The gateway does not persist `Stopped` for this diff --git a/architecture/sandbox.md b/architecture/sandbox.md index a4f72e4a2d..859ad45cd5 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -47,12 +47,82 @@ and serialized workload environment before the gate replaces itself with the normal supervisor. Workload environment is applied to the child, not the privileged supervisor's environment. +The gate can initialize the retained pod's workspace before supervisor exec. +With `--require-workspace-initialization`, a grant without +`workspace_initialization` is rejected. The plan contains `recipe_id`, +`recipe_digest` and optional `initial_configuration_json`. The latter carries +the gateway's exact canonical UTF-8 bytes, not a reserialized JSON value. +The gate checks the complete configuration shape and its 64 KiB limit without +logging its contents. Grants and initialization plans deliberately have no +`Debug` implementation. + +For this path, the resource owner must supply: + +- An approved immutable recipe projected read-only at `/recipe/template`. + The initializer verifies the read-only filesystem; it does not authenticate + the image or infer its identity from files. +- The exclusive writable claim at `/workspace`, writable by UID/GID 1000. + No other pod or sidecar may write this claim during initialization. +- A separate pod-private writable state volume at + `/run/openshell-binding-state`, owned by root and not group- or world-writable. + The gate creates a mode-`0700` `private` child directory. The state volume + must not be the customer claim or the read-only grant projection. + +The root gate holds a nonblocking exclusive writer lock in the private directory. +It starts its own fixed-function `initialize-bound-workspace` subprocess with +UID/GID 1000, an empty environment and a bounded stdin plan. No shell, recipe +script or tenant executable runs. The initializer accepts regular files and +directories, rejects symlinks, special files and reserved state, and copies into +a private staging directory. It creates the `workspace` and +`.globster-openshell-persist` directories, writes the private initial configuration, +and publishes `/` using a Linux no-replace rename after syncing +files and directories. Copying is bounded to 100,000 entries, 1 GiB and 64 levels. +Recipes outside those limits are unsupported and fail closed. + +The `.globster-recipe.json` marker matches the cold materializer's field names, +including the SHA-256 digest of the exact initial-configuration bytes. A retry +accepts only the same recipe and configuration marker, preserves existing tenant +data, and can discard an abandoned private staging directory. It never adopts an +unmarked workspace or overwrites a different marker. The pod-private lock spans +initialization and supervisor exec. After the child succeeds, the gate atomically +publishes `private/initialized.json`, containing the exact binding identity +and recipe marker but no configuration or credentials. A receipt for another +allocation is rejected. Initialization failure or deadline expiry prevents +supervisor startup. The receipt proves initialization only, not a live supervisor +session or customer readiness. + The gate is not an allocator or authorization API. Its resource owner must -first commit single-use ownership with resource-side fencing, initialize -the exclusive workspace with one writer, and publish the grant only after -policy, credentials and ordinary sandbox ownership are established. -Preparation, binding and retirement APIs are not implemented by this gate; -it does not enable a warm pool or prove retained-resource customer readiness. +commit single-use ownership with resource-side fencing and establish policy, +credentials and ordinary sandbox ownership before publishing the grant. +The pod-private writer lock does not fence competing gateway replicas or prevent +another Kubernetes pod from mounting an RWO claim. RWO alone is not a one-writer +guarantee. + +The opt-in native Kubernetes lifecycle uses this gate in a **held init container** +and again as the main container's startup gate. Preparation verifies a blank +claim (allowing only an empty filesystem-created `lost+found`) and publishes a +root-private prepared receipt in the Pod's emptyDir. It does not create tenant +directories or start the privileged supervisor. The init container has only the +binary, read-only recipe, blank PVC, private state and optional private init grant; +it does not mount the ordinary service-account token or mTLS credentials. + +At bind, the same init container makes the blank mount writable by UID/GID 1000, +runs the fixed unprivileged writer, and exits with a sanitized identity- and +grant-digest-bound termination receipt. The main gate accepts the same private +initialized receipt and does not write the PVC a second time. Native allocation +is independently fenced by permanent DB/Kubernetes records; the pod-local lock +is only the sequential writer guard. See [Compute Runtimes](compute-runtimes.md) +for creation quarantine, adoption and retirement. + +The ordinary Kubernetes launch mounts +`/.globster-openshell-persist` at `/sandbox/persist` using a PVC +`subPath`. Kubelet resolves it before starting the main container, but **after** +the held init completes. Only the init container mounts the root claim during +initialization; it does not request that not-yet-existing subPath. This preserves +the exact ordinary launch paths without changing Pod/PVC identities or adding +another writer Pod. A restarted/changed physical Pod cannot consume the original +UID-bound grant. The init gate's wait is bounded to 24 hours; expired or failed +idle capacity must be retired, never restarted/recycled as a fresh slot. ## Isolation Layers diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 8192989375..b178b837bc 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -81,6 +81,34 @@ impl TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 91d520d1af..0ab4e3d23c 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -34,6 +34,34 @@ struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 2c71e0b39f..aa28a5e551 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -99,6 +99,34 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index bcc07619ee..d4a15c4d6d 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -61,6 +61,34 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 7e2cf74f50..1c857ad738 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -49,6 +49,34 @@ struct TestOpenShell { #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index b37b5b6821..fab8460531 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -33,6 +33,14 @@ pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; /// Label carrying the gateway identity on managed namespaces. pub const LABEL_GATEWAY_ID: &str = "openshell.ai/gateway-id"; +/// A native retained-pod binding must finish before ordinary lifecycle mutations. +pub const ANNOTATION_BINDING_PENDING: &str = "openshell.io/binding-pending"; +/// Marks an exclusively reserved external claim, including before allocation. +pub const LABEL_PREWARM_SLOT_ID: &str = "globster.dev/prewarm-slot-id"; +pub const LABEL_PREWARM_WORKLOAD_ID: &str = "globster.dev/workload-id"; +/// Ordinary driver reuse of a bound claim is restricted to its actual workspace. +pub const ANNOTATION_BOUND_WORKSPACE: &str = "openshell.io/bound-workspace"; + /// Label selector that matches all OpenShell-managed resources which carry a /// sandbox ID label. Used by list and watch operations to exclude foreign /// resources from the same namespace. diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index 9b1ac8a880..09e6d3e2c2 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -408,6 +408,27 @@ pub struct KubernetesComputeConfig { /// When empty and `sandbox_uid` is set, defaults to the resolved UID. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_gid: Option, + /// Explicit platform approval for held-init prewarm artifacts and placement. + /// Absent by default; ordinary sandbox creation is unaffected. + pub prewarm: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrewarmConfig { + /// Disable new preparation/binding without disabling reserved-claim protection. + #[serde(default = "prewarm_enabled")] + pub enabled: bool, + pub provider_artifact: String, + pub placement_key: String, + pub image: String, + pub recipe_id: String, + pub recipe_digest: String, + pub recipe_image: String, +} + +fn prewarm_enabled() -> bool { + true } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -495,6 +516,7 @@ impl Default for KubernetesComputeConfig { provider_spiffe_workload_api_socket_path: String::new(), sandbox_uid: None, sandbox_gid: None, + prewarm: None, } } } diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index c63611aab5..3f3d4006b8 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -1618,7 +1618,11 @@ impl KubernetesComputeDriver { } #[allow(clippy::similar_names)] - async fn create_sandbox_inner(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { + /// Render the ordinary driver contract without creating compute. + pub async fn render_sandbox( + &self, + sandbox: &Sandbox, + ) -> Result { let gpu_requirements = sandbox .spec .as_ref() @@ -1744,6 +1748,65 @@ impl KubernetesComputeDriver { }; obj.data = data; + Ok(obj) + } + + pub fn prewarm_context(&self) -> (Client, &KubernetesComputeConfig) { + (self.client.clone(), &self.config) + } + + async fn create_sandbox_inner(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { + let obj = self.render_sandbox(sandbox).await?; + let name = sandbox.name.as_str(); + let namespace = obj + .metadata + .namespace + .as_deref() + .unwrap_or(&self.config.namespace); + if let Some(volumes) = obj.data["spec"]["podTemplate"]["spec"]["volumes"].as_array() { + for volume in volumes { + let Some(claim) = volume["persistentVolumeClaim"]["claimName"].as_str() else { + continue; + }; + let api: Api = + Api::namespaced(self.client.clone(), namespace); + if let Some(pvc) = api + .get_opt(claim) + .await + .map_err(KubernetesDriverError::from_kube)? + && pvc.metadata.labels.as_ref().is_some_and(|l| { + l.contains_key(openshell_core::driver_utils::LABEL_PREWARM_SLOT_ID) + }) + && (pvc + .metadata + .annotations + .as_ref() + .and_then(|a| { + a.get(openshell_core::driver_utils::ANNOTATION_BOUND_WORKSPACE) + }) + .map(String::as_str) + != Some(sandbox.workspace.as_str()) + || pvc + .metadata + .labels + .as_ref() + .and_then(|l| { + l.get(openshell_core::driver_utils::LABEL_PREWARM_WORKLOAD_ID) + }) + .map(String::as_str) + != Some(sandbox.id.as_str())) + { + return Err(KubernetesDriverError::Precondition( + "reserved native prewarm claim cannot be attached by an ordinary create" + .into(), + )); + } + } + } + let agent_sandbox_api = self + .supported_agent_sandbox_api(self.client.clone(), namespace) + .await + .map_err(KubernetesDriverError::Message)?; match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api.api.create(&PostParams::default(), &obj), @@ -4572,6 +4635,7 @@ fn status_from_object(obj: &DynamicObject) -> Option { .map(|items| { items .iter() + .filter(|condition| condition_is_current(obj, condition)) .filter_map(condition_from_value) .collect::>() }) @@ -4603,6 +4667,14 @@ fn status_from_object(obj: &DynamicObject) -> Option { }) } +fn condition_is_current(obj: &DynamicObject, condition: &serde_json::Value) -> bool { + condition + .get("observedGeneration") + .and_then(serde_json::Value::as_i64) + .zip(obj.metadata.generation) + .is_none_or(|(observed, current)| observed >= current) +} + fn kubernetes_sandbox_has_stopped_condition(obj: &DynamicObject) -> bool { obj.data .get("status") @@ -4610,8 +4682,9 @@ fn kubernetes_sandbox_has_stopped_condition(obj: &DynamicObject) -> bool { .and_then(serde_json::Value::as_array) .is_some_and(|conditions| { conditions.iter().any(|condition| { - condition.get("type").and_then(serde_json::Value::as_str) - == Some(SANDBOX_SUSPENDED_CONDITION) + condition_is_current(obj, condition) + && condition.get("type").and_then(serde_json::Value::as_str) + == Some(SANDBOX_SUSPENDED_CONDITION) && condition .get("status") .and_then(serde_json::Value::as_str) @@ -4627,8 +4700,9 @@ fn kubernetes_sandbox_stop_failure(obj: &DynamicObject) -> Option { .as_array()? .iter() .find_map(|condition| { - let is_terminal = condition.get("type").and_then(serde_json::Value::as_str) - == Some(SANDBOX_SUSPENDED_CONDITION) + let is_terminal = condition_is_current(obj, condition) + && condition.get("type").and_then(serde_json::Value::as_str) + == Some(SANDBOX_SUSPENDED_CONDITION) && condition .get("status") .and_then(serde_json::Value::as_str) @@ -5164,6 +5238,35 @@ mod tests { assert!(kubernetes_sandbox_has_stopped_condition(&sandbox)); } + #[test] + fn stale_suspension_cannot_override_current_readiness() { + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + SANDBOX_GROUP, + SANDBOX_VERSION_V1BETA1, + SANDBOX_KIND, + )); + let mut sandbox = DynamicObject::new("sandbox", &resource); + sandbox.metadata.generation = Some(3); + sandbox.data = serde_json::json!({"status": {"conditions": [ + {"type":"Suspended","status":"True","observedGeneration":1}, + {"type":"Ready","status":"True","observedGeneration":3} + ]}}); + let status = status_from_object(&sandbox).unwrap(); + assert_eq!(status.conditions.len(), 1); + assert_eq!(status.conditions[0].r#type, "Ready"); + assert!(!kubernetes_sandbox_has_stopped_condition(&sandbox)); + + sandbox.data["status"]["conditions"][0]["observedGeneration"] = serde_json::json!(3); + assert!(kubernetes_sandbox_has_stopped_condition(&sandbox)); + + sandbox.data["status"]["conditions"][0] = serde_json::json!({ + "type":"Suspended","status":"False","reason":"PodNotOwned","observedGeneration":1 + }); + assert!(kubernetes_sandbox_stop_failure(&sandbox).is_none()); + sandbox.data["status"]["conditions"][0]["observedGeneration"] = serde_json::json!(3); + assert!(kubernetes_sandbox_stop_failure(&sandbox).is_some()); + } + #[test] fn stop_failure_only_rejects_terminal_suspension_condition() { let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index 0d1a13d8c6..f2fa7491ad 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -257,6 +257,7 @@ async fn main() -> Result<()> { let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); let driver = KubernetesComputeDriver::new( KubernetesComputeConfig { + prewarm: None, process_bootstrap: Vec::new(), process_bootstraps: std::collections::HashMap::new(), workspace_mode: args.workspace_mode, diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index e610fbbacc..b0c201fff6 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -46,6 +46,7 @@ rustls = { workspace = true } # Serialization (serde_json::json! for OCSF unmapped fields) serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } prost = { workspace = true } # Logging diff --git a/crates/openshell-sandbox/src/binding_gate.rs b/crates/openshell-sandbox/src/binding_gate.rs index 08705d8fe5..6553775c8e 100644 --- a/crates/openshell-sandbox/src/binding_gate.rs +++ b/crates/openshell-sandbox/src/binding_gate.rs @@ -14,6 +14,8 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use uuid::Uuid; +use crate::binding_workspace::{self, WorkspaceInitialization}; + pub const SUBCOMMAND: &str = "await-binding"; pub const GRANT_VERSION: u32 = 1; const MAX_GRANT_BYTES: u64 = 1024 * 1024; @@ -37,12 +39,19 @@ struct GateArgs { grant: PathBuf, #[arg(long, default_value_t = MAX_WAIT_SECONDS)] wait_seconds: u64, + /// Reject grants without retained-pod workspace initialization. + #[arg(long)] + require_workspace_initialization: bool, + /// Held init-container mode. Initialize once, publish a sanitized Kubernetes + /// termination receipt and exit without ever constructing a supervisor. + #[arg(long)] + initialize_only: bool, /// Normal supervisor flags; no workload command override. #[arg(last = true)] supervisor_args: Vec, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct BoundLaunchGrant { pub version: u32, @@ -53,6 +62,8 @@ pub struct BoundLaunchGrant { pub allocation_id: Uuid, pub main_process: MainProcessConfig, pub environment: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_initialization: Option, } impl BoundLaunchGrant { @@ -88,6 +99,14 @@ impl BoundLaunchGrant { { return Err(miette::miette!("invalid bound workload environment")); } + if expected.require_workspace_initialization && self.workspace_initialization.is_none() { + return Err(miette::miette!( + "bound workspace initialization is required" + )); + } + if let Some(initialization) = &self.workspace_initialization { + initialization.validate()?; + } Ok(()) } } @@ -163,12 +182,51 @@ pub fn run(arguments: &[String]) -> Result<()> { )); } let deadline = Instant::now() + Duration::from_secs(args.wait_seconds); + if args.initialize_only { + binding_workspace::verify_prepared_blank(&serde_json::json!({ + "slotId":args.slot_id,"generation":args.generation,"podUid":args.pod_uid,"sandboxId":args.sandbox_id, + }))?; + eprintln!( + "OpenShell held-init ready slot={} generation={} pod={} sandbox={}", + args.slot_id, args.generation, args.pod_uid, args.sandbox_id + ); + } eprintln!("Waiting for committed workload binding"); loop { if Instant::now() >= deadline { return Err(miette::miette!("workload binding deadline exceeded")); } if let Some(grant) = read_grant(&args.grant, &args)? { + if args.initialize_only { + binding_workspace::prepare_blank_claim()?; + } + // Hold the pod-private lock until exec closes it. The initializer + // finishes as UID/GID 1000 before the privileged supervisor exists. + let _writer = binding_workspace::initialize_retained_pod(&grant, deadline)?; + if args.initialize_only { + use sha2::{Digest, Sha256}; + let canonical = + serde_json::to_vec(&serde_json::to_value(&grant).into_diagnostic()?) + .into_diagnostic()?; + let receipt = serde_json::json!({ + "version": 1, + "slotId": grant.slot_id, + "generation": grant.generation, + "podUid": grant.pod_uid, + "sandboxId": grant.sandbox_id, + "allocationId": grant.allocation_id, + "grantHash": format!("{:x}", Sha256::digest(canonical)), + }); + std::fs::write( + "/dev/termination-log", + serde_json::to_vec(&receipt).into_diagnostic()?, + ) + .into_diagnostic()?; + return Ok(()); + } + if Instant::now() >= deadline { + return Err(miette::miette!("workload binding deadline exceeded")); + } let mut command = std::process::Command::new(std::env::current_exe().into_diagnostic()?); command @@ -200,6 +258,8 @@ mod tests { sandbox_id: Uuid::from_u128(3), grant: PathBuf::from("/run/openshell-binding/grant.json"), wait_seconds: 60, + require_workspace_initialization: false, + initialize_only: false, supervisor_args: Vec::new(), } } @@ -219,6 +279,7 @@ mod tests { tty: false, }, environment: HashMap::from([("AGENT_ID".to_string(), "tenant-agent".to_string())]), + workspace_initialization: None, } } @@ -273,4 +334,25 @@ mod tests { assert!(candidate.validate(&expected()).is_err()); } } + + #[test] + fn required_initialization_cannot_be_omitted_or_supply_malformed_configuration() { + let mut expected = expected(); + expected.require_workspace_initialization = true; + let mut candidate = grant(); + assert!(candidate.validate(&expected).is_err()); + candidate.workspace_initialization = Some(WorkspaceInitialization { + recipe_id: "base-agent".into(), + recipe_digest: format!("sha256:{}", "a".repeat(64)), + initial_configuration_json: None, + }); + candidate.validate(&expected).unwrap(); + candidate + .workspace_initialization + .as_mut() + .unwrap() + .initial_configuration_json = Some("secret-malformed-configuration".into()); + let error = candidate.validate(&expected).unwrap_err(); + assert!(!format!("{error:?}").contains("secret-malformed-configuration")); + } } diff --git a/crates/openshell-sandbox/src/binding_workspace.rs b/crates/openshell-sandbox/src/binding_workspace.rs new file mode 100644 index 0000000000..35a065415e --- /dev/null +++ b/crates/openshell-sandbox/src/binding_workspace.rs @@ -0,0 +1,905 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Retained-pod workspace initialization, before any supervisor or tenant process. +//! +//! The root gate holds the pod-private writer lock. This subprocess drops to the +//! workload identity and receives its bounded plan on stdin, never in environment +//! variables or process arguments. It does not execute recipe-provided code. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +use miette::{IntoDiagnostic, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::binding_gate::BoundLaunchGrant; + +pub const SUBCOMMAND: &str = "initialize-bound-workspace"; +pub const WORKLOAD_UID: u32 = 1000; +pub const WORKLOAD_GID: u32 = 1000; +pub(crate) const MAX_PLAN_BYTES: u64 = 1024 * 1024; +pub(crate) const RECIPE_PATH: &str = "/recipe/template"; +pub(crate) const WORKSPACE_PATH: &str = "/workspace"; +const MARKER_NAME: &str = ".globster-recipe.json"; +const CONFIG_NAME: &str = "initial-plugin-config.json"; +const STORAGE_DIRECTORY: &str = "workspace"; +const PERSISTENCE_DIRECTORY: &str = ".globster-openshell-persist"; +const MAX_CONFIGURATION_BYTES: usize = 65_536; +const MAX_RECIPE_ENTRIES: u64 = 100_000; +const MAX_RECIPE_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_RECIPE_DEPTH: usize = 64; +const STATE_MOUNT: &str = "/run/openshell-binding-state"; +const STATE_DIRECTORY: &str = "private"; +const RECEIPT_FILE: &str = "initialized.json"; +const PENDING_RECEIPT_FILE: &str = "initialized.pending"; + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct InitializationReceipt { + version: u32, + slot_id: Uuid, + generation: u64, + pod_uid: Uuid, + sandbox_id: Uuid, + allocation_id: Uuid, + recipe: RecipeMarker, +} + +/// Trusted owner-supplied recipe intent. Paths and workload UID are not inputs. +/// +/// `initial_configuration_json` is the gateway's canonical UTF-8 serialization. +/// Preserve those exact bytes so its marker digest agrees with cold creation, +/// including JSON number formatting. Do not round-trip it through serde. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceInitialization { + pub recipe_id: String, + pub recipe_digest: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_configuration_json: Option, +} + +// This type deliberately does not implement Debug: configuration can be secret. + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct InitializationPlan { + pub workload_id: Uuid, + pub initialization: WorkspaceInitialization, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RecipeMarker { + pub schema_version: u32, + pub id: String, + pub digest: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_configuration_digest: Option, +} + +impl WorkspaceInitialization { + pub(crate) fn validate(&self) -> Result<()> { + if self.recipe_id.is_empty() + || self.recipe_id.len() > 253 + || self.recipe_id.chars().any(char::is_control) + || !valid_digest(&self.recipe_digest) + { + return Err(miette::miette!("invalid bound recipe identity")); + } + if let Some(json) = &self.initial_configuration_json { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Configuration { + schema_version: u32, + plugins: std::collections::BTreeMap< + String, + std::collections::BTreeMap, + >, + } + if json.len() > MAX_CONFIGURATION_BYTES { + return Err(miette::miette!("bound initial configuration exceeds limit")); + } + let configuration: Configuration = serde_json::from_str(json) + .map_err(|_| miette::miette!("invalid bound initial configuration"))?; + if configuration.schema_version != 1 { + return Err(miette::miette!("invalid bound initial configuration")); + } + // Deserialize the complete shape, not merely schemaVersion. + let _ = configuration.plugins; + } + Ok(()) + } + + pub(crate) fn marker(&self) -> RecipeMarker { + RecipeMarker { + schema_version: 1, + id: self.recipe_id.clone(), + digest: self.recipe_digest.clone(), + initial_configuration_digest: self + .initial_configuration_json + .as_ref() + .map(|json| format!("{:x}", Sha256::digest(json.as_bytes()))), + } + } +} + +fn valid_digest(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +#[allow(unsafe_code)] // Linux flock, with a borrowed live File descriptor. +fn lock_writer(directory: &Path) -> Result { + use std::os::fd::AsRawFd; + use std::os::unix::fs::MetadataExt; + + ensure_private_directory(directory)?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK) + .open(directory.join("writer.lock")) + .into_diagnostic()?; + let metadata = lock.metadata().into_diagnostic()?; + if !metadata.is_file() + || metadata.uid() != nix::unistd::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != 0o600 + { + return Err(miette::miette!("bound workspace writer lock is unsafe")); + } + // SAFETY: flock borrows a live file descriptor and has no pointer arguments. + if unsafe { nix::libc::flock(lock.as_raw_fd(), nix::libc::LOCK_EX | nix::libc::LOCK_NB) } != 0 { + return Err(miette::miette!("bound workspace writer is already active")); + } + Ok(lock) +} + +#[allow(unsafe_code)] // Linux statvfs; no portable std equivalent exposes mount flags. +fn require_read_only_recipe_mount() -> Result<()> { + let path = std::ffi::CString::new(RECIPE_PATH).into_diagnostic()?; + let mut stat = std::mem::MaybeUninit::::uninit(); + // SAFETY: the path is NUL-terminated and stat points to valid writable space. + if unsafe { nix::libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) } != 0 { + return Err(miette::miette!("bound recipe mount is unavailable")); + } + // SAFETY: successful statvfs initialized the output above. + let stat = unsafe { stat.assume_init() }; + if stat.f_flag & nix::libc::ST_RDONLY == 0 { + return Err(miette::miette!("bound recipe mount must be read-only")); + } + Ok(()) +} + +fn read_receipt(path: &Path) -> Result> { + use std::os::unix::fs::MetadataExt; + + let file = match OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).into_diagnostic(), + }; + let metadata = file.metadata().into_diagnostic()?; + if !metadata.is_file() + || metadata.uid() != 0 + || metadata.permissions().mode() & 0o7777 != 0o600 + || metadata.len() > MAX_PLAN_BYTES + { + return Err(miette::miette!("bound initialization receipt is unsafe")); + } + let mut bytes = Vec::new(); + file.take(MAX_PLAN_BYTES + 1) + .read_to_end(&mut bytes) + .into_diagnostic()?; + if bytes.len() as u64 > MAX_PLAN_BYTES { + return Err(miette::miette!( + "bound initialization receipt exceeds limit" + )); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| miette::miette!("invalid bound initialization receipt")) +} + +/// CSI filesystems initially belong to root. Change only the blank mount root, +/// after allocation, before the sole unprivileged writer. Never recursively +/// chown an existing workspace or seed a directory during prepare. +pub(crate) fn prepare_blank_claim() -> Result<()> { + use std::os::unix::fs::MetadataExt; + let path = Path::new(WORKSPACE_PATH); + let metadata = fs::symlink_metadata(path).into_diagnostic()?; + if !metadata.is_dir() { + return Err(miette::miette!("bound workspace must be a directory")); + } + if metadata.uid() == WORKLOAD_UID && metadata.gid() == WORKLOAD_GID { + return Ok(()); + } + if metadata.uid() != 0 || !blank_claim(path)? { + return Err(miette::miette!("prepared workspace is not blank")); + } + nix::unistd::chown( + path, + Some(nix::unistd::Uid::from_raw(WORKLOAD_UID)), + Some(nix::unistd::Gid::from_raw(WORKLOAD_GID)), + ) + .into_diagnostic()?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).into_diagnostic() +} + +fn blank_claim(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + for entry in fs::read_dir(path).into_diagnostic()? { + let entry = entry.into_diagnostic()?; + let metadata = fs::symlink_metadata(entry.path()).into_diagnostic()?; + // A newly formatted ext filesystem may contain its empty recovery + // directory. It is filesystem metadata, never a recipe or tenant seed. + if entry.file_name() != "lost+found" + || !metadata.is_dir() + || metadata.uid() != 0 + || fs::read_dir(entry.path()) + .into_diagnostic()? + .next() + .is_some() + { + return Ok(false); + } + } + Ok(true) +} + +pub(crate) fn verify_prepared_blank(identity: &serde_json::Value) -> Result<()> { + use std::os::unix::fs::MetadataExt; + let state = Path::new(STATE_MOUNT); + let metadata = fs::symlink_metadata(state).into_diagnostic()?; + if !metadata.is_dir() || metadata.uid() != 0 { + return Err(miette::miette!("prepared binding state is unsafe")); + } + // Kubelet creates emptyDir with mode 0777. Only the inert root init can + // establish this private directory, and only while it is completely empty. + if metadata.mode() & 0o022 != 0 { + if fs::read_dir(state).into_diagnostic()?.next().is_some() { + return Err(miette::miette!( + "nonprivate prepared binding state is not empty" + )); + } + fs::set_permissions(state, fs::Permissions::from_mode(0o700)).into_diagnostic()?; + sync_directory(state)?; + } + let directory = state.join(STATE_DIRECTORY); + let _lock = lock_writer(&directory)?; + let path = directory.join("blank.json"); + let bytes = serde_json::to_vec(identity).into_diagnostic()?; + if path.exists() { + let file = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK) + .open(path) + .into_diagnostic()?; + let metadata = file.metadata().into_diagnostic()?; + if !metadata.is_file() + || metadata.uid() != 0 + || metadata.mode() & 0o7777 != 0o600 + || metadata.len() > 4096 + { + return Err(miette::miette!("prepared blank receipt is unsafe")); + } + let mut actual = Vec::new(); + file.take(4097).read_to_end(&mut actual).into_diagnostic()?; + if actual != bytes { + return Err(miette::miette!("prepared blank receipt identity mismatch")); + } + return Ok(()); + } + if !blank_claim(Path::new(WORKSPACE_PATH))? { + return Err(miette::miette!("prepared workspace contains existing data")); + } + write_private_file(&path, &bytes)?; + sync_directory(&directory) +} + +/// Return the exclusive writer lock after successful initialization, to keep it +/// held through the root gate's exec into the supervisor. This is a pod-local +/// single-writer guarantee, not a resource-side allocation or retirement fence. +pub(crate) fn initialize_retained_pod( + grant: &BoundLaunchGrant, + deadline: std::time::Instant, +) -> Result> { + use std::os::unix::fs::MetadataExt; + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let Some(initialization) = &grant.workspace_initialization else { + return Ok(None); + }; + if !nix::unistd::geteuid().is_root() { + return Err(miette::miette!( + "bound initialization requires the root gate" + )); + } + initialization.validate()?; + require_read_only_recipe_mount()?; + let state_mount = Path::new(STATE_MOUNT); + let metadata = fs::symlink_metadata(state_mount).into_diagnostic()?; + if !metadata.is_dir() || metadata.uid() != 0 || metadata.permissions().mode() & 0o022 != 0 { + return Err(miette::miette!( + "bound initialization state mount is unsafe" + )); + } + let state_directory = state_mount.join(STATE_DIRECTORY); + let writer = lock_writer(&state_directory)?; + let receipt = InitializationReceipt { + version: 1, + slot_id: grant.slot_id, + generation: grant.generation, + pod_uid: grant.pod_uid, + sandbox_id: grant.sandbox_id, + allocation_id: grant.allocation_id, + recipe: initialization.marker(), + }; + let existing = read_receipt(&state_directory.join(RECEIPT_FILE))?; + if existing + .as_ref() + .is_some_and(|existing| existing != &receipt) + { + return Err(miette::miette!( + "bound initialization receipt does not match allocation" + )); + } + let plan = serde_json::to_vec(&InitializationPlan { + workload_id: grant.sandbox_id, + initialization: initialization.clone(), + }) + .into_diagnostic()?; + if plan.len() as u64 > MAX_PLAN_BYTES { + return Err(miette::miette!("bound workspace plan exceeds limit")); + } + let mut child = Command::new(std::env::current_exe().into_diagnostic()?) + .arg(SUBCOMMAND) + .env_clear() + .current_dir("/") + .gid(WORKLOAD_GID) + .uid(WORKLOAD_UID) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .into_diagnostic()?; + // Do not leave a detached initializer if feeding stdin fails. + let write_result = child + .stdin + .take() + .ok_or_else(|| miette::miette!("bound workspace initializer input is unavailable")) + .and_then(|mut stdin| stdin.write_all(&plan).into_diagnostic()); + if write_result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + return Err(miette::miette!("bound workspace initialization failed")); + } + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(Some(_)) => return Err(miette::miette!("bound workspace initialization failed")), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(miette::miette!("bound workspace initialization failed")); + } + Ok(None) => {} + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(miette::miette!( + "bound workspace initialization deadline exceeded" + )); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + if std::time::Instant::now() >= deadline { + return Err(miette::miette!( + "bound workspace initialization deadline exceeded" + )); + } + if existing.is_none() { + let pending = state_directory.join(PENDING_RECEIPT_FILE); + // A killed gate may have left its own incomplete receipt, never tenant + // state. Validate its inode before removing it under the writer lock. + match fs::symlink_metadata(&pending) { + Ok(metadata) if metadata.is_file() => { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != 0 || metadata.permissions().mode() & 0o7777 != 0o600 { + return Err(miette::miette!("pending initialization receipt is unsafe")); + } + fs::remove_file(&pending).into_diagnostic()?; + } + Ok(_) => return Err(miette::miette!("pending initialization receipt is unsafe")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).into_diagnostic(), + } + write_private_file(&pending, &serde_json::to_vec(&receipt).into_diagnostic()?)?; + publish(&pending, &state_directory.join(RECEIPT_FILE))?; + sync_directory(&state_directory)?; + } + Ok(Some(writer)) +} + +/// Internal child entrypoint. The root gate is the only supported orchestrator. +pub fn run() -> Result<()> { + if nix::unistd::geteuid().as_raw() != WORKLOAD_UID + || nix::unistd::getegid().as_raw() != WORKLOAD_GID + { + return Err(miette::miette!( + "bound workspace initializer requires the workload identity" + )); + } + let mut bytes = Vec::new(); + std::io::stdin() + .take(MAX_PLAN_BYTES + 1) + .read_to_end(&mut bytes) + .into_diagnostic()?; + if bytes.len() as u64 > MAX_PLAN_BYTES { + return Err(miette::miette!("bound workspace plan exceeds limit")); + } + let plan: InitializationPlan = serde_json::from_slice(&bytes) + .map_err(|_| miette::miette!("invalid bound workspace plan"))?; + initialize(&plan, Path::new(RECIPE_PATH), Path::new(WORKSPACE_PATH)) +} + +fn ensure_directory(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path).into_diagnostic()?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(miette::miette!("bound workspace directory is unsafe")); + } + Ok(()) +} + +fn create_private_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .mode(0o700) + .create(path) + .into_diagnostic() +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + if !path.try_exists().into_diagnostic()? { + create_private_directory(path)?; + } + ensure_directory(path)?; + let metadata = fs::symlink_metadata(path).into_diagnostic()?; + if metadata.uid() != nix::unistd::geteuid().as_raw() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.permissions().mode() & 0o700 != 0o700 + { + return Err(miette::miette!( + "bound workspace directory permissions are unsafe" + )); + } + Ok(()) +} + +fn ensure_workload_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + if !path.try_exists().into_diagnostic()? { + create_private_directory(path)?; + } + ensure_directory(path)?; + let metadata = fs::symlink_metadata(path).into_diagnostic()?; + if metadata.uid() != nix::unistd::geteuid().as_raw() + || metadata.permissions().mode() & 0o300 != 0o300 + { + return Err(miette::miette!("bound workload directory is not writable")); + } + Ok(()) +} + +fn ensure_persistence_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + ensure_workload_directory(path)?; + let metadata = fs::symlink_metadata(path).into_diagnostic()?; + let mode = metadata.permissions().mode(); + if mode & 0o007 != 0 || (mode & 0o070 != 0 && metadata.gid() != nix::unistd::getegid().as_raw()) + { + return Err(miette::miette!( + "bound persistence directory permissions are unsafe" + )); + } + Ok(()) +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .into_diagnostic()?; + file.write_all(bytes).into_diagnostic()?; + file.sync_all().into_diagnostic() +} + +fn sync_directory(path: &Path) -> Result<()> { + File::open(path) + .into_diagnostic()? + .sync_all() + .into_diagnostic() +} + +/// Use Linux's no-replace rename so an unexpected existing destination can never +/// be overwritten, even if it is an empty directory. +#[allow(unsafe_code)] // Linux no-replace rename is the publication safety boundary. +fn publish(staging: &Path, destination: &Path) -> Result<()> { + let staging = std::ffi::CString::new(staging.as_os_str().as_bytes()).into_diagnostic()?; + let destination = + std::ffi::CString::new(destination.as_os_str().as_bytes()).into_diagnostic()?; + // SAFETY: both paths are live NUL-terminated strings; no borrowed FDs. + let result = unsafe { + nix::libc::renameat2( + nix::libc::AT_FDCWD, + staging.as_ptr(), + nix::libc::AT_FDCWD, + destination.as_ptr(), + nix::libc::RENAME_NOREPLACE, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()).into_diagnostic(); + } + Ok(()) +} + +#[derive(Default)] +struct CopyBudget { + entries: u64, + bytes: u64, +} + +fn copy_tree( + source: &Path, + destination: &Path, + budget: &mut CopyBudget, + depth: usize, +) -> Result<()> { + if depth > MAX_RECIPE_DEPTH { + return Err(miette::miette!("bound recipe exceeds nesting limit")); + } + ensure_directory(source)?; + create_private_directory(destination)?; + for entry in fs::read_dir(source).into_diagnostic()? { + let entry = entry.into_diagnostic()?; + let from = entry.path(); + let to = destination.join(entry.file_name()); + let metadata = fs::symlink_metadata(&from).into_diagnostic()?; + budget.entries += 1; + budget.bytes = budget.bytes.saturating_add(metadata.len()); + if budget.entries > MAX_RECIPE_ENTRIES || budget.bytes > MAX_RECIPE_BYTES { + return Err(miette::miette!("bound recipe exceeds copy limit")); + } + if metadata.is_dir() { + copy_tree(&from, &to, budget, depth + 1)?; + } else if metadata.is_file() { + // Source is the approved read-only recipe mount, never a tenant path. + let mut input = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK) + .open(&from) + .into_diagnostic()?; + let mode = 0o600 | (metadata.permissions().mode() & 0o100); + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(&to) + .into_diagnostic()?; + let copied = std::io::copy(&mut input, &mut output).into_diagnostic()?; + if copied != metadata.len() { + return Err(miette::miette!("bound recipe changed during copy")); + } + output.sync_all().into_diagnostic()?; + } else { + return Err(miette::miette!( + "bound recipe contains a link or special file" + )); + } + } + sync_directory(destination) +} + +fn read_marker(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + + let file = OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW | nix::libc::O_NONBLOCK) + .open(path) + .into_diagnostic()?; + let metadata = file.metadata().into_diagnostic()?; + if !metadata.is_file() + || metadata.uid() != nix::unistd::geteuid().as_raw() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.len() > MAX_PLAN_BYTES + { + return Err(miette::miette!("bound recipe marker is unsafe")); + } + let mut bytes = Vec::new(); + file.take(MAX_PLAN_BYTES + 1) + .read_to_end(&mut bytes) + .into_diagnostic()?; + if bytes.len() as u64 > MAX_PLAN_BYTES { + return Err(miette::miette!("bound recipe marker exceeds limit")); + } + serde_json::from_slice(&bytes).map_err(|_| miette::miette!("invalid bound recipe marker")) +} + +fn initialize(plan: &InitializationPlan, source: &Path, workspace: &Path) -> Result<()> { + plan.initialization.validate()?; + if plan.workload_id.is_nil() { + return Err(miette::miette!("invalid bound workspace identity")); + } + ensure_directory(workspace)?; + ensure_directory(source)?; + for reserved in [PERSISTENCE_DIRECTORY, MARKER_NAME, CONFIG_NAME] { + if fs::symlink_metadata(source.join(reserved)).is_ok() { + return Err(miette::miette!("bound recipe contains reserved state")); + } + } + let destination = workspace.join(plan.workload_id.to_string()); + let expected = plan.initialization.marker(); + match fs::symlink_metadata(&destination) { + Ok(_) => { + ensure_workload_directory(&destination)?; + if read_marker(&destination.join(MARKER_NAME))? != expected { + return Err(miette::miette!("bound workspace contains different state")); + } + ensure_workload_directory(&destination.join(STORAGE_DIRECTORY))?; + ensure_persistence_directory(&destination.join(PERSISTENCE_DIRECTORY))?; + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).into_diagnostic(), + } + + let staging = workspace.join(format!(".globster-recipe-{}", plan.workload_id)); + match fs::symlink_metadata(&staging) { + Ok(_) => { + ensure_private_directory(&staging)?; + fs::remove_dir_all(&staging).into_diagnostic()?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).into_diagnostic(), + } + // A crash leaves only this private staging directory. The published + // destination is never repaired or overwritten when its marker differs. + copy_tree(source, &staging, &mut CopyBudget::default(), 0)?; + ensure_workload_directory(&staging.join(STORAGE_DIRECTORY))?; + ensure_persistence_directory(&staging.join(PERSISTENCE_DIRECTORY))?; + if let Some(configuration) = &plan.initialization.initial_configuration_json { + write_private_file( + &staging.join(CONFIG_NAME), + format!("{configuration}\n").as_bytes(), + )?; + } + let marker = serde_json::to_vec(&expected).into_diagnostic()?; + write_private_file(&staging.join(MARKER_NAME), &marker)?; + sync_directory(&staging)?; + publish(&staging, &destination)?; + sync_directory(workspace) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + fn fixture() -> (tempfile::TempDir, InitializationPlan) { + let root = tempfile::Builder::new() + .prefix(".binding-workspace-test-") + .tempdir_in(".") + .unwrap(); + fs::create_dir(root.path().join("recipe")).unwrap(); + fs::create_dir(root.path().join("workspace")).unwrap(); + fs::write(root.path().join("recipe/template.txt"), "approved recipe").unwrap(); + let plan = InitializationPlan { + workload_id: Uuid::from_u128(7), + initialization: WorkspaceInitialization { + recipe_id: "base-agent".into(), + recipe_digest: format!("sha256:{}", "a".repeat(64)), + initial_configuration_json: Some( + r#"{"plugins":{"example":{"n":1e+21}},"schemaVersion":1}"#.into(), + ), + }, + }; + (root, plan) + } + + fn apply(root: &Path, plan: &InitializationPlan) -> Result<()> { + initialize(plan, &root.join("recipe"), &root.join("workspace")) + } + + #[test] + fn initializes_once_with_exact_configuration_bytes_and_cold_compatible_marker() { + let (root, plan) = fixture(); + apply(root.path(), &plan).unwrap(); + let destination = root + .path() + .join("workspace") + .join(plan.workload_id.to_string()); + assert_eq!( + fs::read_to_string(destination.join("template.txt")).unwrap(), + "approved recipe" + ); + let configuration = plan + .initialization + .initial_configuration_json + .as_ref() + .unwrap(); + assert_eq!( + fs::read_to_string(destination.join(CONFIG_NAME)).unwrap(), + format!("{configuration}\n") + ); + let marker: serde_json::Value = + serde_json::from_slice(&fs::read(destination.join(MARKER_NAME)).unwrap()).unwrap(); + assert_eq!(marker["schemaVersion"], 1); + assert_eq!(marker["id"], "base-agent"); + assert_eq!( + marker["initialConfigurationDigest"], + format!("{:x}", Sha256::digest(configuration.as_bytes())) + ); + fs::write(destination.join("template.txt"), "tenant data").unwrap(); + apply(root.path(), &plan).unwrap(); + assert_eq!( + fs::read_to_string(destination.join("template.txt")).unwrap(), + "tenant data" + ); + for name in [STORAGE_DIRECTORY, PERSISTENCE_DIRECTORY] { + assert_eq!( + fs::metadata(destination.join(name)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + } + + #[test] + fn refuses_different_recipe_or_initial_configuration_without_overwriting_data() { + let (root, mut plan) = fixture(); + apply(root.path(), &plan).unwrap(); + plan.initialization.recipe_digest = format!("sha256:{}", "b".repeat(64)); + assert!(apply(root.path(), &plan).is_err()); + plan.initialization.recipe_digest = format!("sha256:{}", "a".repeat(64)); + plan.initialization.initial_configuration_json = None; + assert!(apply(root.path(), &plan).is_err()); + } + + #[test] + fn retry_preserves_ordinary_group_private_persistence_and_rejects_world_access() { + let (root, plan) = fixture(); + apply(root.path(), &plan).unwrap(); + let persistence = root + .path() + .join("workspace") + .join(plan.workload_id.to_string()) + .join(PERSISTENCE_DIRECTORY); + fs::set_permissions(&persistence, fs::Permissions::from_mode(0o770)).unwrap(); + apply(root.path(), &plan).unwrap(); + assert_eq!( + fs::metadata(&persistence).unwrap().permissions().mode() & 0o777, + 0o770 + ); + fs::set_permissions(&persistence, fs::Permissions::from_mode(0o777)).unwrap(); + assert!(apply(root.path(), &plan).is_err()); + } + + #[test] + fn rejects_source_links_reserved_state_and_unsafe_existing_workspace() { + let (root, plan) = fixture(); + let source = root.path().join("recipe"); + symlink("../workspace", source.join("escape")).unwrap(); + assert!(apply(root.path(), &plan).is_err()); + fs::remove_file(source.join("escape")).unwrap(); + fs::write(source.join(PERSISTENCE_DIRECTORY), "reserved").unwrap(); + assert!(apply(root.path(), &plan).is_err()); + fs::remove_file(source.join(PERSISTENCE_DIRECTORY)).unwrap(); + let destination = root + .path() + .join("workspace") + .join(plan.workload_id.to_string()); + symlink(&source, &destination).unwrap(); + assert!(apply(root.path(), &plan).is_err()); + assert!(!source.join(MARKER_NAME).exists()); + } + + #[test] + fn resumes_abandoned_staging_but_never_adopts_unmarked_customer_data() { + let (root, plan) = fixture(); + let workspace = root.path().join("workspace"); + let staging = workspace.join(format!(".globster-recipe-{}", plan.workload_id)); + create_private_directory(&staging).unwrap(); + fs::write(staging.join("partial"), "incomplete").unwrap(); + apply(root.path(), &plan).unwrap(); + assert!(!staging.exists()); + let destination = workspace.join(plan.workload_id.to_string()); + fs::remove_file(destination.join(MARKER_NAME)).unwrap(); + assert!(apply(root.path(), &plan).is_err()); + assert!(destination.join("template.txt").exists()); + } + + #[test] + fn rejects_oversized_or_malformed_configuration_without_creating_a_destination() { + let (root, mut plan) = fixture(); + for json in [ + format!("{}secret-value", " ".repeat(MAX_CONFIGURATION_BYTES)), + r#"{"schemaVersion":2,"plugins":{}}"#.into(), + r#"{"schemaVersion":1,"plugins":[]}"#.into(), + ] { + plan.initialization.initial_configuration_json = Some(json); + let error = apply(root.path(), &plan).unwrap_err(); + assert!(!format!("{error:?}").contains("secret-value")); + } + assert!( + !root + .path() + .join("workspace") + .join(plan.workload_id.to_string()) + .exists() + ); + } + + #[test] + fn no_replace_publication_rejects_even_an_empty_destination() { + let root = tempfile::Builder::new() + .prefix(".binding-publish-test-") + .tempdir_in(".") + .unwrap(); + let staging = root.path().join("staging"); + let destination = root.path().join("destination"); + create_private_directory(&staging).unwrap(); + create_private_directory(&destination).unwrap(); + assert!(publish(&staging, &destination).is_err()); + assert!(staging.is_dir()); + assert!(destination.is_dir()); + } + + #[test] + fn pod_private_lock_excludes_a_second_writer_and_is_recoverable_after_exit() { + let root = tempfile::Builder::new() + .prefix(".binding-lock-test-") + .tempdir_in(".") + .unwrap(); + let directory = root.path().join("state"); + let first = lock_writer(&directory).unwrap(); + assert!(lock_writer(&directory).is_err()); + drop(first); + lock_writer(&directory).unwrap(); + fs::set_permissions( + directory.join("writer.lock"), + fs::Permissions::from_mode(0o666), + ) + .unwrap(); + assert!(lock_writer(&directory).is_err()); + } +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 48a9bc9198..2c87e2e916 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -8,6 +8,8 @@ mod activity_aggregator; #[cfg(target_os = "linux")] pub mod binding_gate; +#[cfg(target_os = "linux")] +pub mod binding_workspace; mod denial_aggregator; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod google_cloud_metadata; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 2ffacf6b71..ac453e77ff 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -521,6 +521,16 @@ fn main() -> Result<()> { // emptyDir volume that the agent container then executes from. let raw_args: Vec = std::env::args().collect(); #[cfg(target_os = "linux")] + if raw_args.get(1).map(String::as_str) == Some(openshell_sandbox::binding_workspace::SUBCOMMAND) + { + if raw_args.len() != 2 { + return Err(miette::miette!( + "bound workspace initializer takes no arguments" + )); + } + return openshell_sandbox::binding_workspace::run(); + } + #[cfg(target_os = "linux")] if raw_args.get(1).map(String::as_str) == Some(openshell_sandbox::binding_gate::SUBCOMMAND) { return openshell_sandbox::binding_gate::run(&raw_args[2..]); } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 4b06b9c1e5..130c26bea7 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -100,6 +100,34 @@ fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> p #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index ae35fc0fbf..7c18a28914 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -90,13 +90,14 @@ pin-project-lite = { workspace = true } ring = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +ryu-js = "1" toml = { workspace = true } tokio-stream = { workspace = true } sqlx = { workspace = true } reqwest = { workspace = true } aws-config = { workspace = true } aws-sdk-sts = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["serde"] } hmac = "0.12" sha2 = { workspace = true } jsonwebtoken = { workspace = true } diff --git a/crates/openshell-server/src/auth/descriptor_authz.rs b/crates/openshell-server/src/auth/descriptor_authz.rs index dbb9fa7ca2..b2ddf7156c 100644 --- a/crates/openshell-server/src/auth/descriptor_authz.rs +++ b/crates/openshell-server/src/auth/descriptor_authz.rs @@ -266,6 +266,22 @@ mod tests { const FUTURE_RPC: &str = "/openshell.v1.OpenShell/FutureRpc"; + #[test] + fn prewarm_operations_require_platform_authority_not_workspace_or_sandbox_identity() { + for (method, scope) in [ + ("PreparePrewarmSlot", "sandbox:write"), + ("ObservePrewarmSlot", "sandbox:read"), + ("BindPrewarmSlot", "sandbox:write"), + ("RetirePrewarmSlot", "sandbox:write"), + ] { + let entry = lookup(&format!("/openshell.v1.OpenShell/{method}")).unwrap(); + assert_eq!(entry.auth_mode, AuthMode::Bearer); + assert_eq!(entry.global_role.as_deref(), Some("platform_admin")); + assert_eq!(entry.workspace_role, None); + assert_eq!(entry.scope.as_deref(), Some(scope)); + } + } + #[test] fn every_proto_rpc_has_authorization_option() { let pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index b85641c986..5059774749 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -559,6 +559,8 @@ impl ComputeDriver for RemoteComputeDriver { #[derive(Clone)] pub struct ComputeRuntime { + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + pub(crate) prewarm_driver: Option, driver: TracedDriver, driver_info: ComputeDriverInfoSnapshot, driver_process: Option>, @@ -685,6 +687,8 @@ impl ComputeRuntime { Err(status) => return Err(compute_error_from_status(status)), }; Ok(Self { + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + prewarm_driver: None, driver: TracedDriver::new(driver, driver_name), driver_info, driver_process, @@ -769,8 +773,9 @@ impl ComputeRuntime { .await .map_err(|err| ComputeError::Message(err.to_string()))?; let operator_allowlist_arc = driver.operator_allowlist().cloned(); + let prewarm_driver = driver.clone(); let driver: SharedComputeDriver = Arc::new(KubernetesDriverService::new_in_process(driver)); - let runtime = Self::from_driver( + let mut runtime = Self::from_driver( ComputeDriverKind::Kubernetes.as_str().to_string(), driver, None, @@ -781,6 +786,7 @@ impl ComputeRuntime { supervisor_sessions, ) .await?; + runtime.prewarm_driver = Some(prewarm_driver); Ok((runtime, operator_allowlist_arc)) } @@ -937,6 +943,56 @@ impl ComputeRuntime { .map(|_| ()) } + /// Publish the validated ordinary sandbox record for resource-side adoption, + /// without dispatching a CREATE or choosing a new workload identity. + fn require_binding_released(sandbox: &Sandbox) -> Result<(), Status> { + if sandbox.metadata.as_ref().is_some_and(|metadata| { + metadata + .annotations + .get(openshell_core::driver_utils::ANNOTATION_BINDING_PENDING) + .map(String::as_str) + == Some("true") + }) { + return Err(Status::failed_precondition( + "native retained sandbox binding has not released its initial launch", + )); + } + Ok(()) + } + + pub(crate) async fn persist_retained_sandbox( + &self, + mut sandbox: Sandbox, + ) -> Result { + let labels = serde_json::to_string(&sandbox.object_labels()) + .map_err(|_| Status::internal("retained sandbox labels could not be encoded"))?; + let result = self + .store + .put_if( + Sandbox::object_type(), + sandbox.object_id(), + sandbox.object_name(), + sandbox.object_workspace(), + &sandbox.encode_to_vec(), + Some(&labels), + WriteCondition::MustCreate, + ) + .await + .map_err(|e| { + if e.is_expected() { + Status::already_exists("retained sandbox already exists") + } else { + Status::internal("retained sandbox could not be persisted") + } + })?; + if let Some(metadata) = sandbox.metadata.as_mut() { + metadata.resource_version = result.resource_version; + } + self.sandbox_index.update_from_sandbox(&sandbox); + self.sandbox_watch_bus.notify(sandbox.object_id()); + Ok(sandbox) + } + pub async fn create_sandbox( &self, sandbox: Sandbox, @@ -1072,6 +1128,7 @@ impl ComputeRuntime { .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + Self::require_binding_released(¤t)?; if current.object_name() != sandbox_name { return Err(Status::aborted( "sandbox name changed while the stop request was waiting; retry explicitly", @@ -1227,6 +1284,7 @@ impl ComputeRuntime { .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + Self::require_binding_released(¤t)?; if current.object_name() != sandbox_name { return Err(Status::aborted( "sandbox name changed while the start request was waiting; retry explicitly", @@ -1557,6 +1615,7 @@ impl ComputeRuntime { deleted: true, }); }; + Self::require_binding_released(¤t)?; if current.object_name() != target.sandbox_name { return Err(Status::aborted( "sandbox name changed while the delete request was waiting; retry explicitly", @@ -3244,6 +3303,14 @@ impl ComputeRuntime { } let sandbox = decode_sandbox_record(¤t_record)?; + if sandbox.metadata.as_ref().is_some_and(|m| { + m.annotations + .get(openshell_core::driver_utils::ANNOTATION_BINDING_PENDING) + .map(String::as_str) + == Some("true") + }) { + return Ok(()); + } let age_ms = openshell_core::time::now_ms().saturating_sub(current_record.created_at_ms); if age_ms < grace_ms { @@ -3445,7 +3512,7 @@ pub async fn connect_remote_compute_driver( )) } -fn driver_sandbox_from_public( +pub fn driver_sandbox_from_public( sandbox: &Sandbox, driver_name: &str, ) -> Result> { @@ -4297,6 +4364,8 @@ pub async fn new_test_runtime_with_driver( ) -> ComputeRuntime { ComputeRuntime { driver: TracedDriver::new(driver, "test".to_string()), + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + prewarm_driver: None, driver_info: ComputeDriverInfoSnapshot { name: driver_name.to_string(), driver_name: driver_name.to_string(), @@ -4983,6 +5052,8 @@ mod tests { let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); ComputeRuntime { driver: TracedDriver::new(driver, "test-driver".to_string()), + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + prewarm_driver: None, driver_info: ComputeDriverInfoSnapshot { name: driver_name.to_string(), driver_name: driver_name.to_string(), @@ -5523,6 +5594,58 @@ mod tests { assert!(store.is_single_replica()); } + #[tokio::test] + async fn prewarm_pending_binding_blocks_ordinary_lifecycle_mutations() { + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let runtime = new_test_runtime(store.clone()).await; + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "prewarm-pending".into(), + name: "pending".into(), + workspace: "default".into(), + annotations: HashMap::from([( + openshell_core::driver_utils::ANNOTATION_BINDING_PENDING.into(), + "true".into(), + )]), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready as i32); + runtime.persist_retained_sandbox(sandbox).await.unwrap(); + assert_eq!( + runtime + .stop_sandbox("default", "pending") + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + assert_eq!( + runtime + .start_sandbox("default", "pending") + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + assert_eq!( + runtime + .delete_sandbox("default", "pending") + .await + .unwrap_err() + .code(), + Code::FailedPrecondition + ); + assert!( + store + .get_message::("prewarm-pending") + .await + .unwrap() + .is_some() + ); + } + #[test] fn delete_gate_registry_removes_stale_entries() { let registry = LifecycleGateRegistry::default(); diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index f502369bcf..3678eb0277 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -5,6 +5,7 @@ mod auth_rpc; pub mod policy; +mod prewarm; pub mod provider; mod sandbox; mod service; @@ -224,6 +225,34 @@ impl OpenShellService { #[tonic::async_trait] impl OpenShell for OpenShellService { + async fn prepare_prewarm_slot( + &self, + request: Request, + ) -> Result, Status> { + prewarm::handle_operation(&self.state, request, prewarm::Operation::Prepare).await + } + + async fn observe_prewarm_slot( + &self, + request: Request, + ) -> Result, Status> { + prewarm::handle_operation(&self.state, request, prewarm::Operation::Observe).await + } + + async fn bind_prewarm_slot( + &self, + request: Request, + ) -> Result, Status> { + prewarm::handle_operation(&self.state, request, prewarm::Operation::Bind).await + } + + async fn retire_prewarm_slot( + &self, + request: Request, + ) -> Result, Status> { + prewarm::handle_operation(&self.state, request, prewarm::Operation::Retire).await + } + async fn health( &self, _request: Request, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 761d06b305..5007ccb529 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3450,6 +3450,17 @@ async fn handle_update_config_inner( .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); let mut response_annotations = sandbox_metadata_annotations(&sandbox); + if sandbox.metadata.as_ref().is_some_and(|metadata| { + metadata + .annotations + .get(openshell_core::driver_utils::ANNOTATION_BINDING_PENDING) + .map(String::as_str) + == Some("true") + }) { + return Err(Status::failed_precondition( + "native retained sandbox binding has not released its initial launch", + )); + } if has_setting { let _settings_guard = state.settings_mutex.lock().await; diff --git a/crates/openshell-server/src/grpc/prewarm.rs b/crates/openshell-server/src/grpc/prewarm.rs new file mode 100644 index 0000000000..7b4f838bb7 --- /dev/null +++ b/crates/openshell-server/src/grpc/prewarm.rs @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Platform-authorized native single-use prewarm transport. + +#![allow(clippy::result_large_err)] // gRPC handlers return Result, Status>. + +use std::sync::Arc; + +use openshell_core::proto::{PrewarmSlotRequest, PrewarmSlotResponse}; +use tonic::{Request, Response, Status}; + +use crate::ServerState; +use crate::auth::workspace_authz::require_platform_admin; + +const MAX_JSON_BYTES: usize = 1024 * 1024; + +#[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] +mod native; + +#[derive(Clone, Copy)] +pub(super) enum Operation { + Prepare, + Observe, + Bind, + Retire, +} + +#[cfg(test)] +pub(super) async fn handle( + state: &Arc, + request: Request, +) -> Result, Status> { + handle_operation(state, request, Operation::Observe).await +} + +pub(super) async fn handle_operation( + state: &Arc, + request: Request, + operation: Operation, +) -> Result, Status> { + let principal = super::extract_principal(&request)?; + require_platform_admin(&state.admin_role, &principal)?; + if request.get_ref().request_json.len() > MAX_JSON_BYTES { + return Err(Status::resource_exhausted( + "prewarm request exceeds the 1 MiB limit", + )); + } + #[cfg(all(not(target_os = "windows"), feature = "in-tree-compute-drivers"))] + if let Some(driver) = state.compute.prewarm_driver.as_ref() + && (driver + .prewarm_context() + .1 + .prewarm + .as_ref() + .is_some_and(|config| config.enabled) + || matches!(operation, Operation::Observe | Operation::Retire)) + { + let evidence = native::execute( + state, + driver, + &principal, + operation, + &request.get_ref().request_json, + ) + .await?; + let evidence_json = serde_json::to_string(&evidence) + .map_err(|_| Status::internal("prewarm evidence encoding failed"))?; + if evidence_json.len() > MAX_JSON_BYTES { + return Err(Status::resource_exhausted( + "prewarm evidence exceeds the 1 MiB limit", + )); + } + return Ok(Response::new(PrewarmSlotResponse { evidence_json })); + } + let _ = operation; + Err(Status::unimplemented( + "native single-use prewarm resource lifecycle is not supported", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; + use crate::grpc::test_support::{authed_request, test_server_state}; + use openshell_core::proto::open_shell_server::OpenShell; + use tonic::Code; + + #[tokio::test] + async fn every_operation_fails_closed_without_touching_the_ordinary_lifecycle() { + let state = test_server_state().await; + let service = crate::grpc::OpenShellService::new(state.clone()); + let request = || { + authed_request(PrewarmSlotRequest { + request_json: r#"{"credentialWrites":[{"value":"never-echo-this"}]}"#.into(), + }) + }; + let results = [ + service.prepare_prewarm_slot(request()).await, + service.observe_prewarm_slot(request()).await, + service.bind_prewarm_slot(request()).await, + service.retire_prewarm_slot(request()).await, + ]; + for result in results { + let status = result.unwrap_err(); + assert_eq!(status.code(), Code::Unimplemented); + assert!(!status.message().contains("never-echo-this")); + } + assert!( + state + .store + .list_all_messages::(1, 0) + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_unauthorized_callers_before_inspecting_the_payload() { + let state = test_server_state().await; + let mut request = Request::new(PrewarmSlotRequest { + request_json: "x".repeat(MAX_JSON_BYTES + 1), + }); + request.extensions_mut().insert(Principal::Anonymous); + assert_eq!( + handle(&state, request).await.unwrap_err().code(), + Code::Unauthenticated + ); + + let mut request = Request::new(PrewarmSlotRequest { + request_json: String::new(), + }); + request + .extensions_mut() + .insert(Principal::Sandbox(SandboxPrincipal { + sandbox_id: uuid::Uuid::new_v4().to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "test".into(), + }, + trust_domain: None, + })); + assert_eq!( + handle(&state, request).await.unwrap_err().code(), + Code::PermissionDenied + ); + + let mut request = authed_request(PrewarmSlotRequest { + request_json: String::new(), + }); + let principal = request.extensions_mut().get_mut::().unwrap(); + if let Principal::User(user) = principal { + user.identity.roles.clear(); + } + let mut state = Arc::try_unwrap(state).unwrap(); + state.admin_role = "openshell-admin".into(); + assert_eq!( + handle(&Arc::new(state), request).await.unwrap_err().code(), + Code::PermissionDenied + ); + } + + #[tokio::test] + async fn bounds_utf8_bytes_without_echoing_or_parsing_the_payload() { + let state = test_server_state().await; + for (request_json, expected) in [ + ("é".repeat(MAX_JSON_BYTES / 2), Code::Unimplemented), + ( + format!("{}x", "é".repeat(MAX_JSON_BYTES / 2)), + Code::ResourceExhausted, + ), + ] { + let request = authed_request(PrewarmSlotRequest { request_json }); + assert_eq!(handle(&state, request).await.unwrap_err().code(), expected); + } + } +} diff --git a/crates/openshell-server/src/grpc/prewarm/native.rs b/crates/openshell-server/src/grpc/prewarm/native.rs new file mode 100644 index 0000000000..42f6ea2de1 --- /dev/null +++ b/crates/openshell-server/src/grpc/prewarm/native.rs @@ -0,0 +1,2052 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! One native resource set, not a capacity pool. Kubernetes CAS is the allocation +//! authority; the permanent database reservation fences creation of that authority. +//! An issued CREATE is never replayed, including after process or replica loss. + +#![allow(clippy::result_large_err)] + +#[cfg(test)] +mod resource_tests; +mod tenant; + +use crate::persistence::ObjectType; +use k8s_openapi::api::core::v1::{ConfigMap, PersistentVolumeClaim, Pod, Secret}; +use kube::api::{DeleteParams, Patch, PatchParams, PostParams, Preconditions}; +use kube::core::{ApiResource, DynamicObject, GroupVersionKind}; +use kube::{Api, Client}; +use openshell_core::proto::Sandbox; +use openshell_driver_kubernetes::{ + KubernetesComputeDriver, + config::{KubernetesComputeConfig, SupervisorTopology, WorkspaceMode}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, + time::Duration, +}; +use tonic::Status; +use uuid::Uuid; + +use super::Operation; +use crate::{ServerState, auth::principal::Principal, persistence::WriteCondition}; + +const RECORD_TYPE: &str = "native_prewarm_slot_v1"; +const DATA_KEY: &str = "slot.json"; +const MANAGED: &str = "globster.dev/managed-by"; +const MANAGED_VALUE: &str = "workload-gateway"; +const WORKLOAD: &str = openshell_core::driver_utils::LABEL_PREWARM_WORKLOAD_ID; +const SLOT: &str = openshell_core::driver_utils::LABEL_PREWARM_SLOT_ID; +const GENERATION: &str = "globster.dev/prewarm-generation"; +const ALLOCATION: &str = "globster.dev/prewarm-allocation-id"; +const OWNER: &str = "globster.dev/organization-owner"; +const PENDING: &str = openshell_core::driver_utils::ANNOTATION_BINDING_PENDING; +const INIT_NAME: &str = "openshell-held-init"; +const GRANT: &str = "openshell.io/prewarm-grant"; +const API_TIMEOUT: Duration = Duration::from_secs(30); +const ASSISTANT_SCHEMA_VERSION: u64 = 2; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Identity { + schema_version: u32, + slot_id: Uuid, + workload_id: Uuid, + generation: u64, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +struct Ticket { + issued: bool, + uid: Option, + rejected: bool, +} + +impl Ticket { + fn may_issue(&self) -> Result { + if self.rejected { + return Err(Status::failed_precondition( + "prewarm CREATE was definitively rejected; slot may only be retired", + )); + } + Ok(!self.issued) + } +} + +#[derive(Clone, Serialize, Deserialize)] +struct Reservation { + identity: Identity, + preparation: Value, + storage: Value, + profile_hash: String, + fence: Ticket, + retire_requested: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[allow(clippy::struct_excessive_bools)] // Independent evidence and quarantine flags. +struct Slot { + identity: Identity, + preparation: Value, + storage: Value, + profile_hash: String, + phase: Phase, + api_version: String, + tickets: BTreeMap, + allocation: Option, + intent_hash: Option, + grant_hash: Option, + initialized: bool, + tenant_configured: bool, + launch_released: bool, + /// Durable diagnostic, never a successful observation state. + quarantined: bool, +} + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +enum Phase { + Preparing, + Ready, + Binding, + Bound, + Retiring, + Retired, +} + +struct Backend<'a> { + state: &'a Arc, + driver: &'a KubernetesComputeDriver, + principal: &'a Principal, + config: &'a KubernetesComputeConfig, + client: Client, + identity: Identity, + name: String, +} + +fn conflict() -> Status { + Status::failed_precondition("prewarm identity, ownership or intent conflict") +} +fn pending() -> Status { + Status::unavailable("prewarm provisioning is incomplete; retry the same identity") +} +fn quarantine() -> Status { + Status::unavailable( + "prewarm CREATE outcome is indeterminate; slot is durably quarantined, never recreated or falsely retired; retry observation to settle the original creation", + ) +} +fn backend_error(error: kube::Error) -> Status { + match error { + kube::Error::Api(error) if error.code == 409 => { + Status::aborted("prewarm Kubernetes resource version changed; retry the same identity") + } + kube::Error::Api(error) if error.code == 403 => { + Status::permission_denied("prewarm Kubernetes operation is not authorized") + } + kube::Error::Api(error) if error.code == 422 => { + Status::failed_precondition("prewarm Kubernetes resource update was rejected") + } + _ => Status::unavailable("prewarm Kubernetes operation failed"), + } +} + +fn hash(value: &Value) -> String { + format!("{:x}", Sha256::digest(canonical(value).as_bytes())) +} + +/// Match gateway canonicalJson, including ECMAScript numbers and UTF-16 key order. +fn canonical(value: &Value) -> String { + match value { + Value::Null => "null".into(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => ryu_js::Buffer::new() + .format(n.as_f64().expect("finite JSON number")) + .into(), + Value::String(s) => serde_json::to_string(s).expect("JSON string"), + Value::Array(values) => format!( + "[{}]", + values.iter().map(canonical).collect::>().join(",") + ), + Value::Object(object) => { + let mut keys: Vec<_> = object.keys().collect(); + keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); + format!( + "{{{}}}", + keys.into_iter() + .map(|key| format!("{}:{}", canonical(&json!(key)), canonical(&object[key]))) + .collect::>() + .join(",") + ) + } + } +} + +fn compatibility(preparation: &Value) -> String { + let mut digest = Sha256::new(); + digest.update(b"globster/prewarm-preparation/v1\0"); + digest.update(canonical(preparation)); + format!("sha256:{digest:x}", digest = digest.finalize()) +} + +fn identity(input: &Value) -> Result { + if input["schemaVersion"].as_u64() == Some(ASSISTANT_SCHEMA_VERSION) { + return Err(Status::unimplemented( + "native assistant prewarm v2 initialization and launch are not supported", + )); + } + let result: Identity = serde_json::from_value(json!({ + "schemaVersion": input["schemaVersion"], "slotId": input["slotId"], + "workloadId": input["workloadId"], "generation": input["generation"], + })) + .map_err(|_| Status::invalid_argument("invalid prewarm identity"))?; + if result.schema_version != 1 + || result.slot_id.is_nil() + || result.workload_id.is_nil() + || result.generation == 0 + || result.generation > 9_007_199_254_740_991 + { + return Err(Status::invalid_argument("invalid prewarm identity")); + } + Ok(result) +} + +fn immutable_image(value: &str) -> bool { + value.rsplit_once("@sha256:").is_some_and(|(name, digest)| { + !name.is_empty() + && digest.len() == 64 + && digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) +} + +fn preparation_shape(value: &Value) -> Result<(), Status> { + fn keys(value: &Value, expected: &[&str]) -> bool { + value.as_object().is_some_and(|o| { + o.len() == expected.len() && expected.iter().all(|k| o.contains_key(*k)) + }) + } + fn quantity(value: &Value, suffixes: &[&str]) -> bool { + value.as_str().is_some_and(|s| { + s.len() <= 32 + && suffixes.iter().any(|suffix| { + s.strip_suffix(suffix).is_some_and(|digits| { + !digits.is_empty() + && digits.as_bytes()[0] != b'0' + && digits.bytes().all(|c| c.is_ascii_digit()) + }) + }) + }) + } + if !keys( + value, + &[ + "schemaVersion", + "providerArtifact", + "placementKey", + "image", + "recipe", + "resources", + "workspace", + ], + ) || value["schemaVersion"] != 1 + || !["providerArtifact", "placementKey", "image"] + .iter() + .all(|key| value[*key].as_str().is_some_and(|s| !s.is_empty())) + || !keys(&value["recipe"], &["id", "digest", "image"]) + || !["id", "digest", "image"].iter().all(|key| { + value["recipe"][*key] + .as_str() + .is_some_and(|s| !s.is_empty()) + }) + || !keys(&value["resources"], &["cpu", "memory"]) + || !quantity(&value["resources"]["cpu"], &["m"]) + || !quantity(&value["resources"]["memory"], &["Mi", "Gi"]) + || !keys(&value["workspace"], &["capacity"]) + || !quantity(&value["workspace"]["capacity"], &["Mi", "Gi", "Ti"]) + { + return Err(Status::invalid_argument( + "invalid prewarm preparation contract", + )); + } + Ok(()) +} + +fn validate_preparation(config: &KubernetesComputeConfig, input: &Value) -> Result<(), Status> { + preparation_shape(&input["preparation"])?; + let approved = config + .prewarm + .as_ref() + .ok_or_else(|| Status::unimplemented("native prewarm is disabled"))?; + if config.workspace_mode != WorkspaceMode::Shared + || config.topology != SupervisorTopology::Combined + || config.sandbox_uid != Some(1000) + || config.sandbox_gid != Some(1000) + || config.provider_spiffe_enabled() + { + return Err(Status::unimplemented( + "held-init prewarm requires shared namespace, combined supervisor, explicit UID/GID 1000 and no SPIFFE provider mount", + )); + } + let preparation = &input["preparation"]; + if preparation["schemaVersion"] != 1 + || preparation["providerArtifact"] != approved.provider_artifact + || preparation["placementKey"] != approved.placement_key + || preparation["image"] != approved.image + || preparation["recipe"]["id"] != approved.recipe_id + || preparation["recipe"]["digest"] != approved.recipe_digest + || preparation["recipe"]["image"] != approved.recipe_image + || !immutable_image(&approved.image) + || !immutable_image(&approved.recipe_image) + || !immutable_image(&config.supervisor_image) + { + return Err(Status::unimplemented( + "prewarm artifact, recipe or placement is not explicitly approved and immutable", + )); + } + if !preparation["workspace"]["capacity"].is_string() + || !preparation["resources"].is_object() + || preparation["resources"] + .as_object() + .is_some_and(|r| r.keys().any(|k| !["cpu", "memory"].contains(&k.as_str()))) + { + return Err(Status::invalid_argument( + "unsupported prewarm storage or compute resources", + )); + } + Ok(()) +} + +fn profile_hash(config: &KubernetesComputeConfig) -> Result { + let mut profile = serde_json::to_value(config).map_err(|_| conflict())?; + // Approval is checked against the preparation separately. Disabling new + // preparation must not prevent observation or retirement of existing slots. + profile + .as_object_mut() + .ok_or_else(conflict)? + .remove("prewarm"); + Ok(hash(&profile)) +} + +fn retained_identities( + tickets: &BTreeMap, + objects: [(&str, &Value); 3], +) -> Result<(), Status> { + for (kind, object) in objects { + let uid = tickets + .get(kind) + .and_then(|ticket| ticket.uid.as_deref()) + .ok_or_else(pending)?; + if object["metadata"]["uid"].as_str() != Some(uid) + || !object["metadata"]["deletionTimestamp"].is_null() + { + return Err(conflict()); + } + } + Ok(()) +} + +pub(super) async fn execute( + state: &Arc, + driver: &KubernetesComputeDriver, + principal: &Principal, + operation: Operation, + request: &str, +) -> Result { + let input: Value = serde_json::from_str(request) + .map_err(|_| Status::invalid_argument("invalid prewarm request document"))?; + let identity = identity(&input)?; + let (client, config) = driver.prewarm_context(); + let backend = Backend { + state, + driver, + principal, + config, + client, + name: format!("prewarm-{}", identity.workload_id), + identity, + }; + match operation { + Operation::Prepare => backend.prepare(&input).await?, + Operation::Observe => {} + Operation::Bind => backend.bind(principal, &input).await?, + Operation::Retire => backend.retire(&input).await?, + } + backend.observe().await +} + +impl Backend<'_> { + fn api(&self) -> Api + where + K: kube::Resource, + { + Api::namespaced(self.client.clone(), &self.config.namespace) + } + fn sandbox_api(&self, version: &str) -> Api { + let ar = ApiResource::from_gvk(&GroupVersionKind::gvk( + "agents.x-k8s.io", + version, + "Sandbox", + )); + Api::namespaced_with(self.client.clone(), &self.config.namespace, &ar) + } + fn pod_api(&self) -> Api { + let ar = ApiResource::from_gvk(&GroupVersionKind::gvk("", "v1", "Pod")); + Api::namespaced_with(self.client.clone(), &self.config.namespace, &ar) + } + fn labels(&self) -> BTreeMap { + BTreeMap::from([ + (MANAGED.into(), MANAGED_VALUE.into()), + (WORKLOAD.into(), self.identity.workload_id.to_string()), + (SLOT.into(), self.identity.slot_id.to_string()), + (GENERATION.into(), self.identity.generation.to_string()), + ]) + } + fn storage(&self) -> Value { + let mut storage = json!({"namespace": self.config.namespace, + "claimName": format!("globster-workspace-{}",self.identity.workload_id), "accessModes":["ReadWriteOnce"]}); + if !self.config.workspace_storage_class.is_empty() { + storage["storageClassName"] = json!(self.config.workspace_storage_class); + } + storage + } + async fn reservation(&self) -> Result<(Reservation, u64), Status> { + let record = self + .state + .store + .get( + RECORD_TYPE, + &format!("{RECORD_TYPE}:{}", self.identity.workload_id), + ) + .await + .map_err(|_| Status::unavailable("prewarm reservation lookup failed"))? + .ok_or_else(|| Status::not_found("prewarm slot does not exist"))?; + let reservation: Reservation = + serde_json::from_slice(&record.payload).map_err(|_| conflict())?; + if reservation.identity != self.identity { + return Err(conflict()); + } + Ok((reservation, record.resource_version)) + } + async fn save_reservation( + &self, + reservation: &Reservation, + version: Option, + ) -> Result<(), Status> { + self.state + .store + .put_if( + RECORD_TYPE, + &format!("{RECORD_TYPE}:{}", self.identity.workload_id), + &self.identity.slot_id.to_string(), + "", + &serde_json::to_vec(reservation).map_err(|_| conflict())?, + None, + version.map_or( + WriteCondition::MustCreate, + WriteCondition::MatchResourceVersion, + ), + ) + .await + .map_err(|e| { + if e.is_expected() { + Status::aborted("prewarm reservation changed; retry the same identity") + } else { + Status::unavailable("prewarm reservation write failed") + } + })?; + Ok(()) + } + async fn reserve(&self, input: &Value, retiring: bool) -> Result { + preparation_shape(&input["preparation"])?; + if !retiring { + validate_preparation(self.config, input)?; + } + if !retiring + && self + .state + .store + .get_message::(&self.identity.workload_id.to_string()) + .await + .map_err(|_| conflict())? + .is_some() + { + return Err(conflict()); + } + let storage = self.storage(); + if !retiring && input["storage"] != storage { + return Err(Status::unimplemented( + "prewarm storage must match the configured native namespace and storage class", + )); + } + match self.reservation().await { + Ok((mut record, version)) => { + if record.preparation != input["preparation"] + || record.storage != storage + || (!retiring && record.profile_hash != profile_hash(self.config)?) + { + return Err(conflict()); + } + if retiring && !record.retire_requested { + record.retire_requested = true; + self.save_reservation(&record, Some(version)).await?; + } + if !retiring && record.retire_requested { + return Err(conflict()); + } + Ok(record) + } + Err(e) if e.code() == tonic::Code::NotFound => { + let record = Reservation { + identity: self.identity.clone(), + preparation: input["preparation"].clone(), + storage, + profile_hash: profile_hash(self.config)?, + fence: Ticket::default(), + retire_requested: retiring, + }; + self.save_reservation(&record, None).await?; + Ok(record) + } + Err(e) => Err(e), + } + } + async fn load(&self) -> Result<(ConfigMap, Slot), Status> { + let (reservation, _) = self.reservation().await?; + let cm = self + .api::() + .get_opt(&self.name) + .await + .map_err(backend_error)? + .ok_or_else(|| { + if reservation.fence.issued { + quarantine() + } else { + pending() + } + })?; + self.check_labels(&cm.metadata, None)?; + if cm.metadata.deletion_timestamp.is_some() { + return Err(conflict()); + } + if reservation + .fence + .uid + .as_ref() + .is_some_and(|uid| cm.metadata.uid.as_ref() != Some(uid)) + { + return Err(conflict()); + } + let slot: Slot = serde_json::from_str( + cm.data + .as_ref() + .and_then(|d| d.get(DATA_KEY)) + .ok_or_else(conflict)?, + ) + .map_err(|_| conflict())?; + if slot.identity != self.identity + || slot.preparation != reservation.preparation + || slot.storage != reservation.storage + || slot.profile_hash != reservation.profile_hash + { + return Err(conflict()); + } + Ok((cm, slot)) + } + async fn save(&self, mut cm: ConfigMap, slot: &Slot) -> Result<(), Status> { + cm.data = Some(BTreeMap::from([( + DATA_KEY.into(), + serde_json::to_string(slot).map_err(|_| conflict())?, + )])); + self.api::() + .replace(&self.name, &PostParams::default(), &cm) + .await + .map_err(|e| match e { + kube::Error::Api(a) if a.code == 409 => { + Status::aborted("prewarm resource fence changed; retry") + } + _ => Status::unavailable("prewarm resource fence update failed"), + })?; + Ok(()) + } + fn check_labels( + &self, + meta: &kube::core::ObjectMeta, + allocation: Option<&Value>, + ) -> Result<(), Status> { + let labels = meta.labels.as_ref().ok_or_else(conflict)?; + if self.labels().iter().any(|(k, v)| labels.get(k) != Some(v)) { + return Err(conflict()); + } + match allocation { + Some(a) => { + if labels.get(ALLOCATION).map(String::as_str) != a["id"].as_str() + || labels.get(OWNER).map(String::as_str) != a["ownershipMarker"].as_str() + { + return Err(conflict()); + } + } + None => { + if labels.contains_key(ALLOCATION) || labels.contains_key(OWNER) { + return Err(conflict()); + } + } + } + Ok(()) + } + fn owner(&self, cm: &ConfigMap) -> Result { + Ok( + json!({"apiVersion":"v1","kind":"ConfigMap","name":self.name, + "uid":cm.metadata.uid.as_ref().ok_or_else(conflict)?,"controller":true,"blockOwnerDeletion":true}), + ) + } + async fn ensure_fence(&self) -> Result<(), Status> { + let (mut reservation, version) = self.reservation().await?; + if self + .api::() + .get_opt(&self.name) + .await + .map_err(backend_error)? + .is_some() + { + if !reservation.fence.issued || reservation.fence.rejected { + return Err(conflict()); + } + let (cm, _) = self.load().await?; + if reservation.fence.uid.is_none() { + reservation.fence.uid = cm.metadata.uid; + self.save_reservation(&reservation, Some(version)).await?; + } + return Ok(()); + } + if reservation.fence.rejected { + return Err(Status::failed_precondition( + "prewarm authority CREATE was definitively rejected; retire this identity", + )); + } + if reservation.fence.issued { + return Err(quarantine()); + } + if reservation.retire_requested { + return Ok(()); + } + let rendered = self.neutral_sandbox(&reservation.preparation).await?; + let api_version = rendered + .types + .as_ref() + .ok_or_else(conflict)? + .api_version + .rsplit('/') + .next() + .ok_or_else(conflict)? + .to_owned(); + reservation.fence.issued = true; + self.save_reservation(&reservation, Some(version)).await?; + let slot = Slot { + identity: self.identity.clone(), + preparation: reservation.preparation, + storage: reservation.storage, + profile_hash: reservation.profile_hash, + phase: Phase::Preparing, + api_version, + tickets: BTreeMap::new(), + allocation: None, + intent_hash: None, + grant_hash: None, + initialized: false, + tenant_configured: false, + launch_released: false, + quarantined: false, + }; + let cm: ConfigMap = + serde_json::from_value(json!({"apiVersion":"v1","kind":"ConfigMap","metadata":{ + "name":self.name,"namespace":self.config.namespace,"labels":self.labels()}, + "data":{(DATA_KEY):serde_json::to_string(&slot).map_err(|_|conflict())?}})) + .map_err(|_| conflict())?; + let created = tokio::time::timeout( + API_TIMEOUT, + self.api::().create(&PostParams::default(), &cm), + ) + .await; + match created { + Ok(Ok(_)) => Ok(()), + Ok(Err(kube::Error::Api(error))) + if [400, 401, 403, 404, 405, 415, 422].contains(&error.code) => + { + let (mut reservation, version) = self.reservation().await?; + reservation.fence.rejected = true; + self.save_reservation(&reservation, Some(version)).await?; + Err(Status::failed_precondition( + "prewarm authority CREATE was definitively rejected; retire this identity", + )) + } + _ => Err(quarantine()), + } + } + async fn neutral_sandbox(&self, preparation: &Value) -> Result { + let sandbox = tenant::sandbox_request(self, &json!({}), &preparation["resources"], None)?; + let public = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: self.identity.workload_id.to_string(), + name: tenant::runtime_name(self.identity.workload_id), + workspace: "default".into(), + ..Default::default() + }), + spec: sandbox.spec, + ..Default::default() + }; + let driver = + crate::compute::driver_sandbox_from_public(&public, "kubernetes").map_err(|s| *s)?; + self.driver + .render_sandbox(&driver) + .await + .map_err(|_| Status::failed_precondition("prewarm ordinary pod rendering failed")) + } + async fn prepare(&self, input: &Value) -> Result<(), Status> { + self.reserve(input, false).await?; + self.ensure_fence().await?; + let (cm, slot) = self.load().await?; + if slot.phase == Phase::Ready { + return Ok(()); + } + if slot.phase != Phase::Preparing { + return Err(conflict()); + } + let owner = self.owner(&cm)?; + let mut rendered = self.neutral_sandbox(&slot.preparation).await?; + let pod_template = self.held_template(&rendered.data["spec"]["podTemplate"], &slot)?; + rendered.metadata.name = Some(self.name.clone()); + rendered.metadata.labels = Some(self.labels()); + rendered.metadata.annotations = Some(BTreeMap::from([( + "agents.x-k8s.io/pod-name".into(), + self.name.clone(), + )])); + rendered.metadata.owner_references = + Some(serde_json::from_value(json!([owner.clone()])).map_err(|_| conflict())?); + rendered.data["spec"]["podTemplate"] = pod_template.clone(); + if slot.api_version == "v1beta1" { + rendered.data["spec"]["operatingMode"] = json!("Suspended"); + } else { + rendered.data["spec"]["replicas"] = json!(0); + } + self.create_once( + "sandbox", + serde_json::to_value(rendered).map_err(|_| conflict())?, + ) + .await?; + let mut pvc = json!({"apiVersion":"v1","kind":"PersistentVolumeClaim","metadata":{ + "name":slot.storage["claimName"],"namespace":self.config.namespace,"labels":self.labels(),"ownerReferences":[owner.clone()]}, + "spec":{"accessModes":["ReadWriteOnce"],"resources":{"requests":{"storage":slot.preparation["workspace"]["capacity"]}}}}); + if let Some(class) = slot.storage.get("storageClassName") { + pvc["spec"]["storageClassName"] = class.clone(); + } + self.create_once("storage", pvc).await?; + self.create_once( + "compute", + json!({"apiVersion":"v1","kind":"Pod","metadata":{ + "name":self.name,"namespace":self.config.namespace,"labels":self.labels(), + "annotations":pod_template["metadata"]["annotations"],"ownerReferences":[owner]}, + "spec":pod_template["spec"]}), + ) + .await?; + let (cm, mut slot) = self.load().await?; + if slot.phase != Phase::Preparing { + return Err(conflict()); + } + if self.held_running().await? { + slot.phase = Phase::Ready; + self.save(cm, &slot).await?; + } + Ok(()) + } + fn held_template(&self, template: &Value, slot: &Slot) -> Result { + let mut result = template.clone(); + let spec = result["spec"].as_object_mut().ok_or_else(conflict)?; + let volumes = spec + .get_mut("volumes") + .and_then(Value::as_array_mut) + .ok_or_else(conflict)?; + volumes.extend([ + json!({"name":"prewarm-state","emptyDir":{}}), + json!({"name":"prewarm-init","secret":{"secretName":format!("{}-init",self.name),"optional":true,"defaultMode":256}}), + json!({"name":"prewarm-launch","secret":{"secretName":format!("{}-launch",self.name),"optional":true,"defaultMode":256}}), + json!({"name":"prewarm-recipe","image":{"reference":slot.preparation["recipe"]["image"],"pullPolicy":"IfNotPresent"}}), + ]); + let agent = spec + .get_mut("containers") + .and_then(Value::as_array_mut) + .and_then(|c| c.first_mut()) + .ok_or_else(conflict)?; + let old_command = agent["command"].as_array().cloned().ok_or_else(|| { + Status::unimplemented("prewarm requires the native sideloaded supervisor command") + })?; + let binary = old_command + .first() + .and_then(Value::as_str) + .ok_or_else(conflict)? + .to_owned(); + let mut supervisor_args: Vec = old_command.into_iter().skip(1).collect(); + supervisor_args.extend(agent["args"].as_array().cloned().unwrap_or_default()); + let common = vec![ + json!("await-binding"), + json!("--slot-id"), + json!(self.identity.slot_id), + json!("--generation"), + json!(self.identity.generation.to_string()), + json!("--sandbox-id"), + json!(self.identity.workload_id), + json!("--pod-uid"), + json!("$(OPENSHELL_PREWARM_POD_UID)"), + json!("--require-workspace-initialization"), + ]; + let mut init_args = common.clone(); + init_args.extend([ + json!("--grant"), + json!("/run/openshell-prewarm-init/grant.json"), + json!("--initialize-only"), + ]); + let mut main_args = common; + main_args.extend([ + json!("--grant"), + json!("/run/openshell-prewarm-launch/grant.json"), + json!("--"), + ]); + main_args.extend(supervisor_args); + agent["command"] = json!([binary]); + agent["args"] = json!(main_args); + let pod_uid = json!({"name":"OPENSHELL_PREWARM_POD_UID","valueFrom":{"fieldRef":{"fieldPath":"metadata.uid"}}}); + agent["env"] + .as_array_mut() + .ok_or_else(conflict)? + .push(pod_uid.clone()); + let mounts = agent["volumeMounts"].as_array_mut().ok_or_else(conflict)?; + mounts.extend([json!({"name":"prewarm-state","mountPath":"/run/openshell-binding-state"}), + json!({"name":"prewarm-launch","mountPath":"/run/openshell-prewarm-launch","readOnly":true}), + json!({"name":"prewarm-recipe","mountPath":"/recipe","subPath":"recipe","readOnly":true})]); + // The idle process sees only its executable, recipe, blank claim and + // private binding state. Ordinary SA/mTLS/proxy mounts remain main-only. + let init_mounts: Vec = mounts.iter().filter(|m| { + let name=m["name"].as_str().unwrap_or_default(); + name=="prewarm-state" || name=="prewarm-recipe" || name=="globster-workspace" + && m["mountPath"]=="/workspace" || name.contains("supervisor") + }).cloned().chain(std::iter::once(json!({"name":"prewarm-init","mountPath":"/run/openshell-prewarm-init","readOnly":true}))).collect(); + let init = json!({"name":INIT_NAME,"image":slot.preparation["image"],"imagePullPolicy":self.config.image_pull_policy, + "command":[binary],"args":init_args,"env":[pod_uid],"resources":agent["resources"], + "securityContext":{"runAsUser":0,"runAsGroup":0},"volumeMounts":init_mounts, + "terminationMessagePolicy":"File"}); + spec.entry("initContainers") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or_else(conflict)? + .push(init); + Ok(result) + } + async fn held_running(&self) -> Result { + let Some(pod) = self + .api::() + .get_opt(&self.name) + .await + .map_err(backend_error)? + else { + return Ok(false); + }; + if pod.metadata.deletion_timestamp.is_some() { + return Ok(false); + } + let running = pod + .status + .and_then(|s| s.init_container_statuses) + .is_some_and(|statuses| { + statuses.iter().any(|s| { + s.name == INIT_NAME && s.state.as_ref().is_some_and(|s| s.running.is_some()) + }) + }); + if !running { + return Ok(false); + } + let uid = pod.metadata.uid.ok_or_else(conflict)?; + let expected = format!( + "OpenShell held-init ready slot={} generation={} pod={} sandbox={}", + self.identity.slot_id, self.identity.generation, uid, self.identity.workload_id + ); + let logs = self + .api::() + .logs( + &self.name, + &kube::api::LogParams { + container: Some(INIT_NAME.into()), + tail_lines: Some(10), + limit_bytes: Some(4096), + ..Default::default() + }, + ) + .await + .map_err(backend_error)?; + Ok(logs.lines().any(|line| line == expected)) + } + async fn check_resources(&self, slot: &Slot) -> Result<(), Status> { + let pod = self.object("compute", slot).await?.ok_or_else(pending)?; + let pvc = self.object("storage", slot).await?.ok_or_else(pending)?; + let sandbox = self.object("sandbox", slot).await?.ok_or_else(pending)?; + retained_identities( + &slot.tickets, + [("compute", &pod), ("storage", &pvc), ("sandbox", &sandbox)], + )?; + let volumes = pod["spec"]["volumes"].as_array().ok_or_else(conflict)?; + let claims: Vec<_> = volumes + .iter() + .filter(|volume| volume.get("persistentVolumeClaim").is_some()) + .collect(); + let containers = pod["spec"]["containers"].as_array().ok_or_else(conflict)?; + let init = pod["spec"]["initContainers"] + .as_array() + .ok_or_else(conflict)?; + if claims.len() != 1 + || claims[0]["name"] != "globster-workspace" + || claims[0]["persistentVolumeClaim"]["claimName"] != slot.storage["claimName"] + || claims[0]["persistentVolumeClaim"]["readOnly"] == true + || pvc["spec"]["accessModes"] != json!(["ReadWriteOnce"]) + || !volumes.iter().any(|volume| { + volume["name"] == "prewarm-recipe" + && volume["image"]["reference"] == slot.preparation["recipe"]["image"] + }) + || slot + .storage + .get("storageClassName") + .is_some_and(|class| pvc["spec"]["storageClassName"] != *class) + || containers.len() != 1 + || containers[0]["image"] != slot.preparation["image"] + || containers[0]["args"][0] != "await-binding" + || containers[0].get("lifecycle").is_some() + || !init.iter().any(|container| { + container["name"] == INIT_NAME + && container["image"] == slot.preparation["image"] + && container["args"] + .as_array() + .is_some_and(|args| args.contains(&json!("--initialize-only"))) + }) + || init.iter().any(|container| { + container["name"] != INIT_NAME + && container["volumeMounts"].as_array().is_some_and(|mounts| { + mounts + .iter() + .any(|mount| mount["name"] == "globster-workspace") + }) + }) + { + return Err(Status::failed_precondition( + "retained-pod layout must have the approved gated image and one exclusive RWO claim/writer", + )); + } + Ok(()) + } + async fn object(&self, kind: &str, slot: &Slot) -> Result, Status> { + let value = match kind { + "sandbox" => self + .sandbox_api(&slot.api_version) + .get_opt(&self.name) + .await + .map_err(backend_error)? + .map(|v| serde_json::to_value(v).expect("Kubernetes object")), + "compute" => self + .pod_api() + .get_opt(&self.name) + .await + .map_err(backend_error)? + .map(|v| serde_json::to_value(v).expect("Kubernetes object")), + "storage" => self + .api::() + .get_opt(slot.storage["claimName"].as_str().ok_or_else(conflict)?) + .await + .map_err(backend_error)? + .map(|v| serde_json::to_value(v).expect("Kubernetes object")), + "init" | "launch" => self + .api::() + .get_opt(&format!("{}-{kind}", self.name)) + .await + .map_err(backend_error)? + .map(|v| serde_json::to_value(v).expect("Kubernetes object")), + _ => return Err(conflict()), + }; + Ok(value) + } + async fn create_once(&self, kind: &str, value: Value) -> Result<(), Status> { + let object: DynamicObject = serde_json::from_value(value).map_err(|_| conflict())?; + let types = object.types.as_ref().ok_or_else(conflict)?; + let (group, version) = types + .api_version + .split_once('/') + .unwrap_or(("", types.api_version.as_str())); + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk(group, version, &types.kind)); + let api: Api = + Api::namespaced_with(self.client.clone(), &self.config.namespace, &resource); + let (cm, mut slot) = self.load().await?; + if !matches!(slot.phase, Phase::Preparing | Phase::Binding) { + return Err(conflict()); + } + let ticket = slot.tickets.entry(kind.into()).or_default(); + if !ticket.may_issue()? { + let object = self.object(kind, &slot).await?; + return self.settle(kind, object).await; + } + ticket.issued = true; + self.save(cm, &slot).await?; + // Exactly one HTTP write. kube's ordinary client does not install a + // write-retrying middleware. Never retry this call after any transport error. + let created = + tokio::time::timeout(API_TIMEOUT, api.create(&PostParams::default(), &object)).await; + match created { + Ok(Ok(object)) => self.settle(kind, Some(json!(object))).await, + Ok(Err(kube::Error::Api(error))) + if [400, 401, 403, 404, 405, 415, 422].contains(&error.code) => + { + let (cm, mut slot) = self.load().await?; + slot.tickets.get_mut(kind).ok_or_else(conflict)?.rejected = true; + self.save(cm, &slot).await?; + Err(Status::failed_precondition( + "prewarm CREATE was definitively rejected; fix platform configuration and retire this slot, never replay its CREATE", + )) + } + _ => { + let (cm, mut slot) = self.load().await?; + slot.quarantined = true; + self.save(cm, &slot).await?; + Err(quarantine()) + } + } + } + async fn settle(&self, kind: &str, object: Option) -> Result<(), Status> { + let (cm, mut slot) = self.load().await?; + let Some(object) = object else { + if slot.tickets.get(kind).is_some_and(|t| t.uid.is_some()) { + return Err(conflict()); + } + slot.quarantined = true; + self.save(cm, &slot).await?; + return Err(quarantine()); + }; + let meta: kube::core::ObjectMeta = + serde_json::from_value(object["metadata"].clone()).map_err(|_| conflict())?; + // Binding may already have stamped allocation ownership on Pod/PVC. + let expected = if matches!(kind, "compute" | "storage") + && meta + .labels + .as_ref() + .is_some_and(|l| l.contains_key(ALLOCATION)) + { + slot.allocation.as_ref() + } else { + None + }; + self.check_labels(&meta, expected)?; + let ticket = slot.tickets.get_mut(kind).ok_or_else(conflict)?; + let uid = meta.uid.ok_or_else(conflict)?; + if !ticket.issued || ticket.uid.as_ref().is_some_and(|old| old != &uid) { + return Err(conflict()); + } + if ticket.uid.as_ref() == Some(&uid) { + return Ok(()); + } + ticket.uid = Some(uid); + slot.quarantined = false; + self.save(cm, &slot).await + } + fn receipt(slot: &Slot) -> Result { + Ok( + json!({"sandboxUid":slot.tickets.get("sandbox").and_then(|t|t.uid.as_ref()).ok_or_else(pending)?, + "computeUid":slot.tickets.get("compute").and_then(|t|t.uid.as_ref()).ok_or_else(pending)?, + "storageUid":slot.tickets.get("storage").and_then(|t|t.uid.as_ref()).ok_or_else(pending)?}), + ) + } + async fn bind(&self, principal: &Principal, input: &Value) -> Result<(), Status> { + let (_, slot) = self.load().await?; + if !matches!(slot.phase, Phase::Ready | Phase::Binding | Phase::Bound) { + return Err(conflict()); + } + self.check_profile(&slot)?; + self.check_resources(&slot).await?; + if slot.phase == Phase::Ready && !self.held_running().await? { + return Err(Status::failed_precondition( + "prepared held-init runtime is no longer ready; native retirement is required, never restart or recycle it", + )); + } + tenant::validate(self, &slot, input)?; + if Self::receipt(&slot)? != input["resourceReceipt"] { + return Err(conflict()); + } + let intent_hash = hash(input); + if matches!(slot.phase, Phase::Binding | Phase::Bound) { + if slot.intent_hash.as_deref() != Some(&intent_hash) { + return Err(conflict()); + } + if slot.phase == Phase::Bound { + return self.finalize_bound().await; + } + } else { + if slot.phase != Phase::Ready || !self.held_running().await? { + return Err(conflict()); + } + let (cm, mut latest) = self.load().await?; + if latest.phase != Phase::Ready { + return Err(Status::aborted("prewarm allocation fence changed; retry")); + } + // The resource-side single-assignment linearization point. Retirement + // and every different allocation permanently lose this CAS. + latest.phase = Phase::Binding; + latest.intent_hash = Some(intent_hash.clone()); + latest.grant_hash = Some(format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&self.bound_grant(&latest, input)?) + .map_err(|_| conflict())? + ) + )); + latest.allocation = Some( + json!({"id":input["allocationId"],"organizationId":input["organization"]["organizationId"], + "workspaceName":input["organization"]["workspaceName"],"ownershipMarker":input["organization"]["ownershipMarker"], + "desiredRevision":input["request"]["desired"]["revision"],"fingerprint":input["request"]["desired"]["fingerprints"]["overall"], + "fingerprints":input["request"]["desired"]["fingerprints"],"bindings":input["request"]["desired"]["secretBindings"]}), + ); + self.save(cm, &latest).await?; + } + let (_, slot) = self.load().await?; + self.check_resources(&slot).await?; + self.stamp("storage").await?; + self.stamp("compute").await?; + tenant::configure(self, principal, input).await?; + let (_, slot) = self.load().await?; + if slot.phase != Phase::Binding || slot.intent_hash.as_deref() != Some(&intent_hash) { + return Err(conflict()); + } + let grant = self.bound_grant(&slot, input)?; + self.check_resources(&slot).await?; + self.grant("init", &grant).await?; + self.resume_binding().await + } + fn bound_grant(&self, slot: &Slot, input: &Value) -> Result { + let receipt = Self::receipt(slot)?; + let mut initialization = json!({"recipe_id":slot.preparation["recipe"]["id"],"recipe_digest":slot.preparation["recipe"]["digest"]}); + if let Some(configuration) = input["request"]["desired"].get("initialConfiguration") { + initialization["initial_configuration_json"] = json!(canonical(configuration)); + } + Ok( + json!({"version":1,"slot_id":self.identity.slot_id,"generation":self.identity.generation, + "pod_uid":receipt["computeUid"],"sandbox_id":self.identity.workload_id,"allocation_id":input["allocationId"], + "main_process":{"version":1,"command":input["launch"]["command"],"tty":false}, + "environment":input["launch"]["environment"],"workspace_initialization":initialization}), + ) + } + async fn resume_binding(&self) -> Result<(), Status> { + let (cm, mut slot) = self.load().await?; + if slot.phase == Phase::Bound { + return self.finalize_bound().await; + } + if slot.phase != Phase::Binding { + return Err(conflict()); + } + self.check_profile(&slot)?; + self.check_resources(&slot).await?; + let Some(ticket) = slot.tickets.get("init") else { + return Ok(()); + }; + if ticket.uid.is_none() { + return Err(quarantine()); + } + let secret = self + .api::() + .get(&format!("{}-init", self.name)) + .await + .map_err(backend_error)?; + if secret.metadata.uid.as_ref() != ticket.uid.as_ref() { + return Err(conflict()); + } + let bytes = &secret + .data + .as_ref() + .and_then(|data| data.get("grant.json")) + .ok_or_else(conflict)? + .0; + if bytes.len() > super::MAX_JSON_BYTES { + return Err(conflict()); + } + let grant: Value = serde_json::from_slice(bytes).map_err(|_| conflict())?; + let grant_hash = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&grant).map_err(|_| conflict())?) + ); + if slot.grant_hash.as_deref() != Some(&grant_hash) { + return Err(conflict()); + } + let sandbox = self + .state + .store + .get_message::(&self.identity.workload_id.to_string()) + .await + .map_err(|_| conflict())? + .ok_or_else(conflict)?; + let allocation = slot.allocation.as_ref().ok_or_else(conflict)?; + let meta = sandbox.metadata.as_ref().ok_or_else(conflict)?; + if meta.labels.get(ALLOCATION).map(String::as_str) != allocation["id"].as_str() + || meta.workspace != allocation["workspaceName"] + || meta + .annotations + .get("globster.dev/spec-hash") + .map(String::as_str) + != allocation["fingerprint"].as_str() + || slot + .tickets + .iter() + .any(|(name, ticket)| name.starts_with("provider:") && ticket.uid.is_none()) + { + return Err(conflict()); + } + if !slot.tenant_configured { + slot.tenant_configured = true; + self.save(cm, &slot).await?; + } + let receipt = Self::receipt(&slot)?; + let pod = self + .api::() + .get(&self.name) + .await + .map_err(backend_error)?; + if pod.metadata.uid.as_deref() != receipt["computeUid"].as_str() { + return Err(conflict()); + } + let terminated = pod + .status + .as_ref() + .and_then(|s| s.init_container_statuses.as_ref()) + .and_then(|statuses| statuses.iter().find(|s| s.name == INIT_NAME)) + .and_then(|s| s.state.as_ref()) + .and_then(|s| s.terminated.as_ref()); + let Some(terminated) = terminated else { + return Ok(()); + }; + if terminated.exit_code != 0 { + return Err(Status::failed_precondition( + "retained-pod single-writer initialization failed; supervisor remains unreleased", + )); + } + let proof: Value = serde_json::from_str(terminated.message.as_deref().unwrap_or_default()) + .map_err(|_| conflict())?; + // Gate hashes a serde_json Value, whose object key order matches this + // serialization. Configuration itself remains the gateway canonical bytes. + if proof + != json!({"version":1,"slotId":self.identity.slot_id,"generation":self.identity.generation, + "podUid":receipt["computeUid"],"sandboxId":self.identity.workload_id, + "allocationId":allocation["id"],"grantHash":grant_hash}) + { + return Err(conflict()); + } + let (cm, mut slot) = self.load().await?; + if slot.phase != Phase::Binding { + return Err(conflict()); + } + slot.initialized = true; + self.save(cm, &slot).await?; + if !tenant::bindings_ready(self, &slot).await? { + return Err(Status::failed_precondition( + "ordinary provider configuration changed or expired before launch; supervisor remains unreleased", + )); + } + self.adopt(&sandbox).await?; + self.grant("launch", &grant).await?; + let (cm, mut slot) = self.load().await?; + if slot.phase != Phase::Binding { + return Err(conflict()); + } + // No outstanding CREATE may survive into a completed binding. + if slot + .tickets + .values() + .any(|ticket| ticket.issued && ticket.uid.is_none()) + { + return Err(quarantine()); + } + slot.launch_released = true; + slot.phase = Phase::Bound; + self.save(cm, &slot).await?; + self.finalize_bound().await + } + fn check_profile(&self, slot: &Slot) -> Result<(), Status> { + validate_preparation(self.config, &json!({"preparation":slot.preparation}))?; + if slot.profile_hash != profile_hash(self.config)? { + return Err(Status::failed_precondition( + "prepared native driver profile changed; original profile is required, never adapt retained resources in place", + )); + } + Ok(()) + } + async fn finalize_bound(&self) -> Result<(), Status> { + use prost::Message; + // Until the private launch grant exists, ordinary lifecycle/exec calls + // cannot race this initial handoff. + let id = self.identity.workload_id.to_string(); + let record = self + .state + .store + .get(Sandbox::object_type(), &id) + .await + .map_err(|_| conflict())? + .ok_or_else(conflict)?; + let sandbox = Sandbox::decode(record.payload.as_slice()).map_err(|_| conflict())?; + if sandbox + .metadata + .as_ref() + .and_then(|m| m.annotations.get(PENDING)) + .map(String::as_str) + != Some("true") + { + return Ok(()); + } + let (_, slot) = self.load().await?; + if slot.phase != Phase::Bound { + return Err(conflict()); + } + self.check_profile(&slot)?; + let pod = self + .api::() + .get(&self.name) + .await + .map_err(backend_error)?; + if pod.metadata.uid.as_deref() != slot.tickets["compute"].uid.as_deref() { + return Err(conflict()); + } + let driver = + crate::compute::driver_sandbox_from_public(&sandbox, "kubernetes").map_err(|s| *s)?; + let ordinary = self + .driver + .render_sandbox(&driver) + .await + .map_err(|_| conflict())?; + let mut cr = self + .sandbox_api(&slot.api_version) + .get(&self.name) + .await + .map_err(backend_error)?; + if cr.metadata.uid.as_deref() != slot.tickets["sandbox"].uid.as_deref() { + return Err(conflict()); + } + cr.data["spec"]["podTemplate"] = ordinary.data["spec"]["podTemplate"].clone(); + self.sandbox_api(&slot.api_version) + .replace(&self.name, &PostParams::default(), &cr) + .await + .map_err(backend_error)?; + self.state + .store + .update_message_cas::(&id, record.resource_version, |sandbox| { + if let Some(meta) = sandbox.metadata.as_mut() { + meta.annotations.remove(PENDING); + } + }) + .await + .map_err(|_| Status::aborted("bound sandbox metadata changed; retry"))?; + Ok(()) + } + async fn stamp(&self, kind: &str) -> Result<(), Status> { + let (_, slot) = self.load().await?; + if slot.phase != Phase::Binding { + return Err(conflict()); + } + let allocation = slot.allocation.as_ref().ok_or_else(conflict)?; + let object = self.object(kind, &slot).await?.ok_or_else(conflict)?; + if object["metadata"]["uid"].as_str() + != slot.tickets.get(kind).and_then(|t| t.uid.as_deref()) + { + return Err(conflict()); + } + let meta: kube::core::ObjectMeta = + serde_json::from_value(object["metadata"].clone()).map_err(|_| conflict())?; + let stamped = meta + .labels + .as_ref() + .is_some_and(|l| l.contains_key(ALLOCATION)); + self.check_labels(&meta, if stamped { Some(allocation) } else { None })?; + // Preserve newer Kubernetes fields (notably image volumes) that the + // typed client's older Pod schema cannot round-trip. + let patch = json!({"metadata":{"uid":meta.uid,"resourceVersion":meta.resource_version, + "labels":{(ALLOCATION):allocation["id"],(OWNER):allocation["ownershipMarker"]}, + "annotations":{(openshell_core::driver_utils::ANNOTATION_BOUND_WORKSPACE):allocation["workspaceName"]}}}); + match kind { + "compute" => { + self.api::() + .patch(&self.name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .map_err(backend_error)?; + } + "storage" => { + self.api::() + .patch( + slot.storage["claimName"].as_str().ok_or_else(conflict)?, + &PatchParams::default(), + &Patch::Merge(&patch), + ) + .await + .map_err(backend_error)?; + } + _ => return Err(conflict()), + } + Ok(()) + } + async fn grant(&self, kind: &str, grant: &Value) -> Result<(), Status> { + let (cm, slot) = self.load().await?; + if slot.phase != Phase::Binding { + return Err(conflict()); + } + let mut labels = self.labels(); + // Grant resources remain platform-owned. Tenant markers are on the + // actual compute/storage and ordinary native records, not these secrets. + labels.insert(GRANT.into(), kind.into()); + self.create_once( + kind, + json!({"apiVersion":"v1","kind":"Secret","metadata":{ + "name":format!("{}-{kind}",self.name),"namespace":self.config.namespace,"labels":labels, + "ownerReferences":[self.owner(&cm)?]},"immutable":true, + "stringData":{"grant.json":serde_json::to_string(grant).map_err(|_|conflict())?}}), + ) + .await?; + let mut pod = self + .api::() + .get(&self.name) + .await + .map_err(backend_error)?; + if pod.metadata.uid.as_deref() + != slot + .tickets + .get("compute") + .and_then(|ticket| ticket.uid.as_deref()) + { + return Err(conflict()); + } + // Trigger projected-secret refresh without changing container environment + // or waiting for kubelet's periodic volume reconciliation. + let publication = format!( + "{kind}:{}", + slot.grant_hash.as_deref().ok_or_else(conflict)? + ); + let annotations = pod.metadata.annotations.get_or_insert_with(BTreeMap::new); + if annotations.get(GRANT) != Some(&publication) { + annotations.insert(GRANT.into(), publication); + let patch = json!({"metadata":{"uid":pod.metadata.uid,"resourceVersion":pod.metadata.resource_version, + "annotations":{(GRANT):annotations[GRANT]}}}); + self.api::() + .patch(&self.name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .map_err(backend_error)?; + } + Ok(()) + } + async fn adopt(&self, sandbox: &Sandbox) -> Result<(), Status> { + let (_, slot) = self.load().await?; + if slot.phase != Phase::Binding || !slot.initialized || !slot.tenant_configured { + return Err(conflict()); + } + let driver = + crate::compute::driver_sandbox_from_public(sandbox, "kubernetes").map_err(|s| *s)?; + let ordinary = self.driver.render_sandbox(&driver).await.map_err(|_| { + Status::failed_precondition("ordinary retained sandbox rendering failed") + })?; + let mut cr = self + .sandbox_api(&slot.api_version) + .get(&self.name) + .await + .map_err(backend_error)?; + if cr.metadata.uid.as_deref() != slot.tickets["sandbox"].uid.as_deref() { + return Err(conflict()); + } + // Keep the held, original-Pod-bound startup template until publication. + // If the pod disappears mid-bind, a controller-created replacement cannot + // consume the original UID-bound grants and is never reported as warm. + cr.metadata.labels = ordinary.metadata.labels; + cr.metadata.annotations = ordinary.metadata.annotations; + cr.metadata + .annotations + .get_or_insert_with(BTreeMap::new) + .insert("agents.x-k8s.io/pod-name".into(), self.name.clone()); + if slot.api_version == "v1beta1" { + cr.data["spec"]["operatingMode"] = json!("Running"); + } else { + cr.data["spec"]["replicas"] = json!(1); + } + let cr = self + .sandbox_api(&slot.api_version) + .replace(&self.name, &PostParams::default(), &cr) + .await + .map_err(backend_error)?; + let mut pod = self + .api::() + .get(&self.name) + .await + .map_err(backend_error)?; + if pod.metadata.uid.as_deref() != slot.tickets["compute"].uid.as_deref() { + return Err(conflict()); + } + pod.metadata.owner_references=Some(serde_json::from_value(json!([{"apiVersion":format!("agents.x-k8s.io/{}",slot.api_version), + "kind":"Sandbox","name":self.name,"uid":cr.metadata.uid,"controller":true,"blockOwnerDeletion":true}])).map_err(|_|conflict())?); + let patch = json!({"metadata":{"uid":pod.metadata.uid,"resourceVersion":pod.metadata.resource_version, + "ownerReferences":pod.metadata.owner_references}}); + self.api::() + .patch(&self.name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .map_err(backend_error)?; + Ok(()) + } + async fn result(&self, slot: &Slot) -> Result { + use crate::policy_store::PolicyStoreExt; + let allocation = slot.allocation.as_ref().ok_or_else(conflict)?; + let id = self.identity.workload_id.to_string(); + let sandbox = self + .state + .store + .get_message::(&id) + .await + .map_err(|_| conflict())? + .ok_or_else(conflict)?; + let current = sandbox + .metadata + .as_ref() + .and_then(|m| m.annotations.get("globster.dev/spec-hash")) + .map(String::as_str) + == allocation["fingerprint"].as_str(); + let latest = self + .state + .store + .get_latest_policy(&id) + .await + .map_err(|_| conflict())?; + let actual_policy = super::super::policy::current_base_policy_for_sandbox( + self.state.store.as_ref(), + &sandbox, + ) + .await?; + let policy_matches = sandbox + .spec + .as_ref() + .and_then(|spec| spec.policy.as_ref()) + .is_some_and(|expected| expected.network_policies == actual_policy.network_policies); + let policy_loaded = policy_matches + && latest + .as_ref() + .is_some_and(|policy| policy.status == "loaded"); + let connected = self.state.supervisor_sessions.has_session(&id); + let bindings_ready = tenant::bindings_ready(self, slot).await?; + let converged = current && policy_loaded && connected && bindings_ready; + let components:Vec<_>=[("compute","compute"),("policy","policy"),("secret-bindings","secretBindings")].iter().map(|(component,key)|json!({ + "component":component,"action":"create","state":if converged {"converged"}else{"pending"}, + "desiredFingerprint":allocation["fingerprints"][*key],"observedFingerprint":if converged {allocation["fingerprints"][*key].clone()}else{Value::Null} + })).collect(); + let healthy = if converged { + self.runtime_healthy().await + } else { + false + }; + let pod = self + .api::() + .get(&self.name) + .await + .map_err(backend_error)?; + if pod.metadata.uid.as_deref() != slot.tickets["compute"].uid.as_deref() { + return Err(conflict()); + } + let mut status = json!({"organizationId":allocation["organizationId"], + "state":if healthy {"ready"}else{"starting"},"nativeSupervisorConnected":connected, + "nativePolicyLoaded":policy_loaded,"runtimeHealthVerified":healthy}); + if healthy { + status["runtimeEndpoint"] = json!(format!( + "/v1/organizations/{}/workloads/{}/runtime", + allocation["organizationId"].as_str().ok_or_else(conflict)?, + self.identity.workload_id + )); + } + Ok( + json!({"schemaVersion":1,"desiredRevision":allocation["desiredRevision"], + "observedRevision":if converged {allocation["desiredRevision"].clone()}else{Value::Null}, + "converged":converged,"ready":healthy,"computeReplaced":false,"processRefreshRequired":false, + "components":components,"status":status}), + ) + } + async fn runtime_healthy(&self) -> bool { + use futures::StreamExt; + use openshell_core::proto::{ExecSandboxRequest, ExposeServiceRequest, exec_sandbox_event}; + let check = async { + // Reuse ordinary authenticated SSH/exec and the agent's actual boot + // probe inside its namespace; PodReady alone is not workload readiness. + let request=ExecSandboxRequest{sandbox_id:self.identity.workload_id.to_string(), + command:vec!["/app/.venv/bin/python".into(),"-I".into(),"-c".into(), + "import json,urllib.request,sys; r=urllib.request.build_opener(urllib.request.ProxyHandler({})).open('http://127.0.0.1:9000/health/ready',timeout=3); sys.exit(0 if r.status==200 and json.loads(r.read(4096))=={'status':'ready'} else 1)".into()], + environment:HashMap::from_iter(["LD_PRELOAD","LD_LIBRARY_PATH","LD_AUDIT","BASH_ENV","ENV","GCONV_PATH"] + .map(|key|(key.into(),String::new()))), + timeout_seconds:5,..Default::default()}; + let response = super::super::sandbox::handle_exec_sandbox( + self.state, + tenant::authed(self.principal, request), + ) + .await + .ok()?; + let mut stream = response.into_inner(); + let mut success = false; + while let Some(event) = stream.next().await { + if let Some(exec_sandbox_event::Payload::Exit(exit)) = event.ok()?.payload { + success = exit.exit_code == 0; + break; + } + } + if !success { + return None; + } + let (_, slot) = self.load().await.ok()?; + let allocation = slot.allocation.as_ref()?; + let request = ExposeServiceRequest { + sandbox: tenant::runtime_name(self.identity.workload_id), + service: "globster-runtime".into(), + target_port: 9000, + domain: false, + workspace: allocation["workspaceName"].as_str()?.into(), + }; + super::super::service::handle_expose_service( + self.state, + tenant::authed(self.principal, request), + ) + .await + .ok()?; + Some(()) + }; + tokio::time::timeout(Duration::from_secs(10), check) + .await + .ok() + .flatten() + .is_some() + } + async fn retire(&self, input: &Value) -> Result<(), Status> { + if let Some(receipt) = input.get("resourceReceipt").filter(|v| !v.is_null()) { + let (_, slot) = self.load().await?; + if Self::receipt(&slot)? != *receipt { + return Err(conflict()); + } + } + let reservation = self.reserve(input, true).await?; + if !reservation.fence.issued { + return Ok(()); + } + if reservation.fence.rejected { + if self + .api::() + .get_opt(&self.name) + .await + .map_err(backend_error)? + .is_some() + { + return Err(conflict()); + } + return Ok(()); + } + // The original authority CREATE can itself be indeterminate. + self.ensure_fence().await?; + let (cm, mut slot) = self.load().await?; + if matches!(slot.phase, Phase::Binding | Phase::Bound) { + return Err(conflict()); + } + if let Some(receipt) = input.get("resourceReceipt").filter(|v| !v.is_null()) + && Self::receipt(&slot)? != *receipt + { + return Err(conflict()); + } + if slot.phase == Phase::Retired { + return Ok(()); + } + slot.phase = Phase::Retiring; + self.save(cm, &slot).await?; + let (_, slot) = self.load().await?; + for kind in ["sandbox", "compute", "storage"] { + if slot + .tickets + .get(kind) + .is_some_and(|t| t.issued && t.uid.is_none() && !t.rejected) + { + self.settle(kind, self.object(kind, &slot).await?).await?; + } + } + // Remove the controller before its pod: it must never recreate compute + // after an absence check. A settled one-shot ticket forbids native replay. + let sandbox_absent = self.delete_exact("sandbox").await?; + let pod_absent = self.delete_exact("compute").await?; + if !sandbox_absent || !pod_absent { + return Ok(()); + } + if !self.delete_exact("storage").await? { + return Ok(()); + } + let (cm, mut slot) = self.load().await?; + if slot.phase != Phase::Retiring { + return Err(conflict()); + } + slot.phase = Phase::Retired; + self.save(cm, &slot).await + } + async fn delete_exact(&self, kind: &str) -> Result { + let (_, slot) = self.load().await?; + if slot.phase != Phase::Retiring { + return Err(conflict()); + } + let Some(object) = self.object(kind, &slot).await? else { + return Ok(true); + }; + let ticket = slot.tickets.get(kind).ok_or_else(conflict)?; + if object["metadata"]["uid"].as_str() != ticket.uid.as_deref() { + return Err(conflict()); + } + let params = DeleteParams { + preconditions: Some(Preconditions { + uid: ticket.uid.clone(), + resource_version: object["metadata"]["resourceVersion"] + .as_str() + .map(str::to_owned), + }), + ..Default::default() + }; + let result = match kind { + "sandbox" => self + .sandbox_api(&slot.api_version) + .delete(&self.name, ¶ms) + .await + .map(|_| ()), + "compute" => self + .api::() + .delete(&self.name, ¶ms) + .await + .map(|_| ()), + "storage" => self + .api::() + .delete( + slot.storage["claimName"].as_str().ok_or_else(conflict)?, + ¶ms, + ) + .await + .map(|_| ()), + _ => return Err(conflict()), + }; + match result { + Ok(()) => {} + Err(kube::Error::Api(a)) if a.code == 404 => {} + Err(e) => return Err(backend_error(e)), + } + Ok(self.object(kind, &slot).await?.is_none()) + } + async fn observe(&self) -> Result { + let (reservation, _) = self.reservation().await?; + if reservation.retire_requested && (!reservation.fence.issued || reservation.fence.rejected) + { + if self + .api::() + .get_opt(&self.name) + .await + .map_err(backend_error)? + .is_some() + { + return Err(conflict()); + } + return Ok(self.evidence_without_resources(&reservation, "retired")); + } + if !reservation.fence.issued { + return Ok(self.evidence_without_resources(&reservation, "preparing")); + } + self.ensure_fence().await?; + let (cm, mut slot) = self.load().await?; + for kind in ["sandbox", "storage", "compute", "init", "launch"] { + if slot + .tickets + .get(kind) + .is_some_and(|t| t.issued && t.uid.is_none() && !t.rejected) + { + self.settle(kind, self.object(kind, &slot).await?).await?; + } + } + if slot.phase == Phase::Preparing && self.held_running().await? { + slot.phase = Phase::Ready; + self.save(cm, &slot).await?; + } + let (_, slot) = self.load().await?; + if slot.phase == Phase::Ready && !self.held_running().await? { + let (cm, mut current) = self.load().await?; + if current.phase == Phase::Ready { + // Permanently fence lost/expired readiness, while retaining + // truthful receipts so the owner can invoke normal retirement. + current.phase = Phase::Retiring; + self.save(cm, ¤t).await?; + } + } + let (_, slot) = self.load().await?; + if !matches!(slot.phase, Phase::Retiring | Phase::Retired) { + self.check_resources(&slot).await?; + } + if slot.phase == Phase::Retired && Self::receipt(&slot).is_err() { + if self.object("compute", &slot).await?.is_some() + || self.object("storage", &slot).await?.is_some() + { + return Err(conflict()); + } + return Ok(self.evidence_without_resources(&reservation, "retired")); + } + if slot.phase == Phase::Binding { + self.stamp("storage").await?; + self.stamp("compute").await?; + self.resume_binding().await?; + } + if slot.phase == Phase::Bound { + self.finalize_bound().await?; + } + let (_, slot) = self.load().await?; + let receipt = Self::receipt(&slot)?; + let mut compute_absent = false; + let mut storage_absent = false; + if !matches!(slot.phase, Phase::Retiring | Phase::Retired) { + let sandbox = self.object("sandbox", &slot).await?.ok_or_else(conflict)?; + if sandbox["metadata"]["uid"] != receipt["sandboxUid"] + || !sandbox["metadata"]["deletionTimestamp"].is_null() + { + return Err(conflict()); + } + } + for (kind, field) in [("compute", "computeUid"), ("storage", "storageUid")] { + match self.object(kind, &slot).await? { + Some(object) => { + if object["metadata"]["uid"] != receipt[field] { + return Err(conflict()); + } + // During binding, stamping both resources is resumable. + if slot.phase != Phase::Binding { + self.check_labels( + &serde_json::from_value(object["metadata"].clone()) + .map_err(|_| conflict())?, + slot.allocation.as_ref(), + )?; + } + } + None => { + if kind == "compute" { + compute_absent = true; + } else { + storage_absent = true; + } + } + } + } + if !matches!(slot.phase, Phase::Retiring | Phase::Retired) + && (compute_absent || storage_absent) + { + return Err(conflict()); + } + let observation = json!({"schemaVersion":1,"slotId":slot.identity.slot_id,"workloadId":slot.identity.workload_id, + "generation":slot.identity.generation,"preparation":slot.preparation,"compatibilityKey":compatibility(&slot.preparation), + "state":match slot.phase {Phase::Preparing=>"preparing",Phase::Ready=>"idle_ready",Phase::Binding=>"binding", + Phase::Bound=>"bound",Phase::Retiring=>"retiring",Phase::Retired=>"retired"}, + "resourceReceipt":receipt,"launchReleased":slot.launch_released, + "allocation":slot.allocation.as_ref().map(|a|json!({"id":a["id"],"organizationId":a["organizationId"],"desiredRevision":a["desiredRevision"],"fingerprint":a["fingerprint"]})), + "result": if slot.phase==Phase::Bound {self.result(&slot).await?} else {Value::Null}}); + Ok( + json!({"schemaVersion":1,"observation":observation,"resources":{"namespace":self.config.namespace, + "podName":self.name,"claimName":slot.storage["claimName"],"receipt":receipt}, + "fenced":true,"tenantConfigured":slot.tenant_configured,"singleWriterInitialized":slot.initialized, + "computeAbsent":compute_absent,"storageAbsent":storage_absent}), + ) + } + fn evidence_without_resources(&self, reservation: &Reservation, phase: &str) -> Value { + json!({"schemaVersion":1,"observation":{"schemaVersion":1,"slotId":self.identity.slot_id, + "workloadId":self.identity.workload_id,"generation":self.identity.generation, + "preparation":reservation.preparation,"compatibilityKey":compatibility(&reservation.preparation), + "state":phase,"resourceReceipt":null,"launchReleased":false,"allocation":null,"result":null}, + "resources":null,"fenced":true,"tenantConfigured":false,"singleWriterInitialized":false,"computeAbsent":true,"storageAbsent":true}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assistant_v2_is_unsupported_before_inspecting_identity_or_tenant_input() { + let error = identity(&json!({"schemaVersion":2, + "request":{"credentialWrites":[{"value":"never-echo-this"}]}})) + .err() + .unwrap(); + assert_eq!(error.code(), tonic::Code::Unimplemented); + assert!(!error.message().contains("never-echo-this")); + } + + #[test] + fn settled_assistant_v2_preparation_is_not_a_legacy_recipe() { + // Gateway #250, bec528d52f978204f4acedd78a811823dae8e1a8: + // docs/prewarm-native-contract.md and test/prewarm-slots.test.ts. + let preparation = json!({ + "schemaVersion":2,"runtime":"assistant", + "providerArtifact":format!("registry.example/openshell@sha256:{}","a".repeat(64)), + "placementKey":"test/canadacentral/standard", + "image":format!("registry.example/assistant@sha256:{}","b".repeat(64)), + "serviceVersion":"1.2.3", + "resources":{"cpu":"250m","memory":"512Mi"},"workspace":{"capacity":"1Gi"} + }); + let mut digest = Sha256::new(); + digest.update(b"globster/prewarm-preparation/v2\0"); + digest.update(canonical(&preparation)); + assert_eq!( + format!("sha256:{:x}", digest.finalize()), + "sha256:a2e775a7361d99388a7b6a3ec2684adc4a70ba43875f0d4bd100aa5fc0910cdf" + ); + assert!(preparation_shape(&preparation).is_err()); + let input = json!({"schemaVersion":2,"slotId":"11111111-1111-4111-8111-111111111111", + "workloadId":"22222222-2222-4222-8222-222222222222","generation":1, + "preparation":preparation}); + assert_eq!( + identity(&input).err().unwrap().code(), + tonic::Code::Unimplemented + ); + } + + #[test] + fn retained_resource_checks_reject_replacements_and_terminating_resources() { + let tickets = ["compute", "storage", "sandbox"].map(|kind| { + ( + kind.to_owned(), + Ticket { + issued: true, + uid: Some(format!("original-{kind}")), + rejected: false, + }, + ) + }); + let tickets = BTreeMap::from(tickets); + let original = ["compute", "storage", "sandbox"] + .map(|kind| json!({"metadata":{"uid":format!("original-{kind}")}})); + let check = |objects: &[Value; 3]| { + retained_identities( + &tickets, + [ + ("compute", &objects[0]), + ("storage", &objects[1]), + ("sandbox", &objects[2]), + ], + ) + }; + check(&original).unwrap(); + for index in 0..original.len() { + let mut replaced = original.clone(); + replaced[index]["metadata"]["uid"] = json!("replacement"); + assert_eq!( + check(&replaced).unwrap_err().code(), + tonic::Code::FailedPrecondition + ); + let mut terminating = original.clone(); + terminating[index]["metadata"]["deletionTimestamp"] = json!("2026-10-25T11:00:00Z"); + assert!(check(&terminating).is_err()); + } + } + + #[test] + fn prepared_profile_changes_are_not_in_place_compatibility() { + let original = KubernetesComputeConfig::default(); + let expected = profile_hash(&original).unwrap(); + for value in [ + json!({"topology":"sidecar"}), + json!({"supervisor_image":format!("registry.example/supervisor@sha256:{}","a".repeat(64))}), + json!({"process_bootstrap":["/usr/local/bin/assistant-computer-bootstrap"]}), + json!({"workspace_storage_class":"different-storage"}), + ] { + let config = serde_json::from_value::(value).unwrap(); + assert_ne!(profile_hash(&config).unwrap(), expected); + } + let mut disabled = serde_json::to_value(&original).unwrap(); + disabled["prewarm"] = json!({"enabled":false,"provider_artifact":"fixture", + "placement_key":"fixture","image":"fixture","recipe_id":"fixture", + "recipe_digest":"fixture","recipe_image":"fixture"}); + let disabled = serde_json::from_value::(disabled).unwrap(); + assert_eq!(profile_hash(&disabled).unwrap(), expected); + } + + #[test] + fn unsupported_sidecar_identity_and_spiffe_profiles_fail_closed() { + let preparation = json!({"preparation":{ + "schemaVersion":1,"providerArtifact":"not-an-artifact-approval","placementKey":"local-fixture", + "image":format!("registry.example/agent@sha256:{}","a".repeat(64)), + "recipe":{"id":"fixture","digest":format!("sha256:{}","b".repeat(64)), + "image":format!("registry.example/recipe@sha256:{}","c".repeat(64))}, + "resources":{"cpu":"1000m","memory":"512Mi"},"workspace":{"capacity":"1Gi"}}}); + let mut profile = json!({ + "sandbox_uid":1000,"sandbox_gid":1000, + "supervisor_image":format!("registry.example/supervisor@sha256:{}","d".repeat(64)), + "prewarm":{"enabled":true,"provider_artifact":"not-an-artifact-approval", + "placement_key":"local-fixture","image":preparation["preparation"]["image"], + "recipe_id":"fixture","recipe_digest":preparation["preparation"]["recipe"]["digest"], + "recipe_image":preparation["preparation"]["recipe"]["image"]} + }); + let config = serde_json::from_value::(profile.clone()).unwrap(); + validate_preparation(&config, &preparation).unwrap(); + for (field, value) in [ + ("topology", json!("sidecar")), + ("workspace_mode", json!("managed")), + ("sandbox_uid", json!(2000)), + ("sandbox_gid", json!(2000)), + ("sandbox_uid", Value::Null), + ("sandbox_gid", Value::Null), + ( + "provider_spiffe_workload_api_socket_path", + json!("/spiffe/agent.sock"), + ), + ] { + let previous = profile[field].clone(); + profile[field] = value; + let config = + serde_json::from_value::(profile.clone()).unwrap(); + assert_eq!( + validate_preparation(&config, &preparation) + .unwrap_err() + .code(), + tonic::Code::Unimplemented + ); + if previous.is_null() { + profile.as_object_mut().unwrap().remove(field); + } else { + profile[field] = previous; + } + } + } + + #[test] + fn canonical_matches_ecmascript_numbers_and_utf16_keys() { + assert_eq!( + canonical(&json!({"z":1.0,"a":[1e21,1e-7,1e-6,-0.0]})), + r#"{"a":[1e+21,1e-7,0.000001,0],"z":1}"# + ); + assert_eq!( + canonical(&json!({"\u{10000}":1,"\u{e000}":2})), + "{\"\u{10000}\":1,\"\u{e000}\":2}" + ); + } + #[test] + fn issued_or_rejected_creations_are_never_replayed() { + assert!(Ticket::default().may_issue().unwrap()); + for uid in [None, Some("original-resource-uid".into())] { + assert!( + !Ticket { + issued: true, + uid, + rejected: false + } + .may_issue() + .unwrap() + ); + } + assert!( + Ticket { + issued: true, + uid: None, + rejected: true + } + .may_issue() + .is_err() + ); + } +} diff --git a/crates/openshell-server/src/grpc/prewarm/native/resource_tests.rs b/crates/openshell-server/src/grpc/prewarm/native/resource_tests.rs new file mode 100644 index 0000000000..ee6beff673 --- /dev/null +++ b/crates/openshell-server/src/grpc/prewarm/native/resource_tests.rs @@ -0,0 +1,452 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Native resource proof, deliberately not a customer/agent readiness test. + +use super::*; +use crate::grpc::test_support::{authed_request, test_server_state}; +use openshell_core::proto::PrewarmSlotRequest; + +async fn state( + config: KubernetesComputeConfig, + store: Option>, +) -> Arc { + let original = test_server_state().await; + let mut state = Arc::try_unwrap(original).unwrap(); + if let Some(store) = store { + state.store = store; + } + let (_shutdown, rx) = tokio::sync::watch::channel(false); + state.compute = crate::compute::ComputeRuntime::new_kubernetes( + config, + state.store.clone(), + state.sandbox_index.clone(), + state.sandbox_watch_bus.clone(), + state.tracing_log_bus.clone(), + state.supervisor_sessions.clone(), + rx, + ) + .await + .unwrap() + .0; + Arc::new(state) +} + +async fn call( + state: &Arc, + operation: Operation, + input: &Value, +) -> Result { + let identity = json!({"schemaVersion":input["schemaVersion"],"slotId":input["slotId"], + "workloadId":input["workloadId"],"generation":input["generation"]}); + let input = if matches!(operation, Operation::Observe) { + &identity + } else { + input + }; + let response = super::super::handle_operation( + state, + authed_request(PrewarmSlotRequest { + request_json: input.to_string(), + }), + operation, + ) + .await?; + Ok(serde_json::from_str(&response.into_inner().evidence_json).unwrap()) +} + +async fn retry(state: &Arc, operation: Operation, input: &Value) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + match call(state, operation, input).await { + Ok(value) => return value, + Err(error) + if matches!( + error.code(), + tonic::Code::Aborted | tonic::Code::Unavailable + ) && tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(200)).await; + } + Err(error) => panic!("native fixture operation failed: {error}"), + } + } +} + +fn prepare(config: &KubernetesComputeConfig) -> Value { + let approved = config.prewarm.as_ref().unwrap(); + let workload = Uuid::new_v4(); + let mut storage = json!({"namespace":config.namespace,"claimName":format!("globster-workspace-{workload}"),"accessModes":["ReadWriteOnce"]}); + if !config.workspace_storage_class.is_empty() { + storage["storageClassName"] = json!(config.workspace_storage_class); + } + json!({"schemaVersion":1,"slotId":Uuid::new_v4(),"workloadId":workload,"generation":1, + "preparation":{"schemaVersion":1,"providerArtifact":approved.provider_artifact, + "placementKey":approved.placement_key,"image":approved.image, + "recipe":{"id":approved.recipe_id,"digest":approved.recipe_digest,"image":approved.recipe_image}, + "resources":{"cpu":"1000m","memory":"512Mi"},"workspace":{"capacity":"1Gi"}},"storage":storage}) +} + +fn binding(prepare: &Value, receipt: &Value) -> Value { + let organization = "native-resource-fixture"; + let id = prepare["workloadId"].as_str().unwrap(); + let owner = format!( + "{:x}", + Sha256::digest(format!("globster/organization-owner/v1\0{organization}")) + )[..32] + .to_owned(); + let workspace = format!( + "gw-{}", + tenant::compact(&Sha256::digest(format!( + "globster/workspace/v1\0{organization}" + ))) + ); + let mut desired = json!({"schemaVersion":1,"revision":1,"powerState":"running", + "image":prepare["preparation"]["image"],"recipe":prepare["preparation"]["recipe"], + "resources":prepare["preparation"]["resources"],"workspace":prepare["preparation"]["workspace"], + "policy":{"schemaVersion":1,"revision":1,"defaultAction":"deny","rules":[]},"secretBindings":[]}); + desired["fingerprints"] = tenant::fingerprints(&desired).unwrap(); + let mut input = prepare.clone(); + input["allocationId"] = json!(Uuid::new_v4()); + input["resourceReceipt"] = receipt.clone(); + input["request"] = json!({"schemaVersion":1,"desired":desired,"credentialWrites":[]}); + input["organization"] = + json!({"organizationId":organization,"workspaceName":workspace,"ownershipMarker":owner}); + input["launch"] = json!({"command":["/app/.venv/bin/python","-m","agent_harness","serve","--headless","--host","0.0.0.0","--port","9000"], + "environment":{"AGENT_DATA_DIR":"/workspace","AGENT_ID":id,"GLOBSTER_ORGANIZATION_ID":organization, + "AGENT_STORAGE_ROOT":format!("/workspace/{id}/workspace"),"AGENT_HOST_CREDENTIAL_REFERENCE_TEMPLATE":"openshell:resolve:env:{credential}","HOME":"/workspace"}, + "networkPolicies":{},"platformPolicyRevision":1,"workspaceMountPath":"/workspace", + "persistenceMountPath":"/sandbox/persist","persistenceSubPath":format!("{id}/.globster-openshell-persist")}); + input +} + +#[tokio::test] +#[ignore = "requires isolated Kubernetes and explicitly approved immutable native gate fixtures; not customer proof"] +async fn retained_prewarm_resources_and_cross_replica_fences() { + let _ = rustls::crypto::ring::default_provider().install_default(); + assert_eq!( + kube::config::Kubeconfig::read() + .unwrap() + .current_context + .as_deref(), + Some("kind-globster-prewarm-native"), + "refusing an unapproved Kubernetes context" + ); + let filename = std::env::var("OPENSHELL_PREWARM_RESOURCE_TEST_CONFIG") + .expect("explicit isolated fixture configuration required"); + let config: KubernetesComputeConfig = + serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap(); + assert!( + config.namespace.starts_with("native-prewarm-fixture-"), + "refusing non-fixture namespace" + ); + let store_path = std::path::Path::new(&filename) + .parent() + .unwrap() + .join(format!("native-resource-proof-{}.sqlite", Uuid::new_v4())); + let store_url = format!("sqlite://{}?mode=rwc", store_path.display()); + let open_store = || async { + Arc::new( + crate::persistence::Store::connect(&store_url) + .await + .unwrap(), + ) + }; + let first = state(config.clone(), Some(open_store().await)).await; + let second = state(config.clone(), Some(open_store().await)).await; + let input = prepare(&config); + let mut evidence = retry(&first, Operation::Prepare, &input).await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + while evidence["observation"]["state"] != "idle_ready" { + assert!( + tokio::time::Instant::now() < deadline, + "held init did not become ready" + ); + tokio::time::sleep(Duration::from_secs(2)).await; + evidence = retry(&second, Operation::Observe, &input).await; + } + let receipt = evidence["resources"]["receipt"].clone(); + assert_eq!(evidence["tenantConfigured"], false); + assert!( + first + .store + .get_message::(input["workloadId"].as_str().unwrap()) + .await + .unwrap() + .is_none() + ); + assert_eq!( + call(&second, Operation::Prepare, &input).await.unwrap()["resources"]["receipt"], + receipt + ); + let mut bound = binding(&input, &receipt); + let mut wrong_receipt = bound.clone(); + wrong_receipt["resourceReceipt"]["computeUid"] = json!(Uuid::new_v4()); + assert_eq!( + call(&second, Operation::Bind, &wrong_receipt) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let mut stale_generation = input.clone(); + stale_generation["generation"] = json!(2); + assert_eq!( + call(&second, Operation::Observe, &stale_generation) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + assert_eq!( + call(&second, Operation::Retire, &stale_generation) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let unchanged = retry(&first, Operation::Observe, &input).await; + assert_eq!(unchanged["observation"]["state"], "idle_ready"); + assert_eq!(unchanged["tenantConfigured"], false); + assert_eq!(unchanged["resources"]["receipt"], receipt); + let mut competing = bound.clone(); + competing["allocationId"] = json!(Uuid::new_v4()); + let (left, right) = tokio::join!( + call(&first, Operation::Bind, &bound), + call(&second, Operation::Bind, &competing) + ); + assert!( + !(left.is_ok() && right.is_ok()), + "two allocations passed the resource-side fence" + ); + let allocated = retry(&second, Operation::Observe, &input).await; + if allocated["observation"]["allocation"]["id"] == competing["allocationId"] { + bound = competing; + } + assert_eq!( + allocated["observation"]["allocation"]["id"], + bound["allocationId"] + ); + retry(&first, Operation::Bind, &bound).await; + let mut conflict = bound.clone(); + conflict["allocationId"] = json!(Uuid::new_v4()); + assert_eq!( + call(&second, Operation::Bind, &conflict) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + loop { + evidence = retry(&second, Operation::Observe, &input).await; + if evidence["observation"]["state"] == "bound" { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "initialization did not complete" + ); + tokio::time::sleep(Duration::from_secs(1)).await; + } + assert_eq!(evidence["resources"]["receipt"], receipt); + assert_eq!(evidence["tenantConfigured"], true); + assert_eq!(evidence["singleWriterInitialized"], true); + assert_eq!(evidence["observation"]["launchReleased"], true); + assert_eq!( + evidence["observation"]["result"]["ready"], false, + "fixture is not an agent/harness customer proof" + ); + assert_eq!( + call(&first, Operation::Bind, &bound).await.unwrap()["resources"]["receipt"], + receipt + ); + let reopened = state(config.clone(), Some(open_store().await)).await; + assert_eq!( + call(&reopened, Operation::Observe, &input).await.unwrap()["resources"]["receipt"], + receipt + ); + assert_eq!( + call(&first, Operation::Retire, &input) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + let mut forbidden = first + .store + .get_message::(input["workloadId"].as_str().unwrap()) + .await + .unwrap() + .unwrap(); + let metadata = forbidden.metadata.as_mut().unwrap(); + metadata.id = Uuid::new_v4().to_string(); + metadata.name = "fixture-forbidden-writer".into(); + let forbidden = crate::compute::driver_sandbox_from_public(&forbidden, "kubernetes").unwrap(); + let error = first + .compute + .prewarm_driver + .as_ref() + .unwrap() + .create_sandbox(&forbidden) + .await + .unwrap_err(); + assert!( + error.to_string().contains("reserved native prewarm claim"), + "ordinary create did not enforce claim exclusivity" + ); + let mut cold_config = config.clone(); + cold_config.prewarm = None; + let (_shutdown, shutdown_rx) = tokio::sync::watch::channel(false); + let cold_driver = KubernetesComputeDriver::new(cold_config, shutdown_rx) + .await + .unwrap(); + let error = cold_driver.create_sandbox(&forbidden).await.unwrap_err(); + assert!( + error.to_string().contains("reserved native prewarm claim"), + "removing prewarm configuration disabled reserved-claim protection" + ); + println!( + "NATIVE_RESOURCE_BIND_PROOF workload={} receipt={} initialized=true retained=true customerReady=false", + input["workloadId"], receipt + ); + + let unused = prepare(&config); + let prepared = retry(&first, Operation::Prepare, &unused).await; + let unused_receipt = prepared["resources"]["receipt"].clone(); + let mut retirement = unused.clone(); + retirement["resourceReceipt"] = unused_receipt.clone(); + let mut wrong_retirement = retirement.clone(); + wrong_retirement["resourceReceipt"]["storageUid"] = json!(Uuid::new_v4()); + assert_eq!( + call(&second, Operation::Retire, &wrong_retirement) + .await + .unwrap_err() + .code(), + tonic::Code::FailedPrecondition + ); + assert_eq!( + retry(&first, Operation::Prepare, &unused).await["resources"]["receipt"], + unused_receipt + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + loop { + let retired = retry(&second, Operation::Retire, &retirement).await; + if retired["observation"]["state"] == "retired" { + assert_eq!(retired["computeAbsent"], true); + assert_eq!(retired["storageAbsent"], true); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "retirement did not complete" + ); + tokio::time::sleep(Duration::from_secs(1)).await; + } + assert!(call(&first, Operation::Prepare, &unused).await.is_err()); + println!( + "NATIVE_RESOURCE_RETIRE_PROOF workload={} receipt={} computeAbsent=true storageAbsent=true", + unused["workloadId"], unused_receipt + ); + + let uncertain = prepare(&config); + let driver = first.compute.prewarm_driver.as_ref().unwrap(); + let (client, config) = driver.prewarm_context(); + let principal = authed_request(()) + .extensions() + .get::() + .unwrap() + .clone(); + let backend = Backend { + state: &first, + driver, + principal: &principal, + config, + client, + identity: identity(&uncertain).unwrap(), + name: format!("prewarm-{}", uncertain["workloadId"].as_str().unwrap()), + }; + backend.reserve(&uncertain, false).await.unwrap(); + backend.ensure_fence().await.unwrap(); + let (cm, mut slot) = backend.load().await.unwrap(); + // Model a process crash after durable ticket issuance, before HTTP CREATE. + slot.tickets.insert( + "compute".into(), + Ticket { + issued: true, + uid: None, + rejected: false, + }, + ); + backend.save(cm, &slot).await.unwrap(); + assert_eq!( + call(&second, Operation::Prepare, &uncertain) + .await + .unwrap_err() + .code(), + tonic::Code::Unavailable + ); + assert_eq!( + call(&first, Operation::Retire, &uncertain) + .await + .unwrap_err() + .code(), + tonic::Code::Unavailable + ); + assert!( + backend + .api::() + .get_opt(&backend.name) + .await + .unwrap() + .is_none() + ); + let (_, slot) = backend.load().await.unwrap(); + assert!(slot.phase == Phase::Retiring && slot.quarantined); + assert!( + backend + .api::() + .get_opt(slot.storage["claimName"].as_str().unwrap()) + .await + .unwrap() + .is_some() + ); + println!( + "NATIVE_RESOURCE_QUARANTINE_PROOF workload={} recreated=false falselyRetired=false storagePreserved=true", + uncertain["workloadId"] + ); + // Deliver the original worker's delayed, single CREATE after retirement + // won its fence. Retirement must discover it before storage can disappear. + let (cm, slot) = backend.load().await.unwrap(); + let rendered = backend.neutral_sandbox(&slot.preparation).await.unwrap(); + let template = backend + .held_template(&rendered.data["spec"]["podTemplate"], &slot) + .unwrap(); + let pod:DynamicObject=serde_json::from_value(json!({"apiVersion":"v1","kind":"Pod","metadata":{ + "name":backend.name,"namespace":config.namespace,"labels":backend.labels(), + "annotations":template["metadata"]["annotations"],"ownerReferences":[backend.owner(&cm).unwrap()]}, + "spec":template["spec"]})).unwrap(); + backend + .pod_api() + .create(&PostParams::default(), &pod) + .await + .unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + loop { + let retired = retry(&second, Operation::Retire, &uncertain).await; + if retired["observation"]["state"] == "retired" { + assert_eq!(retired["computeAbsent"], true); + assert_eq!(retired["storageAbsent"], true); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "delayed creation was not settled" + ); + tokio::time::sleep(Duration::from_secs(1)).await; + } + println!( + "NATIVE_RESOURCE_DELAYED_CREATE_PROOF workload={} lateCreateSettled=true computeAbsent=true storageAbsent=true", + uncertain["workloadId"] + ); +} diff --git a/crates/openshell-server/src/grpc/prewarm/native/tenant.rs b/crates/openshell-server/src/grpc/prewarm/native/tenant.rs new file mode 100644 index 0000000000..f4d8f2474c --- /dev/null +++ b/crates/openshell-server/src/grpc/prewarm/native/tenant.rs @@ -0,0 +1,983 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Translation into ordinary `OpenShell` workspace/provider/policy contracts. +//! Credentials go through the normal credential driver, never tenant pod env. + +use super::{ + ALLOCATION, BTreeMap, Backend, Digest, HashMap, MANAGED, MANAGED_VALUE, OWNER, PENDING, Phase, + Principal, Sandbox, Sha256, Slot, Status, Uuid, Value, canonical, conflict, json, quarantine, +}; +use openshell_core::proto::{ + CreateProviderRequest, CreateSandboxRequest, CreateWorkspaceRequest, Provider, Workspace, +}; +use prost::Message; +use prost_reflect::{DescriptorPool, DynamicMessage}; +use tonic::Request; + +fn proto(name: &str, value: Value) -> Result { + let pool = + DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET).map_err(|_| conflict())?; + let descriptor = pool.get_message_by_name(name).ok_or_else(conflict)?; + let bytes = serde_json::to_vec(&value).map_err(|_| conflict())?; + let mut deserializer = serde_json::Deserializer::from_slice(&bytes); + let message = DynamicMessage::deserialize(descriptor, &mut deserializer) + .map_err(|_| Status::invalid_argument("invalid ordinary prewarm configuration"))?; + T::decode(message.encode_to_vec().as_slice()).map_err(|_| conflict()) +} + +pub(super) fn authed(principal: &Principal, value: T) -> Request { + let mut request = Request::new(value); + request.extensions_mut().insert(principal.clone()); + request +} + +pub(super) fn compact(bytes: &[u8]) -> String { + let alphabet = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut result = String::new(); + let mut acc = 0u32; + let mut bits = 0; + for byte in bytes.iter().take(10) { + acc = (acc << 8) | u32::from(*byte); + bits += 8; + while bits >= 5 { + bits -= 5; + result.push(alphabet[((acc >> bits) & 31) as usize] as char); + } + acc &= (1 << bits) - 1; + } + result +} + +pub(super) fn runtime_name(id: Uuid) -> String { + format!("gs-{}", compact(&Sha256::digest(id.to_string()))) +} + +fn fingerprint(component: &str, value: &Value) -> String { + let mut digest = Sha256::new(); + digest.update(format!("globster/agent-runtime/{component}/v1\0")); + digest.update(canonical(value)); + format!("sha256:{:x}", digest.finalize()) +} + +fn normalized_policy(value: &Value) -> Result { + if value["schemaVersion"] != 1 + || value["defaultAction"] != "deny" + || value["revision"].as_u64().is_none() + { + return Err(conflict()); + } + let mut rules = value["rules"].as_array().cloned().ok_or_else(conflict)?; + for rule in &mut rules { + if let Some(host) = rule["host"].as_str() { + rule["host"] = json!(host.trim().trim_end_matches('.').to_ascii_lowercase()); + } + for field in ["ports", "methods", "paths", "executables"] { + if let Some(array) = rule.get_mut(field).and_then(Value::as_array_mut) { + array.sort_by(|a, b| match (a.as_u64(), b.as_u64()) { + (Some(a), Some(b)) => a.cmp(&b), + _ => a + .as_str() + .unwrap_or_default() + .encode_utf16() + .cmp(b.as_str().unwrap_or_default().encode_utf16()), + }); + } + } + } + rules.sort_by(|a, b| { + a["id"] + .as_str() + .unwrap_or_default() + .encode_utf16() + .cmp(b["id"].as_str().unwrap_or_default().encode_utf16()) + }); + if rules.windows(2).any(|r| r[0]["id"] == r[1]["id"]) { + return Err(conflict()); + } + Ok(json!({"schemaVersion":1,"revision":value["revision"],"defaultAction":"deny","rules":rules})) +} + +fn translated_rule(rule: &Value) -> Result { + let (protocol, tls) = match rule["protocol"].as_str().ok_or_else(conflict)? { + "tcp" => ("", "passthrough"), + "http" => ("rest", "none"), + "https" => ("rest", "terminate"), + "websocket" => ("websocket", "none"), + "websocket-secure" => ("websocket", "terminate"), + "graphql" => ("graphql", "terminate"), + "json-rpc" => ("json-rpc", "terminate"), + "mcp" => ("mcp", "terminate"), + _ => return Err(conflict()), + }; + let mut l7 = Vec::new(); + if let Some(methods) = rule.get("methods") { + for method in methods.as_array().ok_or_else(conflict)? { + for path in rule["paths"].as_array().ok_or_else(conflict)? { + l7.push(json!({"allow":{"method":method,"path":path}})); + } + } + } + let paths = if rule.get("methods").is_none() { + rule.get("paths") + .and_then(Value::as_array) + .cloned() + .unwrap_or_else(|| vec![json!("")]) + } else { + vec![json!("")] + }; + let endpoints:Vec<_>=paths.into_iter().map(|path|json!({"host":rule["host"],"port":0,"ports":rule["ports"], + "protocol":protocol,"tls":tls,"enforcement":"enforce","access":"","rules":l7,"allowedIps":[],"path":path})).collect(); + let binaries: Vec<_> = rule["executables"] + .as_array() + .ok_or_else(conflict)? + .iter() + .map(|path| json!({"path":path})) + .collect(); + Ok(json!({"name":rule["id"],"endpoints":endpoints,"binaries":binaries})) +} + +fn ordered(value: &Value, keys: &[&str]) -> String { + format!( + "{{{}}}", + keys.iter() + .filter(|key| value.get(**key).is_some()) + .map(|key| format!("{}:{}", canonical(&json!(key)), canonical(&value[*key]))) + .collect::>() + .join(",") + ) +} + +fn effective_policy(input: &Value) -> Result { + let desired = &input["request"]["desired"]["policy"]; + let mut rules = normalized_policy(desired)?["rules"] + .as_array() + .cloned() + .ok_or_else(conflict)?; + for (key, rule) in input["launch"]["networkPolicies"] + .as_object() + .ok_or_else(conflict)? + { + let id = key.strip_prefix("globster__").ok_or_else(conflict)?; + if rules.iter().any(|r| r["id"] == id) { + continue; + } + let endpoints = rule["endpoints"].as_array().ok_or_else(conflict)?; + let first = endpoints.first().ok_or_else(conflict)?; + let protocol = match (first["protocol"].as_str(), first["tls"].as_str()) { + (Some(""), _) => "tcp", + (Some("rest"), Some("none")) => "http", + (Some("rest"), _) => "https", + (Some("websocket"), Some("none")) => "websocket", + (Some("websocket"), _) => "websocket-secure", + (Some("graphql"), _) => "graphql", + (Some("json-rpc"), _) => "json-rpc", + (Some("mcp"), _) => "mcp", + _ => return Err(conflict()), + }; + let mut methods = Vec::new(); + let mut paths = Vec::new(); + for endpoint in endpoints { + if endpoint["host"] != first["host"] + || endpoint["ports"] != first["ports"] + || endpoint["protocol"] != first["protocol"] + || endpoint["tls"] != first["tls"] + { + return Err(conflict()); + } + if endpoint["path"].as_str().is_some_and(|p| !p.is_empty()) { + paths.push(endpoint["path"].clone()); + } + for l7 in endpoint["rules"].as_array().ok_or_else(conflict)? { + methods.push(l7["allow"]["method"].clone()); + paths.push(l7["allow"]["path"].clone()); + } + } + methods.sort_by_key(canonical); + methods.dedup(); + paths.sort_by_key(canonical); + paths.dedup(); + let binaries: Vec<_> = rule["binaries"] + .as_array() + .ok_or_else(conflict)? + .iter() + .map(|b| b["path"].clone()) + .collect(); + let mut value = json!({"id":id,"host":first["host"],"ports":first["ports"],"protocol":protocol,"executables":binaries}); + if !methods.is_empty() { + value["methods"] = json!(methods); + } + if !paths.is_empty() { + value["paths"] = json!(paths); + } + if translated_rule(&value)? != *rule { + return Err(conflict()); + } + rules.push(value); + } + normalized_policy( + &json!({"schemaVersion":1,"revision":desired["revision"],"defaultAction":"deny","rules":rules}), + ) +} + +fn normal_metadata_hashes(input: &Value, workload: Uuid) -> Result<(String, String), Status> { + let policy = effective_policy(input)?; + let rules = policy["rules"] + .as_array() + .ok_or_else(conflict)? + .iter() + .map(|rule| { + ordered( + rule, + &[ + "id", + "host", + "ports", + "protocol", + "methods", + "paths", + "executables", + ], + ) + }) + .collect::>() + .join(","); + let serialized = format!( + "{{\"schemaVersion\":1,\"revision\":{},\"defaultAction\":\"deny\",\"rules\":[{}]}}", + policy["revision"], rules + ); + let effective = format!("sha256:{:x}", Sha256::digest(serialized)); + let org = input["organization"]["organizationId"] + .as_str() + .ok_or_else(conflict)?; + let policy_hash = format!( + "sha256:{:x}", + Sha256::digest(format!( + "globster/organization-workload-policy/v1\0{org}\0{workload}\0{effective}" + )) + ); + let mut bindings = input["request"]["desired"]["secretBindings"] + .as_array() + .cloned() + .ok_or_else(conflict)?; + bindings.sort_by_key(|b| b["id"].as_str().unwrap_or_default().to_owned()); + for binding in &mut bindings { + binding["credentialNames"] + .as_array_mut() + .ok_or_else(conflict)? + .sort_by_key(canonical); + } + let serialized = format!( + "[{}]", + bindings + .iter() + .map(|b| ordered( + b, + &[ + "schemaVersion", + "id", + "profile", + "secretRef", + "credentialNames" + ] + )) + .collect::>() + .join(",") + ); + let bindings_hash = format!("sha256:{:x}", Sha256::digest(serialized)); + Ok(( + policy_hash, + format!( + "sha256:{:x}", + Sha256::digest(format!( + "globster/organization-workload-secret-bindings/v1\0{org}\0{workload}\0{bindings_hash}" + )) + ), + )) +} + +pub(super) fn fingerprints(desired: &Value) -> Result { + let mut compute = json!({"powerState":desired["powerState"],"image":desired["image"], + "workspace":desired["workspace"],"resources":desired["resources"]}); + for field in [ + "runtime", + "recipe", + "computeRestartGeneration", + "initialConfiguration", + "environment", + ] { + if let Some(value) = desired.get(field) { + compute[field] = value.clone(); + } + } + let mut bindings = desired["secretBindings"] + .as_array() + .cloned() + .ok_or_else(conflict)?; + for binding in &mut bindings { + // The consumer uses localeCompare here, unlike policy/canonical JSON. + // Restrict to its portable DNS-label alphabet rather than claim a + // different fingerprint for locale-sensitive identifiers. + if !binding["id"].as_str().is_some_and(|id| { + !id.is_empty() + && id + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + }) { + return Err(Status::unimplemented( + "native prewarm requires lower-case alphanumeric/hyphen secret-binding IDs", + )); + } + binding["credentialNames"] + .as_array_mut() + .ok_or_else(conflict)? + .sort_by_key(canonical); + } + bindings.sort_by_key(|b| b["id"].as_str().unwrap_or_default().to_owned()); + if bindings.windows(2).any(|b| b[0]["id"] == b[1]["id"]) { + return Err(conflict()); + } + let mut result = json!({"compute":fingerprint("compute",&compute), + "policy":fingerprint("policy",&normalized_policy(&desired["policy"])?), + "secretBindings":fingerprint("secret-bindings",&json!(bindings))}); + result["overall"] = json!(fingerprint("overall", &result)); + Ok(result) +} + +fn organization(value: &Value) -> Result<(), Status> { + let id = value["organizationId"].as_str().ok_or_else(conflict)?; + if id.is_empty() + || id.len() > 128 + || !id.as_bytes()[0].is_ascii_alphanumeric() + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._~-".contains(&b)) + { + return Err(conflict()); + } + let marker = Sha256::digest(format!("globster/organization-owner/v1\0{id}")); + if value["ownershipMarker"] != format!("{marker:x}")[..32] { + return Err(conflict()); + } + let digest = Sha256::digest(format!("globster/workspace/v1\0{id}")); + let alphabet = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut name = String::from("gw-"); + let mut accumulator = 0u32; + let mut bits = 0; + for byte in digest.iter().take(10) { + accumulator = (accumulator << 8) | u32::from(*byte); + bits += 8; + while bits >= 5 { + bits -= 5; + name.push(alphabet[((accumulator >> bits) & 31) as usize] as char); + } + accumulator &= (1 << bits) - 1; + } + if value["workspaceName"] != name { + return Err(conflict()); + } + Ok(()) +} + +pub(super) fn validate(backend: &Backend<'_>, slot: &Slot, input: &Value) -> Result<(), Status> { + organization(&input["organization"])?; + let allocation = input["allocationId"] + .as_str() + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or_else(conflict)?; + let desired = &input["request"]["desired"]; + if allocation.is_nil() + || input["allocationId"] != allocation.to_string() + || input["request"]["schemaVersion"] != 1 + || desired["schemaVersion"] != 1 + || desired["revision"] + .as_u64() + .is_none_or(|r| r == 0 || r > 9_007_199_254_740_991) + || desired["image"] != slot.preparation["image"] + || desired["recipe"] != slot.preparation["recipe"] + || desired["resources"] != slot.preparation["resources"] + || desired["workspace"] != slot.preparation["workspace"] + || desired.get("computeRestartGeneration").is_some() + || desired + .get("runtime") + .is_some_and(|runtime| runtime != "agent") + { + return Err(conflict()); + } + if desired["powerState"] != "running" { + return Err(Status::unimplemented( + "initial native prewarm binding supports running workloads only", + )); + } + if fingerprints(desired)? != desired["fingerprints"] { + return Err(Status::invalid_argument( + "prewarm desired fingerprints do not match the actual intent", + )); + } + let launch = &input["launch"]; + let id = backend.identity.workload_id.to_string(); + if launch["command"] + != json!([ + "/app/.venv/bin/python", + "-m", + "agent_harness", + "serve", + "--headless", + "--host", + "0.0.0.0", + "--port", + "9000" + ]) + || launch["workspaceMountPath"] != "/workspace" + || launch["persistenceMountPath"] != "/sandbox/persist" + || launch["persistenceSubPath"] != format!("{id}/.globster-openshell-persist") + || launch["platformPolicyRevision"].as_u64().is_none() + { + return Err(conflict()); + } + let mut environment = desired + .get("environment") + .cloned() + .unwrap_or_else(|| json!({})); + if !environment.is_object() { + return Err(conflict()); + } + for (key, value) in [ + ("AGENT_DATA_DIR", "/workspace".to_owned()), + ("AGENT_ID", id.clone()), + ( + "GLOBSTER_ORGANIZATION_ID", + input["organization"]["organizationId"] + .as_str() + .ok_or_else(conflict)? + .into(), + ), + ("AGENT_STORAGE_ROOT", format!("/workspace/{id}/workspace")), + ( + "AGENT_HOST_CREDENTIAL_REFERENCE_TEMPLATE", + "openshell:resolve:env:{credential}".into(), + ), + ("HOME", "/workspace".into()), + ] { + environment[key] = json!(value); + } + if launch["environment"] != environment { + return Err(conflict()); + } + // Validate the full policy through the ordinary native policy machinery. + // Requested rules cannot be silently removed by the platform translation. + let supplied = launch["networkPolicies"].as_object().ok_or_else(conflict)?; + let caller_policy = normalized_policy(&desired["policy"])?; + for rule in caller_policy["rules"].as_array().ok_or_else(conflict)? { + let id = rule["id"].as_str().ok_or_else(conflict)?; + if supplied.get(&format!("globster__{id}")) != Some(&translated_rule(rule)?) { + return Err(conflict()); + } + } + if supplied.keys().any(|key| { + !key.starts_with("globster__platform-") + && !desired["policy"]["rules"].as_array().is_some_and(|rules| { + rules.iter().any(|rule| { + rule["id"] + .as_str() + .is_some_and(|id| key == &format!("globster__{id}")) + }) + }) + }) { + return Err(conflict()); + } + effective_policy(input)?; + Ok(()) +} + +pub(super) fn sandbox_request( + backend: &Backend<'_>, + input: &Value, + resources: &Value, + providers: Option>, +) -> Result { + let bound = providers.is_some(); + let mut labels = backend.labels(); + let mut annotations = BTreeMap::::new(); + let (command, environment, workspace) = if bound { + labels.insert( + OWNER.into(), + input["organization"]["ownershipMarker"] + .as_str() + .ok_or_else(conflict)? + .into(), + ); + labels.insert( + ALLOCATION.into(), + input["allocationId"].as_str().ok_or_else(conflict)?.into(), + ); + let desired = &input["request"]["desired"]; + let (policy_hash, bindings_hash) = + normal_metadata_hashes(input, backend.identity.workload_id)?; + for (key, value) in [ + ( + "globster.dev/desired-revision", + desired["revision"].to_string(), + ), + ( + "globster.dev/spec-hash", + desired["fingerprints"]["overall"] + .as_str() + .ok_or_else(conflict)? + .into(), + ), + ( + "globster.dev/idempotency-key", + input["allocationId"].as_str().ok_or_else(conflict)?.into(), + ), + ("globster.dev/power-state", "running".into()), + ( + "globster.dev/compute-fingerprint", + desired["fingerprints"]["compute"] + .as_str() + .ok_or_else(conflict)? + .into(), + ), + ( + "globster.dev/policy-fingerprint", + desired["fingerprints"]["policy"] + .as_str() + .ok_or_else(conflict)? + .into(), + ), + ( + "globster.dev/secret-bindings-fingerprint", + desired["fingerprints"]["secretBindings"] + .as_str() + .ok_or_else(conflict)? + .into(), + ), + ( + "globster.dev/policy-revision", + desired["policy"]["revision"].to_string(), + ), + ( + "globster.dev/platform-policy-revision", + input["launch"]["platformPolicyRevision"].to_string(), + ), + ("globster.dev/policy-hash", policy_hash), + ("globster.dev/secret-binding-hash", bindings_hash), + ("globster.dev/secret-binding-revision", "1".into()), + ( + "globster.dev/secret-binding-idempotency-key", + input["allocationId"].as_str().ok_or_else(conflict)?.into(), + ), + (PENDING, "true".into()), + ] { + annotations.insert(key.into(), value); + } + let mut workload = json!({"revision":desired["revision"],"specHash":desired["fingerprints"]["overall"], + "powerState":desired["powerState"],"image":desired["image"],"workspace":desired["workspace"], + "policy":normalized_policy(&desired["policy"])?,"secretBindings":desired["secretBindings"], + "fingerprints":desired["fingerprints"],"resources":desired["resources"]}); + for key in ["runtime", "environment"] { + if let Some(v) = desired.get(key) { + workload[key] = v.clone(); + } + } + let request = format!( + "PUT\n/v1/organizations/{}/workloads/{}\n{}", + input["organization"]["organizationId"] + .as_str() + .ok_or_else(conflict)?, + backend.identity.workload_id, + canonical(&workload) + ); + annotations.insert( + "globster.dev/request-fingerprint".into(), + format!("sha256:{:x}", Sha256::digest(request)), + ); + ( + input["launch"]["command"].clone(), + input["launch"]["environment"].clone(), + input["organization"]["workspaceName"] + .as_str() + .ok_or_else(conflict)? + .to_owned(), + ) + } else { + (json!(["/bin/false"]), json!({}), "default".into()) + }; + proto( + "openshell.v1.CreateSandboxRequest", + json!({ + "name":runtime_name(backend.identity.workload_id),"workspace":workspace, + "labels":labels,"annotations":annotations,"spec":{"command":command,"environment":environment, + "providers":providers.unwrap_or_default(), + "template":{"image": if bound {input["request"]["desired"]["image"].clone()} else {json!(backend.config.prewarm.as_ref().ok_or_else(conflict)?.image)}, + "labels":labels,"resources":{"limits":resources},"driverConfig":{"kubernetes":{ + "volumes":[{"name":"globster-workspace","persistent_volume_claim":{"claim_name":backend.storage()["claimName"],"read_only":false}}], + "containers":{"agent":{"volume_mounts":[{"name":"globster-workspace","mount_path":"/workspace","read_only":false}, + {"name":"globster-workspace","mount_path":"/sandbox/persist","sub_path":format!("{}/.globster-openshell-persist",backend.identity.workload_id),"read_only":false}]}} + }}}, + "policy":{"version":1,"filesystem":{"includeWorkdir":true,"readOnly":["/usr","/lib","/proc","/dev/urandom","/app","/etc","/var/log"], + "readWrite":["/sandbox","/workspace","/tmp","/dev/null"]},"landlock":{"compatibility":"best_effort"}, + "process":{"runAsUser":"sandbox","runAsGroup":"sandbox"},"networkPolicies":if bound {input["launch"]["networkPolicies"].clone()} else {json!({})}} + } + }), + ) +} + +pub(super) async fn configure( + backend: &Backend<'_>, + principal: &Principal, + input: &Value, +) -> Result { + let workspace = input["organization"]["workspaceName"] + .as_str() + .ok_or_else(conflict)?; + let owner = input["organization"]["ownershipMarker"] + .as_str() + .ok_or_else(conflict)?; + let existing = backend + .state + .store + .get_message_by_name::("", workspace) + .await + .map_err(|_| conflict())?; + if existing.is_none() { + let request = CreateWorkspaceRequest { + name: workspace.into(), + labels: HashMap::from([ + (MANAGED.into(), MANAGED_VALUE.into()), + (OWNER.into(), owner.into()), + ]), + }; + match super::super::super::workspace::handle_create_workspace( + backend.state, + authed(principal, request), + ) + .await + { + Ok(_) => {} + Err(e) if e.code() == tonic::Code::AlreadyExists => {} + Err(_) => { + return Err(Status::failed_precondition( + "ordinary organization workspace creation failed", + )); + } + } + } + let existing = backend + .state + .store + .get_message_by_name::("", workspace) + .await + .map_err(|_| conflict())? + .ok_or_else(conflict)?; + let meta = existing.metadata.ok_or_else(conflict)?; + if meta.labels.get(OWNER).map(String::as_str) != Some(owner) + || meta.labels.get(MANAGED).map(String::as_str) != Some(MANAGED_VALUE) + || meta.deletion_timestamp_ms != 0 + { + return Err(conflict()); + } + let bindings = input["request"]["desired"]["secretBindings"] + .as_array() + .ok_or_else(conflict)?; + let writes = input["request"]["credentialWrites"] + .as_array() + .ok_or_else(conflict)?; + if writes.len() > bindings.len() { + return Err(conflict()); + } + let mut names = Vec::new(); + for binding in bindings { + let id = binding["id"].as_str().ok_or_else(conflict)?; + let name = format!("{}-sb-{id}", runtime_name(backend.identity.workload_id)); + let ticket_name = format!("provider:{id}"); + let (cm, mut slot) = backend.load().await?; + let existing = backend + .state + .store + .get_message_by_name::(workspace, &name) + .await + .map_err(|_| conflict())?; + if let Some(provider) = existing { + let meta = provider.metadata.as_ref().ok_or_else(conflict)?; + let resource_meta = kube::core::ObjectMeta { + labels: Some( + meta.labels + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ), + ..Default::default() + }; + backend.check_labels(&resource_meta, slot.allocation.as_ref())?; + if meta.labels.get(ALLOCATION).map(String::as_str) != input["allocationId"].as_str() + || meta.labels.get(OWNER).map(String::as_str) != Some(owner) + || provider.r#type != binding["profile"] + || provider + .config + .get("globster.dev/secret-ref") + .map(String::as_str) + != binding["secretRef"].as_str() + { + return Err(conflict()); + } + let ticket = slot.tickets.get_mut(&ticket_name).ok_or_else(conflict)?; + if ticket.uid.as_ref().is_some_and(|uid| uid != &meta.id) { + return Err(conflict()); + } + if ticket.uid.is_none() { + ticket.uid = Some(meta.id.clone()); + backend.save(cm, &slot).await?; + } + names.push(name); + continue; + } + if slot.phase != Phase::Binding { + return Err(conflict()); + } + let ticket = slot.tickets.entry(ticket_name.clone()).or_default(); + if ticket.issued { + return Err(quarantine()); + } + let matching: Vec<_> = writes + .iter() + .filter(|w| w["binding"]["id"] == binding["id"]) + .collect(); + if matching.len() != 1 || matching[0]["binding"] != *binding { + return Err(Status::failed_precondition( + "prewarm provider requires its exact normal credential write", + )); + } + let credentials = matching[0]["credentials"].as_array().ok_or_else(conflict)?; + let mut values = serde_json::Map::new(); + let mut expires = serde_json::Map::new(); + for credential in credentials { + let key = credential["name"].as_str().ok_or_else(conflict)?; + if values + .insert(key.into(), credential["value"].clone()) + .is_some() + { + return Err(conflict()); + } + let expiry = match credential.get("expiresAt") { + None => 0i64, + Some(value) => { + let timestamp: prost_types::Timestamp = value + .as_str() + .ok_or_else(conflict)? + .parse() + .map_err(|_| conflict())?; + timestamp + .seconds + .checked_mul(1000) + .and_then(|s| s.checked_add(i64::from(timestamp.nanos) / 1_000_000)) + .ok_or_else(conflict)? + } + }; + expires.insert(key.into(), json!(expiry.to_string())); + } + let mut actual: Vec<_> = values.keys().cloned().collect(); + actual.sort(); + let mut expected: Vec<_> = binding["credentialNames"] + .as_array() + .ok_or_else(conflict)? + .iter() + .map(|v| v.as_str().map(str::to_owned).ok_or_else(conflict)) + .collect::>()?; + expected.sort(); + if actual != expected { + return Err(conflict()); + } + let mut labels = backend.labels(); + labels.insert(OWNER.into(), owner.into()); + labels.insert( + ALLOCATION.into(), + input["allocationId"].as_str().ok_or_else(conflict)?.into(), + ); + labels.insert("globster.dev/secret-binding-id".into(), id.into()); + let profile = super::super::super::provider::handle_get_provider_profile( + backend.state, + authed( + principal, + openshell_core::proto::GetProviderProfileRequest { + id: binding["profile"].as_str().ok_or_else(conflict)?.into(), + workspace: workspace.into(), + }, + ), + ) + .await + .map_err(|_| { + Status::failed_precondition( + "ordinary provider profile is unavailable in the organization workspace", + ) + })? + .into_inner() + .profile + .ok_or_else(conflict)?; + let request: CreateProviderRequest = proto( + "openshell.v1.CreateProviderRequest", + json!({"workspace":workspace, + "provider":{"metadata":{"name":name,"labels":labels,"annotations":{"globster.dev/secret-ref":binding["secretRef"]}}, + "type":binding["profile"],"profileWorkspace":workspace,"credentials":values,"credentialExpiresAtMs":expires, + "config":{"globster.dev/secret-ref":binding["secretRef"],"globster.dev/provider-profile-revision":profile.resource_version.to_string()}}}), + )?; + ticket.issued = true; + backend.save(cm, &slot).await?; + let result=super::super::super::provider::handle_create_provider(backend.state,authed(principal,request)).await + .map_err(|_|Status::failed_precondition("ordinary prewarm provider initialization failed; retry only to discover its committed record"))? + .into_inner().provider.ok_or_else(conflict)?; + let (cm, mut slot) = backend.load().await?; + slot.tickets.get_mut(&ticket_name).ok_or_else(conflict)?.uid = + Some(result.metadata.ok_or_else(conflict)?.id); + backend.save(cm, &slot).await?; + names.push(name); + } + names.sort(); + let request = sandbox_request( + backend, + input, + &input["request"]["desired"]["resources"], + Some(names), + )?; + if let Some(existing) = backend + .state + .store + .get_message::(&backend.identity.workload_id.to_string()) + .await + .map_err(|_| conflict())? + { + let meta = existing.metadata.as_ref().ok_or_else(conflict)?; + if meta.workspace != workspace + || meta.labels.get(ALLOCATION).map(String::as_str) != input["allocationId"].as_str() + { + return Err(conflict()); + } + return Ok(existing); + } + super::super::super::sandbox::persist_bound_sandbox( + backend.state, + authed(principal, request), + &backend.identity.workload_id.to_string(), + ) + .await + .map_err(|error| { + tracing::warn!(code = ?error.code(), "Ordinary retained sandbox configuration rejected"); + if error.code() == tonic::Code::AlreadyExists { + Status::aborted( + "retained sandbox was concurrently persisted; retry the same allocation", + ) + } else { + Status::failed_precondition( + "ordinary tenant sandbox configuration was rejected; retry the same allocation", + ) + } + }) +} + +pub(super) async fn bindings_ready(backend: &Backend<'_>, slot: &Slot) -> Result { + let allocation = slot.allocation.as_ref().ok_or_else(conflict)?; + let workspace = allocation["workspaceName"].as_str().ok_or_else(conflict)?; + let catalog = backend + .state + .provider_profile_sources + .snapshot_catalog(backend.state.store.as_ref(), workspace) + .await?; + for binding in allocation["bindings"].as_array().ok_or_else(conflict)? { + let id = binding["id"].as_str().ok_or_else(conflict)?; + let name = format!("{}-sb-{id}", runtime_name(backend.identity.workload_id)); + let Some(provider) = backend + .state + .store + .get_message_by_name::(workspace, &name) + .await + .map_err(|_| conflict())? + else { + return Ok(false); + }; + let Some(meta) = provider.metadata.as_ref() else { + return Ok(false); + }; + let Some(profile) = catalog.get_profile(&provider.r#type) else { + return Ok(false); + }; + if slot + .tickets + .get(&format!("provider:{id}")) + .and_then(|t| t.uid.as_deref()) + != Some(meta.id.as_str()) + || provider.r#type != binding["profile"] + || provider.profile_workspace != workspace + || provider + .config + .get("globster.dev/secret-ref") + .map(String::as_str) + != binding["secretRef"].as_str() + || provider + .config + .get("globster.dev/provider-profile-revision") + != Some(&profile.resource_version.to_string()) + { + return Ok(false); + } + for name in binding["credentialNames"].as_array().ok_or_else(conflict)? { + let name = name.as_str().ok_or_else(conflict)?; + if !provider.credentials.contains_key(name) + || provider + .credential_expires_at_ms + .get(name) + .is_some_and(|expiry| { + *expiry != 0 && *expiry <= crate::persistence::current_time_ms() + }) + { + return Ok(false); + } + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn component_fingerprints_match_the_gateway_v1_golden_vector() { + let desired = json!({"schemaVersion":1,"revision":7,"powerState":"running", + "image":format!("registry.example/agent@sha256:{}","a".repeat(64)), + "workspace":{"capacity":"1Gi"},"resources":{"cpu":"1000m","memory":"512Mi"},"environment":{"NORMAL":"value"}, + "policy":{"schemaVersion":1,"revision":2,"defaultAction":"deny","rules":[ + {"id":"api","host":"API.EXAMPLE.COM.","ports":[8443,443],"protocol":"tcp", + "executables":["/usr/bin/curl","/app/.venv/bin/python"]}]}, + "secretBindings":[ + {"schemaVersion":1,"id":"z-binding","profile":"openai","secretRef":"secret-z","credentialNames":["TOKEN_B","TOKEN_A"]}, + {"schemaVersion":1,"id":"a-binding","profile":"anthropic","secretRef":"secret-a","credentialNames":["API_KEY"]}]}); + // Generated by the consumer's actual agentRuntimeFingerprints export. + assert_eq!( + fingerprints(&desired).unwrap(), + json!({ + "compute":"sha256:318ddfde5807db57a3a07f80785e2e2ece1595d2a918e764818f9aa2b575c1d0", + "policy":"sha256:ad984c47d12d3baf6e3cb39a368b474dac4f5189b41cddd8a61524d71aa20973", + "secretBindings":"sha256:1207ec51d293046ff647e4e577904796a7f4b1a63417ee80ae4edc1ab57e06d5", + "overall":"sha256:465212e66a145013efc717e37ac0e4a446ff97778f8edf3b8b1b3f952e132e36"}) + ); + let input = json!({"request":{"desired":desired},"organization":{"organizationId":"org"},"launch":{"networkPolicies":{}}}); + assert_eq!( + normal_metadata_hashes( + &input, + Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap() + ) + .unwrap(), + ( + "sha256:e3761d9d18719759794c19f86798a25ceb71b278eb32c3bb9f7ed6774ab35292".into(), + "sha256:c4552d52ee63189f7375b63a38d182c407c4a3978a1d73a2269fcbafad51ea63".into() + ) + ); + } + #[test] + fn organization_context_is_derived_not_a_caller_selected_workspace() { + let digest = Sha256::digest("globster/organization-owner/v1\0org"); + let mut value = json!({"organizationId":"org","workspaceName":"arbitrary","ownershipMarker":format!("{digest:x}")[..32].to_owned()}); + assert!(organization(&value).is_err()); + value["ownershipMarker"] = json!("forged"); + assert!(organization(&value).is_err()); + } +} diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 89f8c942ea..aa012182ed 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -138,6 +138,18 @@ pub(super) async fn fetch_and_authorize_sandbox( e } })?; + if !matches!(principal, crate::auth::principal::Principal::Sandbox(_)) + && sandbox.metadata.as_ref().is_some_and(|m| { + m.annotations + .get(openshell_core::driver_utils::ANNOTATION_BINDING_PENDING) + .map(String::as_str) + == Some("true") + }) + { + return Err(Status::failed_precondition( + "native retained sandbox binding has not released its initial launch", + )); + } Ok(sandbox) } @@ -157,7 +169,7 @@ pub(super) async fn handle_create_sandbox( request: Request, ) -> Result, Status> { let create_request = request.get_ref().clone(); - let result = handle_create_sandbox_inner(state, request).await; + let result = handle_create_sandbox_inner(state, request, None).await; emit_sandbox_create_telemetry( state, &create_request, @@ -213,6 +225,7 @@ fn telemetry_compute_driver( async fn handle_create_sandbox_inner( state: &Arc, request: Request, + reserved_id: Option<&str>, ) -> Result, Status> { let principal = super::extract_principal(&request)?; let request = request.into_inner(); @@ -297,7 +310,7 @@ async fn handle_create_sandbox_inner( ) .await?; - let id = uuid::Uuid::new_v4().to_string(); + let id = reserved_id.map_or_else(|| uuid::Uuid::new_v4().to_string(), str::to_owned); let name = if request.name.is_empty() { generate_routable_name() } else { @@ -345,6 +358,13 @@ async fn handle_create_sandbox_inner( status })?; + if reserved_id.is_some() { + let sandbox = state.compute.persist_retained_sandbox(sandbox).await?; + return Ok(Response::new(SandboxResponse { + sandbox: Some(sandbox), + })); + } + // Mint the gateway JWT for singleplayer drivers. K8s sandboxes skip // this mint and bootstrap via `IssueSandboxToken` at supervisor // startup; identifying "is this K8s?" lives in the compute layer, so @@ -377,6 +397,18 @@ async fn handle_create_sandbox_inner( })) } +pub(super) async fn persist_bound_sandbox( + state: &Arc, + request: Request, + reserved_id: &str, +) -> Result { + handle_create_sandbox_inner(state, request, Some(reserved_id)) + .await? + .into_inner() + .sandbox + .ok_or_else(|| Status::internal("missing retained sandbox")) +} + pub(super) async fn handle_get_sandbox( state: &Arc, request: Request, diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 96b620a230..b1511ab8ec 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -53,6 +53,34 @@ pub struct TestOpenShell; #[tonic::async_trait] impl OpenShell for TestOpenShell { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 32ef513adb..52355767b3 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -48,6 +48,34 @@ struct RelayGateway { #[tonic::async_trait] impl OpenShell for RelayGateway { + async fn prepare_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn observe_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn bind_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn retire_prewarm_slot( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn report_main_process_exit( &self, _request: tonic::Request, diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index f26e975623..021de3db40 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -610,6 +610,59 @@ The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so this validation path does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. +#### Native single-use prewarm (opt-in) + +Keep this capability disabled until reviewed immutable artifacts, the exact +runtime profile and customer acceptance are available: + +```toml +[openshell.drivers.kubernetes.prewarm] +enabled = false +provider_artifact = "reviewed-native-artifact-version" +placement_key = "reviewed-placement" +image = "registry.example/agent@sha256:<64 lowercase hex digits>" +recipe_id = "reviewed-recipe" +recipe_digest = "sha256:" +recipe_image = "registry.example/recipe@sha256:<64 lowercase hex digits>" +``` + +The driver's `supervisor_image` must also be digest-pinned. This is not a pool +configuration or automatic artifact approval. Shared namespace, combined topology, +explicit `sandbox_uid = 1000` and `sandbox_gid = 1000`, and no provider SPIFFE mount +are required. Do not rely on namespace identity auto-detection for this path. +The recipe uses an image +volume even when the supervisor uses init-container sideloading. + +The existing staging sidecar profile is unsupported. Its operator bootstrap, +dual-supervisor startup and `fsGroup`-affected Secret projection require a reviewed +private-grant handoff; do not change staging topology or weaken grant permissions +to enable this configuration. Native v1 also binds the legacy agent launch +contract, not the current assistant lifecycle. Assistant schema version 2 is +reserved and returns `UNIMPLEMENTED` before resource or tenant mutation. + +Preparation freezes the driver configuration outside this approval section. +Binding and pending adoption reject configuration drift, including a different +supervisor image, bootstrap, identity or storage class. Restore the original +profile to resume an allocated handoff; retire unused slots under their original +namespace/storage configuration. Configuration changes never authorize rebuilding +a slot under its old receipt. + +The stock shared-mode chart intentionally does not grant these additional +permissions. An operator enabling prewarm must separately grant the gateway's +service account namespace-scoped `get/create/update/patch/delete` for Pods, +PersistentVolumeClaims, ConfigMaps and Secrets, plus `get` on `pods/log`, alongside +ordinary Sandbox CR permissions and owner-reference/finalizer authorization. +Do not grant these permissions to sandbox service accounts. Permanent reservation +ConfigMaps must not be removed by TTL cleanup, tenant ownership cleanup or pool +resizing. Requests/evidence are limited to 1 MiB and must never be logged with +credentials. Ambiguous CREATEs remain quarantined indefinitely until settled. + +See [compute-driver availability](sandbox-compute-drivers) for support limits. +Set `enabled = false` to disable new preparation/binding while preserving reserved +PVC protection and observation/retirement. Retain the section and namespace/storage +configuration until all unused claims are retired; do not disable their protection +while idle capacity exists. No customer pool should be enabled from configuration alone. + ### Docker Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required); guest mTLS material is supplied as host paths. diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 2c06a843d0..3f10fc7128 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -31,6 +31,43 @@ driver. Drivers serialize that specification in reconstruct argv with shell parsing. Runtime restart policies are disabled so an exited canonical process remains a terminal sandbox error. +## Native Prewarm Availability + +The in-tree Kubernetes backend has an opt-in, platform-authorized single-use +`PreparePrewarmSlot` / `ObservePrewarmSlot` / `BindPrewarmSlot` / +`RetirePrewarmSlot` lifecycle. Other backends and disabled preparation/binding +return `UNIMPLEMENTED`. Existing records can still be observed and retired when +new preparation is disabled. + +Supported preparation requires the shared namespace, combined supervisor, +explicit UID/GID 1000, an approved immutable agent/recipe/supervisor image set, image-volume +support for the recipe, and one exclusive RWO PVC. Initial binding supports running +agent workloads, not assistant runtimes, stopped initial workloads or restart +generations. Secret-binding identifiers must use lower-case ASCII letters, +digits and hyphens; locale-sensitive identifier collation is not advertised. +The inert held init container, not a running supervisor, is the +warming boundary. Bind retains the real Sandbox CR/Pod/PVC UIDs and reserved +workload UUID and initializes persistence before the main container starts. + +An indeterminate one-shot CREATE is durably quarantined: no recreation, reuse or +false retirement is allowed until the original creation can be settled. Resource +receipts, gate tests and source-built native fixtures are not approved-artifact +or customer readiness proof. Keep customer allocation disabled until real +maintained artifacts and the customer create/open journey have been verified. +Reserved claims remain protected from another ordinary writer even if the +operator removes the prewarm configuration. +The pool remains outside OpenShell. Ordinary cold lifecycle is unchanged. + +This is not support for the existing staging sidecar profile. Its operator-owned +process bootstrap must remain after binding, both supervisors need an ordered +release, and pod `fsGroup` changes Secret projection permissions in a way that +conflicts with the private mode-`0400` grant. A reviewed private-grant transport +and retained-resource sidecar proof are prerequisites, not permission to switch +topology or relax isolation. The legacy agent launch contract also does not +establish current assistant compatibility or a product allocator. Assistant +schema version 2 is reserved for the incompatible no-recipe contract and returns +`UNIMPLEMENTED` before identity, resource or tenant mutation. + ## Configure a Compute Driver Configure the compute driver on the gateway. Current releases accept one driver per gateway. Set `compute_drivers` in the gateway TOML file: diff --git a/proto/openshell.proto b/proto/openshell.proto index 67289eaefe..00130f19e6 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -26,6 +26,41 @@ service OpenShell { }; } + // Reserved native single-use slot operations. These require platform + // authorization, not a sandbox identity or tenant workspace membership. + // Unsupported drivers/artifacts return UNIMPLEMENTED, never readiness evidence. + rpc PreparePrewarmSlot(PrewarmSlotRequest) returns (PrewarmSlotResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + global_role: "platform_admin" + }; + } + + rpc ObservePrewarmSlot(PrewarmSlotRequest) returns (PrewarmSlotResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:read" + global_role: "platform_admin" + }; + } + + rpc BindPrewarmSlot(PrewarmSlotRequest) returns (PrewarmSlotResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + global_role: "platform_admin" + }; + } + + rpc RetirePrewarmSlot(PrewarmSlotRequest) returns (PrewarmSlotResponse) { + option (openshell.options.v1.authorization) = { + auth_mode: "bearer" + scope: "sandbox:write" + global_role: "platform_admin" + }; + } + // Return the authenticated caller identity established by the gateway. rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse) { option (openshell.options.v1.authorization) = { @@ -2882,3 +2917,15 @@ message ExtensionServiceCredential { // Absolute expiry of the token, milliseconds since the epoch. int64 expires_at_ms = 3; } + +// UTF-8 JSON native prewarm v1 request, limited to 1 MiB by the handler. +// Bind includes tenant credentials and must never be logged. +message PrewarmSlotRequest { + string request_json = 1 [(openshell.options.v1.secret) = true]; +} + +// UTF-8 JSON native prewarm v1 evidence, limited to 1 MiB. +// Unsupported providers return UNIMPLEMENTED instead of this message. +message PrewarmSlotResponse { + string evidence_json = 1; +} diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index f613dd0c8c..b8d386ad4d 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -13954,6 +13954,98 @@ func (x *ExtensionServiceCredential) GetExpiresAtMs() int64 { return 0 } +// UTF-8 JSON native prewarm v1 request, limited to 1 MiB by the handler. +// Bind includes tenant credentials and must never be logged. +type PrewarmSlotRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestJson string `protobuf:"bytes,1,opt,name=request_json,json=requestJson,proto3" json:"request_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PrewarmSlotRequest) Reset() { + *x = PrewarmSlotRequest{} + mi := &file_openshell_proto_msgTypes[192] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PrewarmSlotRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PrewarmSlotRequest) ProtoMessage() {} + +func (x *PrewarmSlotRequest) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[192] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PrewarmSlotRequest.ProtoReflect.Descriptor instead. +func (*PrewarmSlotRequest) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{192} +} + +func (x *PrewarmSlotRequest) GetRequestJson() string { + if x != nil { + return x.RequestJson + } + return "" +} + +// UTF-8 JSON native prewarm v1 evidence, limited to 1 MiB. +// Unsupported providers return UNIMPLEMENTED instead of this message. +type PrewarmSlotResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + EvidenceJson string `protobuf:"bytes,1,opt,name=evidence_json,json=evidenceJson,proto3" json:"evidence_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PrewarmSlotResponse) Reset() { + *x = PrewarmSlotResponse{} + mi := &file_openshell_proto_msgTypes[193] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PrewarmSlotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PrewarmSlotResponse) ProtoMessage() {} + +func (x *PrewarmSlotResponse) ProtoReflect() protoreflect.Message { + mi := &file_openshell_proto_msgTypes[193] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PrewarmSlotResponse.ProtoReflect.Descriptor instead. +func (*PrewarmSlotResponse) Descriptor() ([]byte, []int) { + return file_openshell_proto_rawDescGZIP(), []int{193} +} + +func (x *PrewarmSlotResponse) GetEvidenceJson() string { + if x != nil { + return x.EvidenceJson + } + return "" +} + var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + @@ -15029,7 +15121,11 @@ const file_openshell_proto_rawDesc = "" + "\x1aExtensionServiceCredential\x12!\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1a\n" + "\x05token\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs*\x89\x02\n" + + "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs\"=\n" + + "\x12PrewarmSlotRequest\x12'\n" + + "\frequest_json\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\vrequestJson\":\n" + + "\x13PrewarmSlotResponse\x12#\n" + + "\revidence_json\x18\x01 \x01(\tR\fevidenceJson*\x89\x02\n" + "\fSandboxPhase\x12\x1d\n" + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + @@ -15081,10 +15177,18 @@ const file_openshell_proto_rawDesc = "" + "1PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_RETRY\x10\x01\x12;\n" + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE\x10\x02\x12A\n" + "=PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_FIX_CONFIGURATION\x10\x03\x12;\n" + - "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xacF\n" + + "7PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_INVESTIGATE\x10\x042\xcbJ\n" + "\tOpenShell\x12Z\n" + "\x06Health\x12\x1b.openshell.v1.HealthRequest\x1a\x1c.openshell.v1.HealthResponse\"\x15\x82\xb5\x18\x11\n" + - "\x0funauthenticated\x12i\n" + + "\x0funauthenticated\x12\x86\x01\n" + + "\x12PreparePrewarmSlot\x12 .openshell.v1.PrewarmSlotRequest\x1a!.openshell.v1.PrewarmSlotResponse\"+\x82\xb5\x18'\n" + + "\x06bearer\x1a\x0eplatform_admin\"\rsandbox:write\x12\x85\x01\n" + + "\x12ObservePrewarmSlot\x12 .openshell.v1.PrewarmSlotRequest\x1a!.openshell.v1.PrewarmSlotResponse\"*\x82\xb5\x18&\n" + + "\x06bearer\x1a\x0eplatform_admin\"\fsandbox:read\x12\x83\x01\n" + + "\x0fBindPrewarmSlot\x12 .openshell.v1.PrewarmSlotRequest\x1a!.openshell.v1.PrewarmSlotResponse\"+\x82\xb5\x18'\n" + + "\x06bearer\x1a\x0eplatform_admin\"\rsandbox:write\x12\x85\x01\n" + + "\x11RetirePrewarmSlot\x12 .openshell.v1.PrewarmSlotRequest\x1a!.openshell.v1.PrewarmSlotResponse\"+\x82\xb5\x18'\n" + + "\x06bearer\x1a\x0eplatform_admin\"\rsandbox:write\x12i\n" + "\x0eGetCurrentUser\x12#.openshell.v1.GetCurrentUserRequest\x1a$.openshell.v1.GetCurrentUserResponse\"\f\x82\xb5\x18\b\n" + "\x06bearer\x12\x86\x01\n" + "\x0eGetGatewayInfo\x12#.openshell.v1.GetGatewayInfoRequest\x1a$.openshell.v1.GetGatewayInfoResponse\")\x82\xb5\x18%\n" + @@ -15236,7 +15340,7 @@ func file_openshell_proto_rawDescGZIP() []byte { } var file_openshell_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 217) +var file_openshell_proto_msgTypes = make([]protoimpl.MessageInfo, 219) var file_openshell_proto_goTypes = []any{ (SandboxPhase)(0), // 0: openshell.v1.SandboxPhase (ProviderCredentialTokenGrantType)(0), // 1: openshell.v1.ProviderCredentialTokenGrantType @@ -15438,47 +15542,49 @@ var file_openshell_proto_goTypes = []any{ (*ListWorkspaceMembersRequest)(nil), // 197: openshell.v1.ListWorkspaceMembersRequest (*ListWorkspaceMembersResponse)(nil), // 198: openshell.v1.ListWorkspaceMembersResponse (*ExtensionServiceCredential)(nil), // 199: openshell.v1.ExtensionServiceCredential - nil, // 200: openshell.v1.SandboxSpec.EnvironmentEntry - nil, // 201: openshell.v1.SandboxTemplate.LabelsEntry - nil, // 202: openshell.v1.SandboxTemplate.AnnotationsEntry - nil, // 203: openshell.v1.SandboxTemplate.EnvironmentEntry - nil, // 204: openshell.v1.PlatformEvent.MetadataEntry - nil, // 205: openshell.v1.CreateSandboxRequest.LabelsEntry - nil, // 206: openshell.v1.CreateSandboxRequest.AnnotationsEntry - nil, // 207: openshell.v1.ExecSandboxRequest.EnvironmentEntry - nil, // 208: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 209: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - nil, // 210: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - nil, // 211: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - nil, // 212: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry - nil, // 213: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - nil, // 214: openshell.v1.ProviderProfile.AnnotationsEntry - nil, // 215: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 216: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - nil, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - nil, // 219: openshell.v1.UpdateConfigRequest.AnnotationsEntry - nil, // 220: openshell.v1.UpdateConfigResponse.AnnotationsEntry - nil, // 221: openshell.v1.SandboxPolicyRevision.ProvenanceEntry - nil, // 222: openshell.v1.PolicyRevisionPayload.ProvenanceEntry - nil, // 223: openshell.v1.StoredPolicyRevision.ProvenanceEntry - nil, // 224: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 225: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 226: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 227: google.protobuf.Struct - (*datamodelv1.Provider)(nil), // 228: openshell.datamodel.v1.Provider - (*datamodelv1.CredentialHandle)(nil), // 229: openshell.datamodel.v1.CredentialHandle - (*sandboxv1.NetworkEndpoint)(nil), // 230: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 231: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 232: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 233: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 234: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 235: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 236: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 237: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 238: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 239: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 240: openshell.sandbox.v1.GetGatewayConfigResponse + (*PrewarmSlotRequest)(nil), // 200: openshell.v1.PrewarmSlotRequest + (*PrewarmSlotResponse)(nil), // 201: openshell.v1.PrewarmSlotResponse + nil, // 202: openshell.v1.SandboxSpec.EnvironmentEntry + nil, // 203: openshell.v1.SandboxTemplate.LabelsEntry + nil, // 204: openshell.v1.SandboxTemplate.AnnotationsEntry + nil, // 205: openshell.v1.SandboxTemplate.EnvironmentEntry + nil, // 206: openshell.v1.PlatformEvent.MetadataEntry + nil, // 207: openshell.v1.CreateSandboxRequest.LabelsEntry + nil, // 208: openshell.v1.CreateSandboxRequest.AnnotationsEntry + nil, // 209: openshell.v1.ExecSandboxRequest.EnvironmentEntry + nil, // 210: openshell.v1.SandboxLogLine.FieldsEntry + nil, // 211: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 212: openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + nil, // 213: openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + nil, // 214: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + nil, // 215: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + nil, // 216: openshell.v1.ProviderProfile.AnnotationsEntry + nil, // 217: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + nil, // 218: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 219: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + nil, // 220: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + nil, // 221: openshell.v1.UpdateConfigRequest.AnnotationsEntry + nil, // 222: openshell.v1.UpdateConfigResponse.AnnotationsEntry + nil, // 223: openshell.v1.SandboxPolicyRevision.ProvenanceEntry + nil, // 224: openshell.v1.PolicyRevisionPayload.ProvenanceEntry + nil, // 225: openshell.v1.StoredPolicyRevision.ProvenanceEntry + nil, // 226: openshell.v1.CreateWorkspaceRequest.LabelsEntry + (*datamodelv1.ObjectMeta)(nil), // 227: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 228: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 229: google.protobuf.Struct + (*datamodelv1.Provider)(nil), // 230: openshell.datamodel.v1.Provider + (*datamodelv1.CredentialHandle)(nil), // 231: openshell.datamodel.v1.CredentialHandle + (*sandboxv1.NetworkEndpoint)(nil), // 232: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 233: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 234: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 235: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 236: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 237: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 238: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 239: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 240: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 241: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 242: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ 199, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential @@ -15486,34 +15592,34 @@ var file_openshell_proto_depIdxs = []int32{ 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus 18, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo 19, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 225, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 5: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 21, // 6: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec 25, // 7: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 200, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 202, // 8: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry 24, // 9: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 226, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 10: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy 22, // 11: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements 23, // 12: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 201, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 202, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 203, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 227, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 227, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 203, // 13: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 204, // 14: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 205, // 15: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 229, // 16: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 229, // 17: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct 26, // 18: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition 0, // 19: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 204, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 206, // 20: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry 21, // 21: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 205, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 206, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 207, // 22: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 208, // 23: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry 20, // 24: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox 20, // 25: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 228, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 230, // 26: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 20, // 27: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 20, // 28: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox 52, // 29: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 225, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 30: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 51, // 31: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 207, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 209, // 32: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry 56, // 33: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout 57, // 34: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr 58, // 35: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit @@ -15522,18 +15628,18 @@ var file_openshell_proto_depIdxs = []int32{ 60, // 38: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit 55, // 39: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest 63, // 40: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 225, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 41: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 20, // 42: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox 67, // 43: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine 27, // 44: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent 68, // 45: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning 159, // 46: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 208, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 228, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 228, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 209, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 228, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 228, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 210, // 47: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 230, // 48: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 230, // 49: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 211, // 50: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + 230, // 51: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 230, // 52: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider 99, // 53: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile 80, // 54: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride 1, // 55: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType @@ -15545,26 +15651,26 @@ var file_openshell_proto_depIdxs = []int32{ 85, // 61: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput 2, // 62: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy 7, // 63: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 225, // 64: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 227, // 64: openshell.v1.StoredProviderCredentialRefreshState.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 2, // 65: openshell.v1.StoredProviderCredentialRefreshState.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 210, // 66: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry - 211, // 67: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry - 212, // 68: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry + 212, // 66: openshell.v1.StoredProviderCredentialRefreshState.material:type_name -> openshell.v1.StoredProviderCredentialRefreshState.MaterialEntry + 213, // 67: openshell.v1.StoredProviderCredentialRefreshState.additional_output_keys:type_name -> openshell.v1.StoredProviderCredentialRefreshState.AdditionalOutputKeysEntry + 214, // 68: openshell.v1.StoredProviderCredentialRefreshState.secret_material_handles:type_name -> openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry 90, // 69: openshell.v1.StoredProviderCredentialRefreshState.pending_secret_deletions:type_name -> openshell.v1.StoredRefreshMaterialDeletion 7, // 70: openshell.v1.StoredProviderCredentialRefreshState.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 229, // 71: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle + 231, // 71: openshell.v1.StoredRefreshMaterialDeletion.handle:type_name -> openshell.datamodel.v1.CredentialHandle 87, // 72: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus 2, // 73: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 213, // 74: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 215, // 74: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry 87, // 75: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus 87, // 76: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus 3, // 77: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory 83, // 78: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 230, // 79: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 231, // 80: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 232, // 79: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 233, // 80: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary 88, // 81: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 214, // 82: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 225, // 83: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 216, // 82: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 227, // 83: openshell.v1.StoredProviderProfile.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 99, // 84: openshell.v1.StoredProviderProfile.profile:type_name -> openshell.v1.ProviderProfile 99, // 85: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile 99, // 86: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile @@ -15577,30 +15683,30 @@ var file_openshell_proto_depIdxs = []int32{ 78, // 93: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem 79, // 94: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic 113, // 95: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 215, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 216, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 217, // 98: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 218, // 99: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 226, // 100: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 232, // 101: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 217, // 96: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 218, // 97: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + 219, // 98: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 220, // 99: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 228, // 100: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 234, // 101: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue 119, // 102: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 219, // 103: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 221, // 103: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry 120, // 104: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule 121, // 105: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint 122, // 106: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule 123, // 107: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules 124, // 108: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules 125, // 109: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 233, // 110: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 234, // 111: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 235, // 112: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 220, // 113: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 235, // 110: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 236, // 111: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 237, // 112: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 222, // 113: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry 133, // 114: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision 133, // 115: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision 4, // 116: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus 4, // 117: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 226, // 118: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 221, // 119: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 228, // 118: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 223, // 119: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry 67, // 120: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine 67, // 121: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine 140, // 122: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello @@ -15617,174 +15723,182 @@ var file_openshell_proto_depIdxs = []int32{ 150, // 133: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit 154, // 134: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample 156, // 135: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 233, // 136: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 226, // 137: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 138: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 235, // 136: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 228, // 137: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 138: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy 155, // 139: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary 158, // 140: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk 157, // 141: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary 158, // 142: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk 168, // 143: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 233, // 144: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 235, // 144: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule 178, // 145: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 226, // 146: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 222, // 147: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry - 233, // 148: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 226, // 149: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 150: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 223, // 151: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry - 226, // 152: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 226, // 153: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 224, // 154: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 236, // 155: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 236, // 156: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 236, // 157: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 225, // 158: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 228, // 146: openshell.v1.PolicyRevisionPayload.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 224, // 147: openshell.v1.PolicyRevisionPayload.provenance:type_name -> openshell.v1.PolicyRevisionPayload.ProvenanceEntry + 235, // 148: openshell.v1.DraftChunkPayload.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 228, // 149: openshell.v1.DraftChunkPayload.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 150: openshell.v1.DraftChunkPayload.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 225, // 151: openshell.v1.StoredPolicyRevision.provenance:type_name -> openshell.v1.StoredPolicyRevision.ProvenanceEntry + 228, // 152: openshell.v1.StoredDraftChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 228, // 153: openshell.v1.StoredDraftChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 226, // 154: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 238, // 155: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 238, // 156: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 238, // 157: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 227, // 158: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta 6, // 159: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole 6, // 160: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole 192, // 161: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember 192, // 162: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 229, // 163: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 231, // 163: openshell.v1.StoredProviderCredentialRefreshState.SecretMaterialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle 83, // 164: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential 114, // 165: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding 12, // 166: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 14, // 167: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 16, // 168: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 28, // 169: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 29, // 170: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 30, // 171: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 31, // 172: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 32, // 173: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 33, // 174: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 34, // 175: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 35, // 176: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 36, // 177: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 43, // 178: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 45, // 179: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 46, // 180: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 47, // 181: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 49, // 182: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 53, // 183: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 55, // 184: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 61, // 185: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 62, // 186: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 69, // 187: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 70, // 188: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 71, // 189: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 76, // 190: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 77, // 191: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 103, // 192: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 105, // 193: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 107, // 194: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 72, // 195: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 91, // 196: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 93, // 197: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 95, // 198: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 97, // 199: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 73, // 200: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 110, // 201: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 237, // 202: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 238, // 203: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 118, // 204: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 127, // 205: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 129, // 206: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 131, // 207: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 112, // 208: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 116, // 209: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 134, // 210: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 135, // 211: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 138, // 212: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 145, // 213: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 151, // 214: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 65, // 215: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 160, // 216: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 162, // 217: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 164, // 218: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 166, // 219: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 169, // 220: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 171, // 221: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 173, // 222: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 175, // 223: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 177, // 224: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 8, // 225: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 10, // 226: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 184, // 227: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 186, // 228: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 188, // 229: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 190, // 230: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 193, // 231: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 195, // 232: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 197, // 233: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 13, // 234: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 15, // 235: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 17, // 236: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 37, // 237: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 238: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 38, // 239: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 39, // 240: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 40, // 241: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 41, // 242: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 42, // 243: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 37, // 244: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 37, // 245: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 44, // 246: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 52, // 247: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 52, // 248: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 48, // 249: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 50, // 250: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 54, // 251: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 59, // 252: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 61, // 253: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 59, // 254: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 74, // 255: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 74, // 256: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 75, // 257: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 102, // 258: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 101, // 259: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 104, // 260: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 106, // 261: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 108, // 262: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 74, // 263: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 92, // 264: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 94, // 265: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 96, // 266: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 98, // 267: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 109, // 268: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 111, // 269: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 239, // 270: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 240, // 271: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 126, // 272: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 128, // 273: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 130, // 274: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 132, // 275: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 115, // 276: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 117, // 277: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 137, // 278: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 136, // 279: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 139, // 280: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 146, // 281: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 151, // 282: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 66, // 283: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 161, // 284: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 163, // 285: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 165, // 286: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 167, // 287: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 170, // 288: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 172, // 289: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 174, // 290: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 176, // 291: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 179, // 292: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 9, // 293: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 11, // 294: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 185, // 295: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 187, // 296: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 189, // 297: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 191, // 298: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 194, // 299: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 196, // 300: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 198, // 301: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 234, // [234:302] is the sub-list for method output_type - 166, // [166:234] is the sub-list for method input_type + 200, // 167: openshell.v1.OpenShell.PreparePrewarmSlot:input_type -> openshell.v1.PrewarmSlotRequest + 200, // 168: openshell.v1.OpenShell.ObservePrewarmSlot:input_type -> openshell.v1.PrewarmSlotRequest + 200, // 169: openshell.v1.OpenShell.BindPrewarmSlot:input_type -> openshell.v1.PrewarmSlotRequest + 200, // 170: openshell.v1.OpenShell.RetirePrewarmSlot:input_type -> openshell.v1.PrewarmSlotRequest + 14, // 171: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 16, // 172: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 28, // 173: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 29, // 174: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 30, // 175: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 31, // 176: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 32, // 177: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 33, // 178: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 34, // 179: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 35, // 180: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 36, // 181: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 43, // 182: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 45, // 183: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 46, // 184: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 47, // 185: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 49, // 186: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 53, // 187: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 55, // 188: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 61, // 189: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 62, // 190: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 69, // 191: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 70, // 192: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 71, // 193: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 76, // 194: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 77, // 195: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 103, // 196: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 105, // 197: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 107, // 198: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 72, // 199: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 91, // 200: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 93, // 201: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 95, // 202: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 97, // 203: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 73, // 204: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 110, // 205: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 239, // 206: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 240, // 207: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 118, // 208: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 127, // 209: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 129, // 210: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 131, // 211: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 112, // 212: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 116, // 213: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 134, // 214: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 135, // 215: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 138, // 216: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 145, // 217: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 151, // 218: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 65, // 219: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 160, // 220: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 162, // 221: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 164, // 222: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 166, // 223: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 169, // 224: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 171, // 225: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 173, // 226: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 175, // 227: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 177, // 228: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 8, // 229: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 10, // 230: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 184, // 231: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 186, // 232: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 188, // 233: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 190, // 234: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 193, // 235: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 195, // 236: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 197, // 237: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 13, // 238: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 201, // 239: openshell.v1.OpenShell.PreparePrewarmSlot:output_type -> openshell.v1.PrewarmSlotResponse + 201, // 240: openshell.v1.OpenShell.ObservePrewarmSlot:output_type -> openshell.v1.PrewarmSlotResponse + 201, // 241: openshell.v1.OpenShell.BindPrewarmSlot:output_type -> openshell.v1.PrewarmSlotResponse + 201, // 242: openshell.v1.OpenShell.RetirePrewarmSlot:output_type -> openshell.v1.PrewarmSlotResponse + 15, // 243: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 17, // 244: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 37, // 245: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 246: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 38, // 247: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 39, // 248: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 40, // 249: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 41, // 250: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 42, // 251: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 37, // 252: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 37, // 253: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 44, // 254: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 52, // 255: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 52, // 256: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 48, // 257: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 50, // 258: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 54, // 259: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 59, // 260: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 61, // 261: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 59, // 262: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 74, // 263: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 74, // 264: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 75, // 265: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 102, // 266: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 101, // 267: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 104, // 268: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 106, // 269: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 108, // 270: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 74, // 271: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 92, // 272: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 94, // 273: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 96, // 274: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 98, // 275: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 109, // 276: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 111, // 277: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 241, // 278: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 242, // 279: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 126, // 280: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 128, // 281: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 130, // 282: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 132, // 283: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 115, // 284: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 117, // 285: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 137, // 286: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 136, // 287: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 139, // 288: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 146, // 289: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 151, // 290: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 66, // 291: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 161, // 292: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 163, // 293: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 165, // 294: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 167, // 295: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 170, // 296: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 172, // 297: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 174, // 298: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 176, // 299: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 179, // 300: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 9, // 301: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 11, // 302: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 185, // 303: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 187, // 304: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 189, // 305: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 191, // 306: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 194, // 307: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 196, // 308: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 198, // 309: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 238, // [238:310] is the sub-list for method output_type + 166, // [166:238] is the sub-list for method input_type 166, // [166:166] is the sub-list for extension type_name 166, // [166:166] is the sub-list for extension extendee 0, // [0:166] is the sub-list for field type_name @@ -15861,7 +15975,7 @@ func file_openshell_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_openshell_proto_rawDesc), len(file_openshell_proto_rawDesc)), NumEnums: 8, - NumMessages: 217, + NumMessages: 219, NumExtensions: 0, NumServices: 1, }, diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 663c09aed5..6818784288 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -24,6 +24,10 @@ const _ = grpc.SupportPackageIsVersion9 const ( OpenShell_Health_FullMethodName = "/openshell.v1.OpenShell/Health" + OpenShell_PreparePrewarmSlot_FullMethodName = "/openshell.v1.OpenShell/PreparePrewarmSlot" + OpenShell_ObservePrewarmSlot_FullMethodName = "/openshell.v1.OpenShell/ObservePrewarmSlot" + OpenShell_BindPrewarmSlot_FullMethodName = "/openshell.v1.OpenShell/BindPrewarmSlot" + OpenShell_RetirePrewarmSlot_FullMethodName = "/openshell.v1.OpenShell/RetirePrewarmSlot" OpenShell_GetCurrentUser_FullMethodName = "/openshell.v1.OpenShell/GetCurrentUser" OpenShell_GetGatewayInfo_FullMethodName = "/openshell.v1.OpenShell/GetGatewayInfo" OpenShell_CreateSandbox_FullMethodName = "/openshell.v1.OpenShell/CreateSandbox" @@ -108,6 +112,13 @@ const ( type OpenShellClient interface { // Check the health of the service. Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) + // Reserved native single-use slot operations. These require platform + // authorization, not a sandbox identity or tenant workspace membership. + // Unsupported drivers/artifacts return UNIMPLEMENTED, never readiness evidence. + PreparePrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) + ObservePrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) + BindPrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) + RetirePrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) // Return the authenticated caller identity established by the gateway. GetCurrentUser(ctx context.Context, in *GetCurrentUserRequest, opts ...grpc.CallOption) (*GetCurrentUserResponse, error) // Fetch elevated live gateway runtime metadata. @@ -302,6 +313,46 @@ func (c *openShellClient) Health(ctx context.Context, in *HealthRequest, opts .. return out, nil } +func (c *openShellClient) PreparePrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PrewarmSlotResponse) + err := c.cc.Invoke(ctx, OpenShell_PreparePrewarmSlot_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) ObservePrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PrewarmSlotResponse) + err := c.cc.Invoke(ctx, OpenShell_ObservePrewarmSlot_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) BindPrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PrewarmSlotResponse) + err := c.cc.Invoke(ctx, OpenShell_BindPrewarmSlot_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *openShellClient) RetirePrewarmSlot(ctx context.Context, in *PrewarmSlotRequest, opts ...grpc.CallOption) (*PrewarmSlotResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PrewarmSlotResponse) + err := c.cc.Invoke(ctx, OpenShell_RetirePrewarmSlot_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *openShellClient) GetCurrentUser(ctx context.Context, in *GetCurrentUserRequest, opts ...grpc.CallOption) (*GetCurrentUserResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetCurrentUserResponse) @@ -1020,6 +1071,13 @@ func (c *openShellClient) ListWorkspaceMembers(ctx context.Context, in *ListWork type OpenShellServer interface { // Check the health of the service. Health(context.Context, *HealthRequest) (*HealthResponse, error) + // Reserved native single-use slot operations. These require platform + // authorization, not a sandbox identity or tenant workspace membership. + // Unsupported drivers/artifacts return UNIMPLEMENTED, never readiness evidence. + PreparePrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) + ObservePrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) + BindPrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) + RetirePrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) // Return the authenticated caller identity established by the gateway. GetCurrentUser(context.Context, *GetCurrentUserRequest) (*GetCurrentUserResponse, error) // Fetch elevated live gateway runtime metadata. @@ -1207,6 +1265,18 @@ type UnimplementedOpenShellServer struct{} func (UnimplementedOpenShellServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { return nil, status.Error(codes.Unimplemented, "method Health not implemented") } +func (UnimplementedOpenShellServer) PreparePrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) { + return nil, status.Error(codes.Unimplemented, "method PreparePrewarmSlot not implemented") +} +func (UnimplementedOpenShellServer) ObservePrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ObservePrewarmSlot not implemented") +} +func (UnimplementedOpenShellServer) BindPrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) { + return nil, status.Error(codes.Unimplemented, "method BindPrewarmSlot not implemented") +} +func (UnimplementedOpenShellServer) RetirePrewarmSlot(context.Context, *PrewarmSlotRequest) (*PrewarmSlotResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RetirePrewarmSlot not implemented") +} func (UnimplementedOpenShellServer) GetCurrentUser(context.Context, *GetCurrentUserRequest) (*GetCurrentUserResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetCurrentUser not implemented") } @@ -1447,6 +1517,78 @@ func _OpenShell_Health_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } +func _OpenShell_PreparePrewarmSlot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PrewarmSlotRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).PreparePrewarmSlot(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_PreparePrewarmSlot_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).PreparePrewarmSlot(ctx, req.(*PrewarmSlotRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_ObservePrewarmSlot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PrewarmSlotRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).ObservePrewarmSlot(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_ObservePrewarmSlot_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).ObservePrewarmSlot(ctx, req.(*PrewarmSlotRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_BindPrewarmSlot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PrewarmSlotRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).BindPrewarmSlot(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_BindPrewarmSlot_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).BindPrewarmSlot(ctx, req.(*PrewarmSlotRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _OpenShell_RetirePrewarmSlot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PrewarmSlotRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OpenShellServer).RetirePrewarmSlot(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OpenShell_RetirePrewarmSlot_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OpenShellServer).RetirePrewarmSlot(ctx, req.(*PrewarmSlotRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OpenShell_GetCurrentUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetCurrentUserRequest) if err := dec(in); err != nil { @@ -2595,6 +2737,22 @@ var OpenShell_ServiceDesc = grpc.ServiceDesc{ MethodName: "Health", Handler: _OpenShell_Health_Handler, }, + { + MethodName: "PreparePrewarmSlot", + Handler: _OpenShell_PreparePrewarmSlot_Handler, + }, + { + MethodName: "ObservePrewarmSlot", + Handler: _OpenShell_ObservePrewarmSlot_Handler, + }, + { + MethodName: "BindPrewarmSlot", + Handler: _OpenShell_BindPrewarmSlot_Handler, + }, + { + MethodName: "RetirePrewarmSlot", + Handler: _OpenShell_RetirePrewarmSlot_Handler, + }, { MethodName: "GetCurrentUser", Handler: _OpenShell_GetCurrentUser_Handler,