From 80415d1604988cf4fcfcfef8f13660fbe57232be Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Mon, 14 Sep 2026 11:55:28 -0700 Subject: [PATCH] feat(api): extend mutation replay through gateway interceptors Add typed durable replay receipts for 24 ordinary unary mutations, protect sensitive payload fingerprints, and revalidate intercepted retries without repeating post-commit observation. Part of #3051 (phase 3b). Signed-off-by: Mrunal Patel --- architecture/gateway.md | 36 +- crates/openshell-cli/src/commands/provider.rs | 16 + crates/openshell-cli/src/run.rs | 10 + .../src/proto_json.rs | 3 + .../src/runtime.rs | 3 + crates/openshell-sdk/src/client.rs | 8 + crates/openshell-server/src/grpc/mod.rs | 50 +- .../src/grpc/mutation_replay.rs | 145 ++- .../src/grpc/mutation_replay/ordinary.rs | 905 +++++++++++++++++ .../grpc/mutation_replay/ordinary/tests.rs | 917 ++++++++++++++++++ .../src/grpc/mutation_replay/tests.rs | 10 +- .../src/grpc/mutation_tests.rs | 1 + crates/openshell-server/src/grpc/policy.rs | 48 + crates/openshell-server/src/grpc/provider.rs | 123 ++- crates/openshell-server/src/grpc/sandbox.rs | 39 + crates/openshell-server/src/grpc/service.rs | 11 + crates/openshell-server/src/multiplex.rs | 205 +++- crates/openshell-server/src/storage_proto.rs | 2 +- crates/openshell-tui/src/lib.rs | 8 + docs/reference/api-errors.mdx | 62 +- e2e/python/test_sandbox_api.py | 110 ++- proto/openshell.proto | 72 ++ sdk/go/proto/openshellv1/openshell.pb.go | 466 +++++++-- 23 files changed, 3077 insertions(+), 173 deletions(-) create mode 100644 crates/openshell-server/src/grpc/mutation_replay/ordinary.rs create mode 100644 crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs diff --git a/architecture/gateway.md b/architecture/gateway.md index f5d08fc6d4..fec5c3206e 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -60,8 +60,8 @@ version when available. `google.rpc.RetryInfo` expresses a minimum retry delay; it does not establish that a mutation is safe to repeat. SDKs retain the original transport status, metadata, and unknown details alongside decoded fields. -Workspace lifecycle, membership, and sandbox-template mutations explicitly opt -into durable request admission when the client supplies a UUID. Typed adapters +Ordinary user-callable unary mutations explicitly opt into durable request +admission when the client supplies a UUID. Typed adapters check current authorization before looking up a caller/method/workspace-scoped key. The payload fingerprint excludes that UUID and canonicalizes protobuf maps. An atomic, quota-checked insert chooses one executor; owned execution survives @@ -71,10 +71,24 @@ interruption leave permanent unresolved claims, never stealable leases. Admission rows live outside user workspace namespaces and are bounded per caller. Successes expire after 24 hours; cleanup uses the unique admission incarnation and version so an old cleaner cannot delete a new attempt. Replay stores only -resource UUID/version references or deletion outcomes. It checks the original -workspace identity and current authorization, and never substitutes a same-name -resource. Intercepted mutations and credential capabilities require separate -adapters; this mechanism does not replay streams or repeat interceptor observers. +resource references and reviewed public scalar/diagnostic receipts, never +credential-bearing response snapshots. It checks original identities and current +authorization and never substitutes a same-name resource. Sandbox responses are +live projections of the original UUID; normal status reconciliation does not +invalidate replay. Refresh status additionally requires the original grant epoch. +Other resource projections retain exact-version guards. Terminal delete receipts +do not require the deleted target or parent to remain present. + +Sandbox, service, provider/profile, and policy/config adapters use keyed payload +fingerprints derived from existing gateway JWT or primary TLS private material. +Replicas must share that material; missing keys or key changes fail closed without +changing admission identity. Workspace/template adapters retain their original +format. Intercepted requests carry the original decoded payload only in a private +in-memory extension. Replay reauthorizes original and current effective scopes, +requires the same effective payload, and reruns current interceptor validation. +Interceptors cannot mutate the request UUID. Server-marked replay suppresses +post-commit observation, which remains best-effort rather than an outbox. +Credential capabilities and streaming execution require separate contracts. The gateway listens on one service port and multiplexes gRPC and HTTP traffic. The default local single-user deployment mode is mTLS user authentication: @@ -366,7 +380,7 @@ Compute-driver, credential-driver, gateway-interceptor, and supervisor-middleware services are compiled contracts for internal extension boundaries, not public gateway RPCs. The current public inventory has 74 methods, 278 messages, and 13 enums -(`8fab4ae6475cc3b768db710c1fc4c0f2ed75000682749e303388ba908dda5b59`). +(`5de04dd390599ebd165111df4f14c6dd05ae77aa748866a0e3ec1a22abd63133`). The removed `NetworkBinary.harness` field remains reserved by number and name, so protobuf implementations cannot reuse its wire slot or source identifier. The durable-policy compatibility decoder reads the former boolean before Prost @@ -406,8 +420,8 @@ Missing targets return `NOT_FOUND` unless `allow_missing` explicitly requests failures remain errors. Already-revoked sessions complete without another write after current authorization. The removed response booleans are reserved by name and number; this coordinated pre-1.0 API change does not alter durable schemas. -The outcome alone does not provide request deduplication. The six opted-in -workspace/template methods require a request UUID for the admission contract. +The outcome alone does not provide request deduplication. Opted-in unary methods +require a request UUID for the admission contract. | Dual-purpose encoded root | Current decision | |---|---| @@ -460,8 +474,8 @@ advisor drafts without creating resource-specific tables. Mutation admission uses a private, version-tagged JSON envelope in the same object store. Its identity namespace stays stable across format changes, and an -unknown format fails closed. It contains no public response payloads and is not -part of the protobuf storage closure. +unknown format fails closed. It contains explicit typed receipts, not arbitrary +public response payloads, and is not part of the protobuf storage closure. Each sandbox policy revision stores the complete provenance annotation map supplied with that update. The revision payload is the authoritative immutable diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 2fbd7ca94d..39c1fecf2f 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -103,6 +103,7 @@ pub async fn sandbox_provider_attach( let response = match client .attach_sandbox_provider(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, @@ -159,6 +160,7 @@ pub async fn sandbox_provider_detach( let response = match client .detach_sandbox_provider(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: name.to_string(), provider_name: provider.to_string(), expected_resource_version: resource_version, @@ -471,6 +473,7 @@ async fn auto_create_provider( if let Some(exact_name) = preferred_name { // Explicit name: create with exactly that name, no retries. let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -519,6 +522,7 @@ async fn auto_create_provider( }; let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -670,6 +674,7 @@ async fn rollback_provider_create_after_gcloud_adc_failure( ) -> Result<()> { match client .delete_provider(DeleteProviderRequest { + request_id: String::new(), allow_missing: true, name: provider_name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -1101,6 +1106,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> let response = client .create_provider(CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -1140,6 +1146,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> if let Err(configure_err) = client .configure_provider_refresh(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: provider_name.clone(), credential_key: adc_credential_key.clone(), strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, @@ -1165,6 +1172,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> if let Err(rotate_err) = client .rotate_provider_credential(RotateProviderCredentialRequest { + request_id: String::new(), provider: provider_name.clone(), credential_key: adc_credential_key, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -1581,6 +1589,7 @@ pub async fn provider_profile_import( if !items.is_empty() { let response = client .import_provider_profiles(ImportProviderProfilesRequest { + request_id: String::new(), profiles: items, workspace: workspace.to_string(), }) @@ -1630,6 +1639,7 @@ pub async fn provider_profile_update( .map_or(0, |profile| profile.resource_version); let response = client .update_provider_profiles(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(item), expected_resource_version, id: id.to_string(), @@ -1694,6 +1704,7 @@ pub async fn provider_profile_delete( for id in ids { let response = match client .delete_provider_profile(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: true, id: id.clone(), workspace: workspace.to_string(), @@ -1806,6 +1817,7 @@ pub async fn provider_refresh_config( let mut client = grpc_client(server, tls).await?; let status = client .configure_provider_refresh(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: input.name.to_string(), credential_key: input.credential_key.to_string(), strategy: strategy as i32, @@ -1839,6 +1851,7 @@ pub async fn provider_rotate( let mut client = grpc_client(server, tls).await?; let status = client .rotate_provider_credential(RotateProviderCredentialRequest { + request_id: String::new(), provider: name.to_string(), credential_key: credential_key.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -1876,6 +1889,7 @@ pub async fn provider_refresh_delete( let mut client = grpc_client(server, tls).await?; let response = client .delete_provider_refresh(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: true, provider: name.to_string(), credential_key: credential_key.to_string(), @@ -2313,6 +2327,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { let response = client .update_provider(UpdateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -2367,6 +2382,7 @@ pub async fn provider_delete( for name in names { let response = match client .delete_provider(DeleteProviderRequest { + request_id: String::new(), allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 6c24268c47..e55a564652 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -626,6 +626,7 @@ pub async fn sandbox_create( )]) }; let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { resource_requirements, environment: if template.is_none() { @@ -3105,6 +3106,7 @@ pub async fn sandbox_delete( let response = match client .delete_sandbox(DeleteSandboxRequest { + request_id: String::new(), allow_missing: true, name: name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -3165,6 +3167,7 @@ pub async fn sandbox_stop( let mut client = grpc_client(server, tls).await?; let sandbox = client .stop_sandbox(StopSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) @@ -3188,6 +3191,7 @@ pub async fn sandbox_start( let mut client = grpc_client(server, tls).await?; let sandbox = client .start_sandbox(StartSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) @@ -3286,6 +3290,7 @@ pub async fn service_expose( let mut client = grpc_client(server, tls).await?; let response = client .expose_service(ExposeServiceRequest { + request_id: String::new(), sandbox: sandbox.to_string(), service: service.to_string(), target_port: u32::from(target_port), @@ -3412,6 +3417,7 @@ pub async fn service_delete( let mut client = grpc_client(server, tls).await?; let response = client .delete_service(DeleteServiceRequest { + request_id: String::new(), allow_missing: false, sandbox: sandbox.to_string(), service: service.to_string(), @@ -5668,6 +5674,7 @@ pub async fn sandbox_draft_approve( let response = client .approve_draft_chunk(ApproveDraftChunkRequest { + request_id: String::new(), name: name.to_string(), chunk_id: chunk_id.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -5700,6 +5707,7 @@ pub async fn sandbox_draft_reject( client .reject_draft_chunk(RejectDraftChunkRequest { + request_id: String::new(), name: name.to_string(), chunk_id: chunk_id.to_string(), reason: reason.to_string(), @@ -5741,6 +5749,7 @@ pub async fn sandbox_draft_approve_all( let response = client .approve_all_draft_chunks(ApproveAllDraftChunksRequest { + request_id: String::new(), name: name.to_string(), include_security_flagged, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -5772,6 +5781,7 @@ pub async fn sandbox_draft_clear( let response = client .clear_draft_chunks(ClearDraftChunksRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) diff --git a/crates/openshell-gateway-interceptors/src/proto_json.rs b/crates/openshell-gateway-interceptors/src/proto_json.rs index dde37190e6..0419bbd225 100644 --- a/crates/openshell-gateway-interceptors/src/proto_json.rs +++ b/crates/openshell-gateway-interceptors/src/proto_json.rs @@ -308,6 +308,7 @@ mod tests { let codec = ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], ..SandboxSpec::default() @@ -336,6 +337,7 @@ mod tests { fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() { let codec = ProtoJsonCodec::openshell().unwrap(); let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { r#type: "github".to_string(), credentials: HashMap::from([( @@ -396,6 +398,7 @@ mod tests { fn generic_sandbox_environment_remains_visible() { let codec = ProtoJsonCodec::openshell().unwrap(); let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), ..SandboxSpec::default() diff --git a/crates/openshell-gateway-interceptors/src/runtime.rs b/crates/openshell-gateway-interceptors/src/runtime.rs index 0bd1ba2b47..91ea949feb 100644 --- a/crates/openshell-gateway-interceptors/src/runtime.rs +++ b/crates/openshell-gateway-interceptors/src/runtime.rs @@ -755,6 +755,7 @@ mod tests { fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation { let request = CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { r#type: "github".to_string(), credentials: HashMap::from([( @@ -1009,6 +1010,7 @@ mod tests { codec: codec.clone(), }; let request = UpdateConfigRequest { + request_id: String::new(), name: "demo".to_string(), expected_resource_version: u64::MAX - 1, annotations: HashMap::from([ @@ -1049,6 +1051,7 @@ mod tests { let codec = ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { template: Some(SandboxTemplate { resources: Some( diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index 741a17e877..921891f230 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -356,6 +356,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), @@ -374,6 +375,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::StopSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), }; @@ -388,6 +390,7 @@ impl OpenShellClient { let response = self .unary(|mut grpc| { let request = proto::StartSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector("default")), }; @@ -938,6 +941,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::DeleteSandboxRequest { + request_id: String::new(), allow_missing: opts.allow_missing, name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), @@ -957,6 +961,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::StopSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; @@ -972,6 +977,7 @@ impl WorkspaceScopedClient { .client .unary(|mut grpc| { let request = proto::StartSandboxRequest { + request_id: String::new(), name: name.to_string(), workspace_scope: Some(proto::workspace_selector(&self.workspace)), }; @@ -1155,6 +1161,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest { gpu: Some(proto::GpuResourceRequirements { count: None }), }); proto::CreateSandboxRequest { + request_id: String::new(), spec: Some(proto::SandboxSpec { environment, template, @@ -1186,6 +1193,7 @@ fn create_sandbox_from_template_request( policy, } = spec; proto::CreateSandboxRequest { + request_id: String::new(), spec: Some(proto::SandboxSpec { providers, command, diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 4a11a4f024..860a3a1bea 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -4,7 +4,7 @@ //! gRPC service implementation. mod auth_rpc; -mod mutation_replay; +pub mod mutation_replay; pub mod policy; pub mod provider; mod sandbox; @@ -266,7 +266,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - sandbox::handle_create_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn begin_rootfs_tar_staging( @@ -338,35 +338,35 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - sandbox::handle_attach_sandbox_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn detach_sandbox_provider( &self, request: Request, ) -> Result, Status> { - sandbox::handle_detach_sandbox_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_sandbox( &self, request: Request, ) -> Result, Status> { - sandbox::handle_delete_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn stop_sandbox( &self, request: Request, ) -> Result, Status> { - sandbox::handle_stop_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn start_sandbox( &self, request: Request, ) -> Result, Status> { - sandbox::handle_start_sandbox(&self.state, request).await + mutation_replay::run(&self.state, request).await } // --- Exec --- @@ -412,7 +412,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - service::handle_expose_service(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_service( @@ -433,7 +433,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - service::handle_delete_service(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn revoke_ssh_session( @@ -449,7 +449,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_create_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_provider( @@ -484,14 +484,14 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_import_provider_profiles(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn update_provider_profiles( &self, request: Request, ) -> Result, Status> { - provider::handle_update_provider_profiles(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn lint_provider_profiles( @@ -505,7 +505,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_update_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_provider_refresh_status( @@ -519,35 +519,35 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_configure_provider_refresh(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn rotate_provider_credential( &self, request: Request, ) -> Result, Status> { - provider::handle_rotate_provider_credential(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_provider_refresh( &self, request: Request, ) -> Result, Status> { - provider::handle_delete_provider_refresh(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_provider( &self, request: Request, ) -> Result, Status> { - provider::handle_delete_provider(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn delete_provider_profile( &self, request: Request, ) -> Result, Status> { - provider::handle_delete_provider_profile(&self.state, request).await + mutation_replay::run(&self.state, request).await } // --- Config / Policy --- @@ -584,7 +584,7 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - policy::handle_update_config(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_sandbox_policy_status( @@ -644,42 +644,42 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - policy::handle_approve_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn reject_draft_chunk( &self, request: Request, ) -> Result, Status> { - policy::handle_reject_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn approve_all_draft_chunks( &self, request: Request, ) -> Result, Status> { - policy::handle_approve_all_draft_chunks(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn edit_draft_chunk( &self, request: Request, ) -> Result, Status> { - policy::handle_edit_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn undo_draft_chunk( &self, request: Request, ) -> Result, Status> { - policy::handle_undo_draft_chunk(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn clear_draft_chunks( &self, request: Request, ) -> Result, Status> { - policy::handle_clear_draft_chunks(&self.state, request).await + mutation_replay::run(&self.state, request).await } async fn get_draft_history( diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs index 4e2cc78c7e..e5bb5a236a 100644 --- a/crates/openshell-server/src/grpc/mutation_replay.rs +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -3,13 +3,14 @@ //! Explicitly opted-in unary mutations. This is an admission fence, not a lease: //! an owner that cannot persist success leaves an unresolved claim forever. -//! Intercepted and credential-bearing RPCs must not use this adapter unchanged. +//! Each adapter explicitly approves its authorization and replay representation. #![allow(clippy::result_large_err)] use std::collections::BTreeMap; use std::sync::{Arc, LazyLock}; +use hmac::{Hmac, Mac}; use openshell_core::proto::{ AddWorkspaceMemberRequest, AddWorkspaceMemberResponse, CreateSandboxTemplateRequest, CreateWorkspaceRequest, CreateWorkspaceResponse, DeleteSandboxTemplateRequest, @@ -52,16 +53,31 @@ struct Admission { // change must reject incompatible records, not admit the old key again. format_version: u32, payload_hash: String, + #[serde(default)] + protection: Option, workspace_id: Option, success: Option, completed_at_ms: Option, } +#[derive(Serialize, Deserialize, PartialEq)] +struct Protection { + key_id: String, + effective_payload_hash: String, + effective_workspace_id: Option, +} + +/// Gateway-owned, in-memory input before hydration and interceptor modification. +/// Never populated from HTTP metadata and never written to the admission store. +#[derive(Clone)] +pub struct OriginalMutation(pub(crate) Vec); + /// Deliberately no serialized requests, responses, tokens, or error messages. #[derive(Serialize, Deserialize)] pub(super) enum Success { Resource { id: String, version: u64 }, Deletion { outcome: i32 }, + Ordinary(ordinary::Outcome), } pub(super) struct Scope { @@ -73,13 +89,14 @@ pub(super) struct Scope { pub(super) trait Mutation: Message + Default + Send + Sync + 'static { type Output: Message + Default + Send + 'static; const METHOD: &'static str; + const PROTECTED: bool = false; fn request_id(&self) -> &str; async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result; async fn execute( state: &Arc, request: Request, ) -> Result, Status>; - fn capture(response: &Self::Output) -> Result; + fn capture(response: &Response) -> Result; async fn restore(store: &Store, success: Success) -> Result; } @@ -88,7 +105,20 @@ pub(super) async fn run( state: &Arc, request: Request, ) -> Result, Status> { - if request.get_ref().request_id().is_empty() { + let original = request + .extensions() + .get::() + .map(|original| M::decode(original.0.as_slice())) + .transpose() + .map_err(|_| Status::internal("decode original mutation request"))?; + let original = original.as_ref().unwrap_or_else(|| request.get_ref()); + if original.request_id() != request.get_ref().request_id() { + return Err(rpc_error::invalid_argument( + "request_id", + "interceptors must preserve the original request_id", + )); + } + if original.request_id().is_empty() { return M::execute(state, request).await; } if request.get_ref().encoded_len() > 4 * 1024 * 1024 { @@ -101,7 +131,21 @@ pub(super) async fn run( "request admission requires a user principal", )); }; - let scope = request.get_ref().authorize(state, &principal).await?; + let scope = original.authorize(state, &principal).await?; + let effective_scope = request.get_ref().authorize(state, &principal).await?; + let (payload_hash, protection) = if M::PROTECTED { + let key = fingerprint_key(state).await?; + ( + key.fingerprint(original)?, + Some(Protection { + key_id: key.id(), + effective_payload_hash: key.fingerprint(request.get_ref())?, + effective_workspace_id: effective_scope.workspace_id, + }), + ) + } else { + (fingerprint(original)?, None) + }; let (provider, issuer) = match user.identity.provider { IdentityProvider::Oidc => ( "oidc", @@ -129,7 +173,6 @@ pub(super) async fn run( scope.name, request_id ]))?; - let payload_hash = fingerprint(request.get_ref())?; let permit = EXECUTORS.clone().try_acquire_owned().map_err(|_| { Status::resource_exhausted( "mutation admission workers are busy; no work was started by this call", @@ -140,7 +183,16 @@ pub(super) async fn run( // must not drop the owner between durable admission and execution. tokio::spawn(async move { let _permit = permit; - execute_owned(&state, request, &key, &bucket, payload_hash, scope).await + execute_owned( + &state, + request, + &key, + &bucket, + payload_hash, + protection, + scope, + ) + .await }) .await .map_err(|_| uncertain())? @@ -148,15 +200,17 @@ pub(super) async fn run( async fn execute_owned( state: &Arc, - request: Request, + mut request: Request, key: &str, bucket: &str, payload_hash: String, + protection: Option, scope: Scope, ) -> Result, Status> { let mut admission = Admission { format_version: 1, payload_hash, + protection, workspace_id: scope.workspace_id, success: None, completed_at_ms: None, @@ -188,13 +242,22 @@ async fn execute_owned( Err(error) => return Err(storage_error(error)), } } + // A rotated/unavailable protection key must never turn the old ID + // into a new admission, or report a misleading payload mismatch. + if previous.protection.as_ref().map(|p| &p.key_id) + != admission.protection.as_ref().map(|p| &p.key_id) + { + return Err(replay_unavailable()); + } if previous.payload_hash != admission.payload_hash { return Err(rpc_error::failed_precondition( "REQUEST_ID_PAYLOAD_MISMATCH", "request_id was already used with a different payload", )); } - if previous.workspace_id != admission.workspace_id { + if previous.workspace_id != admission.workspace_id + || previous.protection != admission.protection + { return Err(replay_unavailable()); } let success = previous.success.ok_or_else(uncertain)?; @@ -239,10 +302,16 @@ async fn execute_owned( }; // Any error or panic from here leaves the claim unresolved. Status codes // do not establish that a handler performed no effects. - let response = M::execute(state, request).await?; - admission.success = Some(M::capture(response.get_ref())?); + let facts = ordinary::Facts::default(); + request.extensions_mut().insert(facts.clone()); + let mut response = M::execute(state, request).await?; + response.extensions_mut().insert(facts); + admission.success = Some(M::capture(&response)?); admission.completed_at_ms = Some(current_time_ms()); let payload = serde_json::to_vec(&admission).map_err(|_| uncertain())?; + if payload.len() > 64 * 1024 { + return Err(uncertain()); + } state .store .put_if( @@ -320,6 +389,50 @@ fn fingerprint(request: &M) -> Result { hash_json(&value) } +struct FingerprintKey([u8; 32]); + +impl FingerprintKey { + fn id(&self) -> String { + format!("{:x}", Sha256::digest(self.0)) + } + + fn fingerprint(&self, request: &M) -> Result { + let mut mac = Hmac::::new_from_slice(&self.0) + .map_err(|_| Status::internal("initialize mutation fingerprint"))?; + mac.update(fingerprint(request)?.as_bytes()); + Ok(format!("{:x}", mac.finalize().into_bytes())) + } +} + +// New adapters can contain credentials or arbitrary sandbox environment values. +// A plain database hash would permit offline guesses. Reuse existing private +// gateway material without introducing a database-stored key or new deployment +// configuration. HA replicas must share that material; rotation fails closed. +async fn fingerprint_key(state: &ServerState) -> Result { + let path = state + .config + .gateway_jwt + .as_ref() + .map(|jwt| &jwt.signing_key_path) + .or_else(|| state.config.tls.as_ref().map(|tls| &tls.key_path)); + let unavailable = || { + rpc_error::failed_precondition( + "REQUEST_REPLAY_UNAVAILABLE", + "request_id on this method requires readable, stable gateway JWT or TLS private key material; no work was started by this call", + ) + }; + let bytes = tokio::fs::read(path.ok_or_else(unavailable)?) + .await + .map_err(|_| unavailable())?; + if bytes.is_empty() { + return Err(unavailable()); + } + let mut hash = Sha256::new(); + hash.update(b"openshell/mutation-fingerprint/v1\0"); + hash.update(bytes); + Ok(FingerprintKey(hash.finalize().into())) +} + // Sort every map explicitly; do not depend on serde_json's preserve_order feature. fn hash_json(value: &serde_json::Value) -> Result { struct Canonical<'a>(&'a serde_json::Value); @@ -355,7 +468,7 @@ fn uncertain() -> Status { fn replay_unavailable() -> Status { rpc_error::failed_precondition( "REQUEST_REPLAY_UNAVAILABLE", - "the original workspace or result no longer exists at its recorded version; this request was not executed again", + "the original scope, protected payload, or result is no longer replayable; this request was not executed again", ) } @@ -478,8 +591,8 @@ macro_rules! resource_mutation { ) -> Result, Status> { $handler(state, request).await } - fn capture(response: &Self::Output) -> Result { - resource_success(response.$field.as_ref()) + fn capture(response: &Response) -> Result { + resource_success(response.get_ref().$field.as_ref()) } async fn restore(store: &Store, success: Success) -> Result { Ok($resp { @@ -512,9 +625,9 @@ macro_rules! deletion_mutation { ) -> Result, Status> { $handler(state, request).await } - fn capture(response: &Self::Output) -> Result { + fn capture(response: &Response) -> Result { Ok(Success::Deletion { - outcome: response.outcome, + outcome: response.get_ref().outcome, }) } async fn restore(_store: &Store, success: Success) -> Result { @@ -587,3 +700,5 @@ deletion_mutation!( #[cfg(test)] mod tests; + +pub(super) mod ordinary; diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs new file mode 100644 index 0000000000..5d8b8adb3e --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary.rs @@ -0,0 +1,905 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Explicit replay recipes. Never serialize a whole public request or response: +//! sandbox specs, provider credentials and refresh material can contain secrets. + +#[cfg(test)] +pub(super) mod tests; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use openshell_core::proto::{ + ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, + ApproveDraftChunkResponse, AttachSandboxProviderRequest, AttachSandboxProviderResponse, + ClearDraftChunksRequest, ClearDraftChunksResponse, ConfigureProviderRefreshRequest, + ConfigureProviderRefreshResponse, CreateProviderRequest, CreateSandboxRequest, + DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, + EditDraftChunkResponse, ExposeServiceRequest, ImportProviderProfilesRequest, + ImportProviderProfilesResponse, Provider, ProviderProfile, ProviderProfileDiagnostic, + ProviderResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, + RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, + ServiceEndpointResponse, StartSandboxRequest, StopSandboxRequest, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, + UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, + WorkspaceSelector, +}; +use openshell_core::{GetResourceVersion, ObjectId}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use tonic::{Request, Response, Status}; + +use super::{ + Mutation, Scope, Success, global_scope, named_scope, replay_unavailable, restore_resource, + storage_error, uncertain, +}; +use crate::ServerState; +use crate::auth::principal::Principal; +use crate::auth::workspace_authz::{ + MinWorkspaceRole, authorize_workspace, authorize_workspace_selector, +}; +use crate::grpc::{policy, provider, sandbox, service}; +use crate::persistence::{ObjectType, SetResourceVersion, Store}; +use crate::storage_proto::{StoredProviderCredentialRefreshState, StoredProviderProfile}; + +#[derive(Clone, Serialize, Deserialize)] +pub(in crate::grpc) struct Reference { + id: String, + version: u64, +} + +impl Reference { + fn new(resource: &(impl ObjectId + GetResourceVersion)) -> Self { + Self { + id: resource.object_id().into(), + version: resource.get_resource_version(), + } + } + + async fn restore< + T: Message + Default + ObjectType + SetResourceVersion + GetResourceVersion, + >( + &self, + store: &Store, + ) -> Result { + restore_resource( + store, + Success::Resource { + id: self.id.clone(), + version: self.version, + }, + ) + .await + } +} + +/// Facts come from the handler's actual resource/write, never a later lookup by +/// mutable name. The collector only exists in the owner task and is not metadata. +#[derive(Clone, Default)] +pub struct Facts(Arc>); + +#[derive(Clone, Default)] +struct WriteFacts { + references: Vec, + refresh: Option, + global: bool, +} + +impl Facts { + pub(crate) fn global(&self) -> Result<(), Status> { + self.0.lock().map_err(|_| uncertain())?.global = true; + Ok(()) + } + pub(crate) fn from_request(request: &Request) -> Self { + request + .extensions() + .get::() + .cloned() + .unwrap_or_default() + } + + pub(crate) fn resource( + &self, + resource: &(impl ObjectId + GetResourceVersion), + ) -> Result<(), Status> { + self.0 + .lock() + .map_err(|_| uncertain())? + .references + .push(Reference::new(resource)); + Ok(()) + } + + pub(crate) fn refresh( + &self, + state: &StoredProviderCredentialRefreshState, + ) -> Result<(), Status> { + self.0.lock().map_err(|_| uncertain())?.refresh = Some(Refresh { + id: state.object_id().into(), + provider_id: state.provider_id.clone(), + epoch: crate::provider_refresh::effective_authorization_epoch(state)?.into(), + }); + Ok(()) + } +} + +fn facts(response: &Response) -> Result { + Ok(response + .extensions() + .get::() + .ok_or_else(uncertain)? + .0 + .lock() + .map_err(|_| uncertain())? + .clone()) +} + +fn parent(response: &Response) -> Result { + let facts = facts(response)?; + if facts.references.len() != 1 { + return Err(uncertain()); + } + Ok(facts.references[0].id.clone()) +} + +#[derive(Serialize, Deserialize)] +pub(in crate::grpc) enum Outcome { + Sandbox { + id: String, + changed: bool, + }, + SandboxDeletion { + id: String, + outcome: i32, + }, + Provider(Reference), + Service { + reference: Reference, + sandbox_id: String, + url: String, + }, + Profiles { + references: Vec, + diagnostics: Vec, + changed: bool, + }, + Config { + sandbox_id: Option, + version: u32, + policy_hash: String, + settings_revision: u64, + deleted: bool, + annotations: HashMap, + }, + Policy { + sandbox_id: String, + version: u32, + hash: String, + approved: u32, + skipped: u32, + cleared: u32, + }, + Refresh(Refresh), +} + +/// Public profile declarations and diagnostics have a nonsecret contract. These +/// fields may echo declaration text; they are not arbitrary sanitized payloads. +#[derive(Serialize, Deserialize)] +pub(in crate::grpc) struct Diagnostic { + source: String, + profile_id: String, + field: String, + message: String, + severity: String, +} + +impl From<&ProviderProfileDiagnostic> for Diagnostic { + fn from(value: &ProviderProfileDiagnostic) -> Self { + Self { + source: value.source.clone(), + profile_id: value.profile_id.clone(), + field: value.field.clone(), + message: value.message.clone(), + severity: value.severity.clone(), + } + } +} +impl From for ProviderProfileDiagnostic { + fn from(value: Diagnostic) -> Self { + Self { + source: value.source, + profile_id: value.profile_id, + field: value.field, + message: value.message, + severity: value.severity, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub(in crate::grpc) struct Refresh { + id: String, + provider_id: String, + epoch: String, +} + +fn outcome(success: Success) -> Result { + match success { + Success::Ordinary(value) => Ok(value), + _ => Err(replay_unavailable()), + } +} + +async fn live( + store: &Store, + id: &str, +) -> Result { + store + .get_message(id) + .await + .map_err(storage_error)? + .ok_or_else(replay_unavailable) +} + +async fn selected_scope( + state: &ServerState, + principal: &Principal, + selector: Option<&WorkspaceSelector>, + role: MinWorkspaceRole, +) -> Result { + let authz = + authorize_workspace_selector(&state.store, &state.admin_role, principal, selector, role) + .await?; + named_scope(state, &authz.workspace).await +} + +async fn profile_scope( + state: &ServerState, + principal: &Principal, + workspace: &str, +) -> Result { + if workspace.is_empty() { + return global_scope(state, principal); + } + let authz = authorize_workspace( + &state.store, + &state.admin_role, + principal, + workspace, + MinWorkspaceRole::Admin, + ) + .await?; + named_scope(state, &authz.workspace).await +} + +macro_rules! mutation { + ($req:ty, $resp:ty, $method:literal, $handler:path, $auth:expr, $capture:expr, $restore:expr) => { + #[tonic::async_trait] + impl Mutation for $req { + type Output = $resp; + const METHOD: &'static str = $method; + const PROTECTED: bool = true; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize( + &self, + state: &ServerState, + principal: &Principal, + ) -> Result { + ($auth)(self, state, principal).await + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + $handler(state, request).await + } + fn capture(response: &Response) -> Result { + ($capture)(response).map(Success::Ordinary) + } + async fn restore(store: &Store, success: Success) -> Result { + ($restore)(store, outcome(success)?).await + } + } + }; +} + +macro_rules! scoped_mutation { + ($req:ty, $resp:ty, $method:literal, $handler:path, $role:ident, $capture:expr, $restore:expr) => { + mutation!( + $req, + $resp, + $method, + $handler, + async |req: &$req, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::$role, + ) + .await + }, + $capture, + $restore + ); + }; +} + +fn sandbox_receipt(sandbox: Option<&Sandbox>, changed: bool) -> Result { + Ok(Outcome::Sandbox { + id: sandbox.ok_or_else(uncertain)?.object_id().into(), + changed, + }) +} + +macro_rules! sandbox_mutation { + ($req:ty, $method:literal, $handler:path) => { + scoped_mutation!( + $req, + SandboxResponse, + $method, + $handler, + User, + |response: &Response| sandbox_receipt( + response.get_ref().sandbox.as_ref(), + false + ), + async |store: &Store, outcome: Outcome| { + let Outcome::Sandbox { id, .. } = outcome else { + return Err(replay_unavailable()); + }; + Ok(SandboxResponse { + sandbox: Some(live(store, &id).await?), + }) + } + ); + }; +} +sandbox_mutation!( + CreateSandboxRequest, + "CreateSandbox", + sandbox::handle_create_sandbox +); +sandbox_mutation!( + StartSandboxRequest, + "StartSandbox", + sandbox::handle_start_sandbox +); +sandbox_mutation!( + StopSandboxRequest, + "StopSandbox", + sandbox::handle_stop_sandbox +); + +macro_rules! attachment_mutation { + ($req:ty, $resp:ident, $method:literal, $handler:path, $field:ident) => { + scoped_mutation!( + $req, + $resp, + $method, + $handler, + User, + |response: &Response<$resp>| sandbox_receipt( + response.get_ref().sandbox.as_ref(), + response.get_ref().$field + ), + async |store: &Store, outcome: Outcome| { + let Outcome::Sandbox { id, changed } = outcome else { + return Err(replay_unavailable()); + }; + Ok($resp { + sandbox: Some(live(store, &id).await?), + $field: changed, + }) + } + ); + }; +} +attachment_mutation!( + AttachSandboxProviderRequest, + AttachSandboxProviderResponse, + "AttachSandboxProvider", + sandbox::handle_attach_sandbox_provider, + attached +); +attachment_mutation!( + DetachSandboxProviderRequest, + DetachSandboxProviderResponse, + "DetachSandboxProvider", + sandbox::handle_detach_sandbox_provider, + detached +); + +scoped_mutation!( + DeleteSandboxRequest, + DeleteSandboxResponse, + "DeleteSandbox", + sandbox::handle_delete_sandbox, + User, + |response: &Response| Ok(Outcome::SandboxDeletion { + id: response.get_ref().sandbox_id.clone(), + outcome: response.get_ref().outcome + }), + async |_store: &Store, value: Outcome| { + let Outcome::SandboxDeletion { id, outcome } = value else { + return Err(replay_unavailable()); + }; + Ok(DeleteSandboxResponse { + sandbox_id: id, + outcome, + }) + } +); + +scoped_mutation!( + ExposeServiceRequest, + ServiceEndpointResponse, + "ExposeService", + service::handle_expose_service, + User, + |response: &Response| { + let value = response.get_ref(); + let endpoint = value.endpoint.as_ref().ok_or_else(uncertain)?; + Ok(Outcome::Service { + reference: Reference::new(endpoint), + sandbox_id: endpoint.sandbox_id.clone(), + url: value.url.clone(), + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Service { + reference, + sandbox_id, + url, + } = outcome + else { + return Err(replay_unavailable()); + }; + let _: Sandbox = live(store, &sandbox_id).await?; + Ok(ServiceEndpointResponse { + endpoint: Some(reference.restore(store).await?), + url, + }) + } +); + +macro_rules! provider_mutation { + ($req:ty, $method:literal, $handler:path) => { + scoped_mutation!( + $req, + ProviderResponse, + $method, + $handler, + Admin, + |response: &Response| Ok(Outcome::Provider(Reference::new( + response.get_ref().provider.as_ref().ok_or_else(uncertain)? + ))), + async |store: &Store, outcome: Outcome| { + let Outcome::Provider(reference) = outcome else { + return Err(replay_unavailable()); + }; + Ok(ProviderResponse { + provider: Some(provider::redact_provider_credentials( + reference.restore(store).await?, + )), + }) + } + ); + }; +} +provider_mutation!( + CreateProviderRequest, + "CreateProvider", + provider::handle_create_provider +); +provider_mutation!( + UpdateProviderRequest, + "UpdateProvider", + provider::handle_update_provider +); + +// Terminal receipts deliberately do not resolve a deleted parent or replacement. +macro_rules! ordinary_deletion { + ($req:ty, $resp:ident, $method:literal, $handler:path, $auth:expr) => { + #[tonic::async_trait] + impl Mutation for $req { + type Output = $resp; + const METHOD: &'static str = $method; + const PROTECTED: bool = true; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize( + &self, + state: &ServerState, + principal: &Principal, + ) -> Result { + ($auth)(self, state, principal).await + } + async fn execute( + state: &Arc, + request: Request, + ) -> Result, Status> { + $handler(state, request).await + } + fn capture(response: &Response) -> Result { + Ok(Success::Deletion { + outcome: response.get_ref().outcome, + }) + } + async fn restore(_store: &Store, success: Success) -> Result { + let Success::Deletion { outcome } = success else { + return Err(replay_unavailable()); + }; + Ok($resp { outcome }) + } + } + }; +} +ordinary_deletion!( + DeleteServiceRequest, + DeleteServiceResponse, + "DeleteService", + service::handle_delete_service, + async |req: &DeleteServiceRequest, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::User, + ) + .await + } +); +ordinary_deletion!( + DeleteProviderRequest, + DeleteProviderResponse, + "DeleteProvider", + provider::handle_delete_provider, + async |req: &DeleteProviderRequest, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::Admin, + ) + .await + } +); +ordinary_deletion!( + DeleteProviderRefreshRequest, + DeleteProviderRefreshResponse, + "DeleteProviderRefresh", + provider::handle_delete_provider_refresh, + async |req: &DeleteProviderRefreshRequest, state: &ServerState, principal: &Principal| { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::Admin, + ) + .await + } +); +ordinary_deletion!( + DeleteProviderProfileRequest, + DeleteProviderProfileResponse, + "DeleteProviderProfile", + provider::handle_delete_provider_profile, + async |req: &DeleteProviderProfileRequest, state: &ServerState, principal: &Principal| { + profile_scope(state, principal, &req.workspace).await + } +); + +async fn restore_profiles( + store: &Store, + references: Vec, +) -> Result, Status> { + let mut profiles = Vec::with_capacity(references.len()); + for reference in references { + let stored: StoredProviderProfile = reference.restore(store).await?; + profiles.push(crate::provider_profile_sources::profile_response_payload( + stored.profile.ok_or_else(replay_unavailable)?, + reference.version, + )); + } + Ok(profiles) +} + +mutation!( + ImportProviderProfilesRequest, + ImportProviderProfilesResponse, + "ImportProviderProfiles", + provider::handle_import_provider_profiles, + async |req: &ImportProviderProfilesRequest, state: &ServerState, principal: &Principal| { + profile_scope(state, principal, &req.workspace).await + }, + |response: &Response| { + let value = response.get_ref(); + let references = facts(response)?.references; + if references.len() != value.profiles.len() { + return Err(uncertain()); + } + Ok(Outcome::Profiles { + references, + diagnostics: value.diagnostics.iter().map(Diagnostic::from).collect(), + changed: value.imported, + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Profiles { + references, + diagnostics, + changed, + } = outcome + else { + return Err(replay_unavailable()); + }; + Ok(ImportProviderProfilesResponse { + profiles: restore_profiles(store, references).await?, + diagnostics: diagnostics.into_iter().map(Into::into).collect(), + imported: changed, + }) + } +); + +mutation!( + UpdateProviderProfilesRequest, + UpdateProviderProfilesResponse, + "UpdateProviderProfiles", + provider::handle_update_provider_profiles, + async |req: &UpdateProviderProfilesRequest, state: &ServerState, principal: &Principal| { + profile_scope(state, principal, &req.workspace).await + }, + |response: &Response| { + let value = response.get_ref(); + let references = facts(response)?.references; + if references.len() != usize::from(value.profile.is_some()) { + return Err(uncertain()); + } + Ok(Outcome::Profiles { + references, + diagnostics: value.diagnostics.iter().map(Diagnostic::from).collect(), + changed: value.updated, + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Profiles { + references, + diagnostics, + changed, + } = outcome + else { + return Err(replay_unavailable()); + }; + let mut profiles = restore_profiles(store, references).await?; + Ok(UpdateProviderProfilesResponse { + profile: profiles.pop(), + diagnostics: diagnostics.into_iter().map(Into::into).collect(), + updated: changed, + }) + } +); + +macro_rules! refresh_mutation { + ($req:ty, $resp:ident, $method:literal, $handler:path) => { + scoped_mutation!( + $req, + $resp, + $method, + $handler, + Admin, + |response: &Response<$resp>| Ok(Outcome::Refresh( + facts(response)?.refresh.ok_or_else(uncertain)? + )), + async |store: &Store, outcome: Outcome| { + let Outcome::Refresh(reference) = outcome else { + return Err(replay_unavailable()); + }; + let state: StoredProviderCredentialRefreshState = + live(store, &reference.id).await?; + let _: Provider = live(store, &reference.provider_id).await?; + if state + .metadata + .as_ref() + .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + || state.provider_id != reference.provider_id + || crate::provider_refresh::effective_authorization_epoch(&state)? + != reference.epoch + { + return Err(replay_unavailable()); + } + Ok($resp { + status: Some(crate::provider_refresh::refresh_status_from_state(&state)), + }) + } + ); + }; +} +refresh_mutation!( + ConfigureProviderRefreshRequest, + ConfigureProviderRefreshResponse, + "ConfigureProviderRefresh", + provider::handle_configure_provider_refresh +); +refresh_mutation!( + RotateProviderCredentialRequest, + RotateProviderCredentialResponse, + "RotateProviderCredential", + provider::handle_rotate_provider_credential +); + +mutation!( + UpdateConfigRequest, + UpdateConfigResponse, + "UpdateConfig", + policy::handle_update_config, + async |req: &UpdateConfigRequest, state: &ServerState, principal: &Principal| { + if req.global { + if req.workspace_scope.is_some() { + return Err(Status::invalid_argument( + "workspace_scope must be omitted when global is true", + )); + } + global_scope(state, principal) + } else { + selected_scope( + state, + principal, + req.workspace_scope.as_ref(), + MinWorkspaceRole::Admin, + ) + .await + } + }, + |response: &Response| { + let value = response.get_ref(); + let facts = facts(response)?; + let references = facts.references; + if references.len() != usize::from(!facts.global) { + return Err(uncertain()); + } + Ok(Outcome::Config { + sandbox_id: references.first().map(|r| r.id.clone()), + version: value.version, + policy_hash: value.policy_hash.clone(), + settings_revision: value.settings_revision, + deleted: value.deleted, + annotations: value.annotations.clone(), + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Config { + sandbox_id, + version, + policy_hash, + settings_revision, + deleted, + annotations, + } = outcome + else { + return Err(replay_unavailable()); + }; + if let Some(id) = sandbox_id { + let _: Sandbox = live(store, &id).await?; + } + Ok(UpdateConfigResponse { + version, + policy_hash, + settings_revision, + deleted, + annotations, + }) + } +); + +macro_rules! policy_mutation { + ($req:ty, $resp:ty, $method:literal, $handler:path, $values:expr, $restore:expr) => { + scoped_mutation!( + $req, + $resp, + $method, + $handler, + Admin, + |response: &Response<$resp>| { + let (version, hash, approved, skipped, cleared) = ($values)(response.get_ref()); + Ok(Outcome::Policy { + sandbox_id: parent(response)?, + version, + hash, + approved, + skipped, + cleared, + }) + }, + async |store: &Store, outcome: Outcome| { + let Outcome::Policy { + sandbox_id, + version, + hash, + approved, + skipped, + cleared, + } = outcome + else { + return Err(replay_unavailable()); + }; + let _: Sandbox = live(store, &sandbox_id).await?; + Ok(($restore)(version, hash, approved, skipped, cleared)) + } + ); + }; +} +policy_mutation!( + ApproveDraftChunkRequest, + ApproveDraftChunkResponse, + "ApproveDraftChunk", + policy::handle_approve_draft_chunk, + |v: &ApproveDraftChunkResponse| (v.policy_version, v.policy_hash.clone(), 0, 0, 0), + |version, hash, _, _, _| ApproveDraftChunkResponse { + policy_version: version, + policy_hash: hash + } +); +policy_mutation!( + UndoDraftChunkRequest, + UndoDraftChunkResponse, + "UndoDraftChunk", + policy::handle_undo_draft_chunk, + |v: &UndoDraftChunkResponse| (v.policy_version, v.policy_hash.clone(), 0, 0, 0), + |version, hash, _, _, _| UndoDraftChunkResponse { + policy_version: version, + policy_hash: hash + } +); +policy_mutation!( + ApproveAllDraftChunksRequest, + ApproveAllDraftChunksResponse, + "ApproveAllDraftChunks", + policy::handle_approve_all_draft_chunks, + |v: &ApproveAllDraftChunksResponse| ( + v.policy_version, + v.policy_hash.clone(), + v.chunks_approved, + v.chunks_skipped, + 0 + ), + |version, hash, approved, skipped, _| ApproveAllDraftChunksResponse { + policy_version: version, + policy_hash: hash, + chunks_approved: approved, + chunks_skipped: skipped + } +); +policy_mutation!( + ClearDraftChunksRequest, + ClearDraftChunksResponse, + "ClearDraftChunks", + policy::handle_clear_draft_chunks, + |v: &ClearDraftChunksResponse| (0, String::new(), 0, 0, v.chunks_cleared), + |_, _, _, _, cleared| ClearDraftChunksResponse { + chunks_cleared: cleared + } +); +policy_mutation!( + RejectDraftChunkRequest, + RejectDraftChunkResponse, + "RejectDraftChunk", + policy::handle_reject_draft_chunk, + |_: &RejectDraftChunkResponse| (0, String::new(), 0, 0, 0), + |_, _, _, _, _| RejectDraftChunkResponse {} +); +policy_mutation!( + EditDraftChunkRequest, + EditDraftChunkResponse, + "EditDraftChunk", + policy::handle_edit_draft_chunk, + |_: &EditDraftChunkResponse| (0, String::new(), 0, 0, 0), + |_, _, _, _, _| EditDraftChunkResponse {} +); diff --git a/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs new file mode 100644 index 0000000000..df52879de9 --- /dev/null +++ b/crates/openshell-server/src/grpc/mutation_replay/ordinary/tests.rs @@ -0,0 +1,917 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use crate::grpc::mutation_replay::tests::reason; +use crate::grpc::mutation_replay::{Admission, OBJECT_TYPE, OriginalMutation, fingerprint, run}; +use crate::grpc::test_support::{authed_request, test_server_state}; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::{ + DeletionOutcome, DraftChunkApproval, GetDraftPolicyRequest, NetworkBinary, NetworkEndpoint, + NetworkPolicyRule, PolicyChunk, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, + ProviderCredentialRefreshStrategy, ProviderProfileCategory, ProviderProfileCredential, + ProviderProfileImportItem, SandboxPhase, SandboxPolicy, SandboxSpec, ServiceEndpoint, + SettingValue, SubmitPolicyAnalysisRequest, WorkspaceMember, WorkspaceRole, setting_value, + workspace_selector, +}; +use tonic::Code; + +async fn protected_state() -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().unwrap(); + let mut state = test_server_state().await; + configure_key(&mut state, &directory); + (directory, state) +} + +fn configure_key(state: &mut Arc, directory: &tempfile::TempDir) { + let key = directory.path().join("private-key"); + std::fs::write(&key, b"test-only stable private fingerprint material").unwrap(); + Arc::get_mut(state).unwrap().config.gateway_jwt = + Some(openshell_core::config::GatewayJwtConfig { + signing_key_path: key, + public_key_path: directory.path().join("public"), + kid_path: directory.path().join("kid"), + gateway_id: "test".into(), + ttl_secs: None, + }); +} + +pub(in crate::grpc::mutation_replay) async fn exercise_protected_backend(url: &str) { + use crate::grpc::mutation_replay::tests::state_for; + let directory = tempfile::tempdir().unwrap(); + let mut first = state_for(Store::connect(url).await.unwrap()).await; + let mut second = state_for(Store::connect(url).await.unwrap()).await; + configure_key(&mut first, &directory); + configure_key(&mut second, &directory); + let req = DeleteSandboxRequest { + name: "keyed-restart".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + let mut tasks = Vec::new(); + for index in 0..16 { + let state = if index % 2 == 0 { + first.clone() + } else { + second.clone() + }; + let req = req.clone(); + tasks.push(tokio::spawn(async move { + run(&state, authed_request(req)).await + })); + } + for task in tasks { + if let Err(status) = task.await.unwrap() { + assert_eq!(reason(&status), "REQUEST_OUTCOME_UNCERTAIN"); + } + } + drop(first); + drop(second); + let mut restarted = state_for(Store::connect(url).await.unwrap()).await; + configure_key(&mut restarted, &directory); + let replacement = Sandbox { + metadata: Some(meta(&req.name)), + ..Default::default() + }; + restarted.store.put_message(&replacement).await.unwrap(); + assert_eq!( + replay(&restarted, req).await.outcome, + i32::from(DeletionOutcome::AlreadyAbsent) + ); + assert!( + restarted + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn ordinary_replay_reauthorizes_workspace_role() { + let (_directory, mut state) = protected_state().await; + Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".into(); + let req = DeleteProviderRequest { + name: "missing".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + run(&state, authed_request(req.clone())).await.unwrap(); + let member = WorkspaceMember { + metadata: Some(meta("dev-user")), + principal_subject: "dev-user".into(), + role: WorkspaceRole::User.into(), + }; + state.store.put_message(&member).await.unwrap(); + let mut request = authed_request(req); + let principal = request.extensions_mut().get_mut::().unwrap(); + if let Principal::User(user) = principal { + user.identity.roles.clear(); + } + let principal = principal.clone(); + assert!( + CreateSandboxRequest { + workspace_scope: Some(scope()), + ..Default::default() + } + .authorize(&state, &principal) + .await + .is_ok() + ); + assert_eq!( + run(&state, request).await.unwrap_err().code(), + Code::PermissionDenied + ); + for global in [false, true] { + let req = UpdateConfigRequest { + global, + workspace_scope: if global { None } else { Some(scope()) }, + ..Default::default() + }; + assert!(req.authorize(&state, &principal).await.is_err()); + } + assert!( + ImportProviderProfilesRequest::default() + .authorize(&state, &principal) + .await + .is_err() + ); +} + +#[tokio::test] +async fn oversized_public_diagnostics_leave_a_bounded_unresolved_claim() { + let (_directory, state) = protected_state().await; + let request = ImportProviderProfilesRequest { + request_id: id(), + profiles: (0..80) + .map(|_| ProviderProfileImportItem { + profile: None, + source: "public-source".repeat(100), + }) + .collect(), + ..Default::default() + }; + for _ in 0..2 { + assert_eq!( + reason( + &run(&state, authed_request(request.clone())) + .await + .unwrap_err() + ), + "REQUEST_OUTCOME_UNCERTAIN" + ); + } + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert!(rows[0].payload.len() < 1024); + assert!( + serde_json::from_slice::(&rows[0].payload) + .unwrap() + .success + .is_none() + ); +} + +fn scope() -> WorkspaceSelector { + workspace_selector("default") +} +fn id() -> String { + uuid::Uuid::new_v4().to_string() +} +fn meta(name: &str) -> ObjectMeta { + ObjectMeta { + id: id(), + name: name.into(), + workspace: "default".into(), + ..Default::default() + } +} + +fn create_sandbox(name: &str) -> CreateSandboxRequest { + CreateSandboxRequest { + name: name.into(), + request_id: id(), + workspace_scope: Some(scope()), + spec: Some(SandboxSpec { + environment: HashMap::from([("PRIVATE_VALUE".into(), "low-entropy-secret".into())]), + ..Default::default() + }), + ..Default::default() + } +} + +async fn replay(state: &Arc, req: M) -> M::Output { + let response = run(state, authed_request(req)).await.unwrap(); + assert_eq!( + response.metadata().get("openshell-replayed").unwrap(), + "true" + ); + response.into_inner() +} + +#[tokio::test] +async fn sandbox_replay_survives_status_churn_but_never_selects_replacement() { + let (_directory, state) = protected_state().await; + let req = create_sandbox("original"); + let created = run(&state, authed_request(req.clone())) + .await + .unwrap() + .into_inner() + .sandbox + .unwrap(); + state + .store + .update_message_cas::( + created.object_id(), + created.get_resource_version(), + |sandbox| { + sandbox.status.as_mut().unwrap().phase = SandboxPhase::Ready.into(); + }, + ) + .await + .unwrap(); + let returned = replay(&state, req.clone()).await.sandbox.unwrap(); + assert_eq!(returned.object_id(), created.object_id()); + assert!(returned.get_resource_version() > created.get_resource_version()); + let rows = state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap(); + let receipt = String::from_utf8(rows[0].payload.clone()).unwrap(); + assert!(!receipt.contains("low-entropy-secret")); + assert!(!receipt.contains("PRIVATE_VALUE")); + assert!(!receipt.contains(&fingerprint(&req).unwrap())); + state + .store + .delete(Sandbox::object_type(), created.object_id()) + .await + .unwrap(); + let replacement = Sandbox { + metadata: Some(meta("original")), + ..Default::default() + }; + state.store.put_message(&replacement).await.unwrap(); + assert_eq!( + reason(&run(&state, authed_request(req)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert!( + state + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn keyed_fingerprints_fail_closed_on_missing_or_rotated_keys() { + let missing = test_server_state().await; + let req = DeleteSandboxRequest { + name: "missing".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + assert_eq!( + reason( + &run(&missing, authed_request(req.clone())) + .await + .unwrap_err() + ), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert!( + missing + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + .is_empty() + ); + let (directory, state) = protected_state().await; + run(&state, authed_request(req.clone())).await.unwrap(); + std::fs::write( + directory.path().join("private-key"), + b"rotated-private-material", + ) + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(req)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + assert_eq!( + state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn original_payload_is_identity_and_current_transformation_is_a_replay_guard() { + let (_directory, state) = protected_state().await; + let original = DeleteSandboxRequest { + name: "original".into(), + workspace_scope: Some(scope()), + allow_missing: true, + request_id: id(), + }; + let mut effective = original.clone(); + effective.name = "transformed".into(); + let intercepted = |original: &DeleteSandboxRequest, effective: DeleteSandboxRequest| { + let mut request = authed_request(effective); + request + .extensions_mut() + .insert(OriginalMutation(original.encode_to_vec())); + request + }; + run(&state, intercepted(&original, effective.clone())) + .await + .unwrap(); + assert!( + run(&state, intercepted(&original, effective.clone())) + .await + .unwrap() + .metadata() + .contains_key("openshell-replayed") + ); + let mut changed = original.clone(); + changed.name = "different-original".into(); + assert_eq!( + reason( + &run(&state, intercepted(&changed, effective.clone())) + .await + .unwrap_err() + ), + "REQUEST_ID_PAYLOAD_MISMATCH" + ); + effective.name = "changed-transformation".into(); + assert_eq!( + reason( + &run(&state, intercepted(&original, effective)) + .await + .unwrap_err() + ), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[tokio::test] +async fn interceptors_cannot_enable_disable_or_replace_request_ids() { + let state = test_server_state().await; + for (original_id, effective_id) in [(String::new(), id()), (id(), String::new()), (id(), id())] + { + let original = CreateSandboxRequest { + request_id: original_id, + ..Default::default() + }; + let mut req = authed_request(CreateSandboxRequest { + request_id: effective_id, + ..Default::default() + }); + req.extensions_mut() + .insert(OriginalMutation(original.encode_to_vec())); + assert_eq!( + run(&state, req).await.unwrap_err().code(), + Code::InvalidArgument + ); + } + assert!( + state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn service_deletion_replays_without_parent_and_does_not_delete_replacement() { + let (_directory, state) = protected_state().await; + let sandbox = Sandbox { + metadata: Some(meta("service-parent")), + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + let expose = ExposeServiceRequest { + sandbox: "service-parent".into(), + service: "web".into(), + target_port: 8080, + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let endpoint = run(&state, authed_request(expose.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, expose.clone()).await, endpoint); + let delete = DeleteServiceRequest { + sandbox: "service-parent".into(), + service: "web".into(), + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let deleted = run(&state, authed_request(delete.clone())) + .await + .unwrap() + .into_inner(); + let mut fresh = expose; + fresh.request_id = id(); + let replacement = run(&state, authed_request(fresh)) + .await + .unwrap() + .into_inner() + .endpoint + .unwrap(); + state + .store + .delete(Sandbox::object_type(), sandbox.object_id()) + .await + .unwrap(); + assert_eq!(replay(&state, delete).await, deleted); + assert!( + state + .store + .get_message::(replacement.object_id()) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn provider_replay_is_redacted_and_update_does_not_recheck_stale_version() { + let (_directory, state) = protected_state().await; + let create = CreateProviderRequest { + provider: Some(Provider { + metadata: Some(meta("replay-provider")), + r#type: "openai".into(), + credentials: HashMap::from([("OPENAI_API_KEY".into(), "provider-secret".into())]), + ..Default::default() + }), + workspace_scope: Some(scope()), + request_id: id(), + }; + let first = run(&state, authed_request(create.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, create).await, first); + let mut provider = first.provider.unwrap(); + provider.credentials = HashMap::from([("OPENAI_API_KEY".into(), "updated-secret".into())]); + let update = UpdateProviderRequest { + provider: Some(provider), + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let updated = run(&state, authed_request(update.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, update).await, updated); + let provider = updated.provider.unwrap(); + assert!(provider.credential_handles.is_empty()); + assert!( + provider + .credentials + .values() + .all(|value| value == "REDACTED") + ); + for row in state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + { + let receipt = String::from_utf8(row.payload).unwrap(); + assert!(!receipt.contains("provider-secret")); + assert!(!receipt.contains("updated-secret")); + } +} + +#[tokio::test] +async fn profile_receipts_use_private_identity_and_preserve_diagnostics() { + let (_directory, state) = protected_state().await; + let profile = ProviderProfile { + id: "replay-profile".into(), + display_name: "Replay".into(), + category: ProviderProfileCategory::Other.into(), + ..Default::default() + }; + let create = ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "test.yaml".into(), + }], + workspace: "default".into(), + request_id: id(), + }; + let first = run(&state, authed_request(create.clone())) + .await + .unwrap() + .into_inner(); + assert!(first.imported, "{:?}", first.diagnostics); + assert_eq!(replay(&state, create.clone()).await, first); + let mut profile = first.profiles[0].clone(); + profile.display_name = "Updated".into(); + let update = UpdateProviderProfilesRequest { + id: profile.id.clone(), + profile: Some(ProviderProfileImportItem { + profile: Some(profile), + source: "update.yaml".into(), + }), + workspace: "default".into(), + request_id: id(), + ..Default::default() + }; + let updated = run(&state, authed_request(update.clone())) + .await + .unwrap() + .into_inner(); + assert!(updated.updated); + assert_eq!(replay(&state, update).await, updated); + assert_eq!( + reason(&run(&state, authed_request(create)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); + let invalid = ImportProviderProfilesRequest { + request_id: id(), + ..Default::default() + }; + let diagnostics = run(&state, authed_request(invalid.clone())) + .await + .unwrap() + .into_inner(); + assert!(!diagnostics.imported); + assert!(!diagnostics.diagnostics.is_empty()); + assert_eq!(replay(&state, invalid).await, diagnostics); +} + +#[tokio::test] +async fn config_and_clear_receipts_preserve_revision_and_parent_lifetime() { + let (_directory, state) = protected_state().await; + let sandbox = Sandbox { + metadata: Some(meta("policy-parent")), + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + for global in [false, true] { + let update = UpdateConfigRequest { + name: if global { + String::new() + } else { + "policy-parent".into() + }, + global, + setting_key: "ocsf_json_enabled".into(), + setting_value: Some(SettingValue { + value: Some(setting_value::Value::BoolValue(true)), + }), + workspace_scope: if global { None } else { Some(scope()) }, + request_id: id(), + ..Default::default() + }; + let first = run(&state, authed_request(update.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, update).await, first); + } + let clear = ClearDraftChunksRequest { + name: "policy-parent".into(), + workspace_scope: Some(scope()), + request_id: id(), + }; + let first = run(&state, authed_request(clear.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, clear.clone()).await, first); + state + .store + .delete(Sandbox::object_type(), sandbox.object_id()) + .await + .unwrap(); + assert_eq!( + reason(&run(&state, authed_request(clear)).await.unwrap_err()), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[tokio::test] +async fn refresh_receipts_never_replay_a_new_grant_epoch() { + let state = test_server_state().await; + let provider = Provider { + metadata: Some(meta("refresh-parent")), + ..Default::default() + }; + state.store.put_message(&provider).await.unwrap(); + let mut refresh = StoredProviderCredentialRefreshState { + metadata: Some(meta("refresh-record")), + provider_id: provider.object_id().into(), + authorization_epoch: id(), + ..Default::default() + }; + state.store.put_message(&refresh).await.unwrap(); + let receipt = || { + Success::Ordinary(Outcome::Refresh(Refresh { + id: refresh.object_id().into(), + provider_id: provider.object_id().into(), + epoch: refresh.authorization_epoch.clone(), + })) + }; + ConfigureProviderRefreshRequest::restore(&state.store, receipt()) + .await + .unwrap(); + let original = receipt(); + refresh.authorization_epoch = id(); + state.store.put_message(&refresh).await.unwrap(); + assert_eq!( + reason( + &ConfigureProviderRefreshRequest::restore(&state.store, original) + .await + .unwrap_err() + ), + "REQUEST_REPLAY_UNAVAILABLE" + ); +} + +#[tokio::test] +async fn configure_and_rotate_capture_actual_grant_without_repeating_token_exchange() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + let (_directory, state) = protected_state().await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "minted-secret", "token_type": "Bearer", "expires_in": 3600, + }))) + .expect(1) + .mount(&server) + .await; + let strategy = ProviderCredentialRefreshStrategy::Oauth2ClientCredentials; + let profile = ProviderProfile { + id: "refresh-profile".into(), + display_name: "Refresh".into(), + category: ProviderProfileCategory::Other.into(), + credentials: vec![ProviderProfileCredential { + name: "access_token".into(), + env_vars: vec!["ACCESS_TOKEN".into()], + auth_style: "bearer".into(), + header_name: "Authorization".into(), + refresh: Some(ProviderCredentialRefresh { + strategy: strategy.into(), + token_url: server.uri(), + material: vec![ + ProviderCredentialRefreshMaterial { + name: "client_id".into(), + required: true, + ..Default::default() + }, + ProviderCredentialRefreshMaterial { + name: "client_secret".into(), + secret: true, + required: true, + ..Default::default() + }, + ], + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + let imported = run( + &state, + authed_request(ImportProviderProfilesRequest { + workspace: "default".into(), + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "refresh.yaml".into(), + }], + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "{:?}", imported.diagnostics); + run( + &state, + authed_request(CreateProviderRequest { + workspace_scope: Some(scope()), + provider: Some(Provider { + metadata: Some(meta("refresh-provider")), + r#type: "refresh-profile".into(), + profile_workspace: "default".into(), + credentials: HashMap::from([("ACCESS_TOKEN".into(), "initial-secret".into())]), + ..Default::default() + }), + ..Default::default() + }), + ) + .await + .unwrap(); + let configure = ConfigureProviderRefreshRequest { + provider: "refresh-provider".into(), + credential_key: "ACCESS_TOKEN".into(), + strategy: strategy.into(), + workspace_scope: Some(scope()), + request_id: id(), + material: HashMap::from([ + ("client_id".into(), "client".into()), + ("client_secret".into(), "configured-secret".into()), + ]), + ..Default::default() + }; + let configured = run(&state, authed_request(configure.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, configure.clone()).await, configured); + let rotate = RotateProviderCredentialRequest { + provider: "refresh-provider".into(), + credential_key: "ACCESS_TOKEN".into(), + workspace_scope: Some(scope()), + request_id: id(), + }; + let rotated = run(&state, authed_request(rotate.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, rotate).await, rotated); + assert_eq!(replay(&state, configure).await.status, rotated.status); + for row in state + .store + .list_by_type_after(OBJECT_TYPE, None, 100) + .await + .unwrap() + { + let receipt = String::from_utf8(row.payload).unwrap(); + for secret in ["initial-secret", "configured-secret", "minted-secret"] { + assert!(!receipt.contains(secret)); + } + } + server.verify().await; +} + +#[tokio::test] +async fn draft_receipts_replay_after_chunk_state_and_review_tokens_change() { + let (_directory, state) = protected_state().await; + let name = "draft-parent"; + let sandbox = Sandbox { + metadata: Some(meta(name)), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy::default()), + ..Default::default() + }), + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + let rule = |name: &str| NetworkPolicyRule { + name: name.into(), + endpoints: vec![NetworkEndpoint { + host: format!("{name}.example.com"), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".into(), + }], + }; + let submitted = policy::handle_submit_policy_analysis( + &state, + authed_request(SubmitPolicyAnalysisRequest { + name: name.into(), + analysis_mode: "agent_authored".into(), + proposed_chunks: ["alpha", "beta", "gamma"] + .into_iter() + .map(|name| PolicyChunk { + rule_name: name.into(), + proposed_rule: Some(rule(name)), + ..Default::default() + }) + .collect(), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!( + submitted.accepted_chunk_ids.len(), + 3, + "{:?}", + submitted.rejection_reasons + ); + let edit = EditDraftChunkRequest { + name: name.into(), + chunk_id: submitted.accepted_chunk_ids[0].clone(), + proposed_rule: Some(rule("edited")), + workspace_scope: Some(scope()), + request_id: id(), + }; + run(&state, authed_request(edit.clone())).await.unwrap(); + let draft = policy::handle_get_draft_policy( + &state, + authed_request(GetDraftPolicyRequest { + name: name.into(), + workspace_scope: Some(scope()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let token = |id: &str| { + draft + .chunks + .iter() + .find(|chunk| chunk.id == id) + .unwrap() + .review_token + .clone() + }; + let approve = ApproveDraftChunkRequest { + name: name.into(), + chunk_id: edit.chunk_id.clone(), + review_token: token(&edit.chunk_id), + workspace_scope: Some(scope()), + request_id: id(), + }; + let approved = run(&state, authed_request(approve.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, approve.clone()).await, approved); + replay(&state, edit).await; + let undo = UndoDraftChunkRequest { + name: name.into(), + chunk_id: approve.chunk_id.clone(), + workspace_scope: Some(scope()), + request_id: id(), + }; + let undone = run(&state, authed_request(undo.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(replay(&state, undo).await, undone); + assert_eq!(replay(&state, approve).await, approved); + let reject = RejectDraftChunkRequest { + name: name.into(), + chunk_id: submitted.accepted_chunk_ids[1].clone(), + reason: "not needed".into(), + workspace_scope: Some(scope()), + request_id: id(), + }; + run(&state, authed_request(reject.clone())).await.unwrap(); + replay(&state, reject).await; + let draft = policy::handle_get_draft_policy( + &state, + authed_request(GetDraftPolicyRequest { + name: name.into(), + workspace_scope: Some(scope()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_inner(); + let chunk = draft + .chunks + .iter() + .find(|chunk| chunk.id == submitted.accepted_chunk_ids[2]) + .unwrap(); + let all = ApproveAllDraftChunksRequest { + name: name.into(), + approvals: vec![DraftChunkApproval { + chunk_id: chunk.id.clone(), + review_token: chunk.review_token.clone(), + }], + workspace_scope: Some(scope()), + request_id: id(), + ..Default::default() + }; + let approved = run(&state, authed_request(all.clone())) + .await + .unwrap() + .into_inner(); + assert_eq!(approved.chunks_approved, 1); + assert_eq!(replay(&state, all).await, approved); +} diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index 59b74b401a..6f79c916e4 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -23,7 +23,7 @@ fn create(name: &str) -> CreateWorkspaceRequest { } } -fn reason(status: &Status) -> String { +pub(super) fn reason(status: &Status) -> String { assert!(status.get_error_details().retry_info().is_none()); status .get_error_details() @@ -33,7 +33,7 @@ fn reason(status: &Status) -> String { .clone() } -async fn state_for(store: Store) -> Arc { +pub(super) async fn state_for(store: Store) -> Arc { let store = Arc::new(store); crate::ensure_default_workspace(&store).await.unwrap(); let compute = crate::compute::new_test_runtime(store.clone()).await; @@ -170,6 +170,7 @@ async fn exercise_backend(url: &str) { "REQUEST_REPLAY_UNAVAILABLE" ); exercise_expiry_and_uncertainty(&restarted).await; + ordinary::tests::exercise_protected_backend(url).await; } #[tokio::test] @@ -487,8 +488,8 @@ impl Mutation for ControlledCreate { } Ok(response) } - fn capture(response: &Self::Output) -> Result { - resource_success(response.workspace.as_ref()) + fn capture(response: &Response) -> Result { + resource_success(response.get_ref().workspace.as_ref()) } async fn restore(store: &Store, success: Success) -> Result { Ok(CreateWorkspaceResponse { @@ -543,6 +544,7 @@ async fn quota_fails_closed_but_replays_and_expired_success_cleanup_still_work() .unwrap() .remove(0); let pending = Admission { + protection: None, format_version: 1, payload_hash: "unused".into(), workspace_id: None, diff --git a/crates/openshell-server/src/grpc/mutation_tests.rs b/crates/openshell-server/src/grpc/mutation_tests.rs index e0dfcd7584..d1b135dccc 100644 --- a/crates/openshell-server/src/grpc/mutation_tests.rs +++ b/crates/openshell-server/src/grpc/mutation_tests.rs @@ -157,6 +157,7 @@ async fn allow_missing_does_not_hide_missing_parents_or_invalid_requests() { let err = provider::handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), provider: "missing-parent".into(), credential_key: "API_KEY".into(), allow_missing: true, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 936211b265..46aadb4492 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3357,6 +3357,7 @@ async fn handle_update_config_inner( principal: &Principal, sandbox_caller: bool, ) -> Result, Status> { + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); validate_annotations(&req.annotations, "annotations")?; let workspace = if req.global { @@ -3366,6 +3367,7 @@ async fn handle_update_config_inner( )); } require_platform_admin(&state.admin_role, principal)?; + replay_facts.global()?; String::new() } else { let min_role = if sandbox_caller { @@ -3612,6 +3614,7 @@ async fn handle_update_config_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let mut response_annotations = sandbox_metadata_annotations(&sandbox); if has_setting { @@ -4917,6 +4920,7 @@ async fn handle_approve_draft_chunk_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -4945,6 +4949,7 @@ async fn handle_approve_draft_chunk_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5072,6 +5077,7 @@ async fn handle_reject_draft_chunk_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5098,6 +5104,7 @@ async fn handle_reject_draft_chunk_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5182,6 +5189,7 @@ async fn handle_approve_all_draft_chunks_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5207,6 +5215,7 @@ async fn handle_approve_all_draft_chunks_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let pending_chunks = state .store @@ -5490,6 +5499,7 @@ pub(super) async fn handle_edit_draft_chunk( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5519,6 +5529,7 @@ pub(super) async fn handle_edit_draft_chunk( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5571,6 +5582,7 @@ async fn handle_undo_draft_chunk_inner( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5597,6 +5609,7 @@ async fn handle_undo_draft_chunk_inner( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let chunk = state .store @@ -5668,6 +5681,7 @@ pub(super) async fn handle_clear_draft_chunks( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let req = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -5691,6 +5705,7 @@ pub(super) async fn handle_clear_draft_chunks( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; let sandbox_id = sandbox.object_id().to_string(); + replay_facts.resource(&sandbox)?; let deleted = state .store @@ -8991,6 +9006,7 @@ mod tests { let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { + request_id: String::new(), global: true, setting_key: "log_level".to_string(), delete_setting: true, @@ -9085,6 +9101,7 @@ mod tests { let error = handle_update_config( &state, Request::new(UpdateConfigRequest { + request_id: String::new(), global: true, setting_key: "log_level".to_string(), delete_setting: true, @@ -11032,6 +11049,7 @@ mod tests { let error = super::super::sandbox::handle_attach_sandbox_provider( &state, authed_request(openshell_core::proto::AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "provider-ambiguity".to_string(), provider_name: "candidate-provider".to_string(), expected_resource_version: 0, @@ -11297,6 +11315,7 @@ mod tests { let response = handle_update_provider_profiles( &state, with_user(Request::new(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(updated_profile), source: "custom-policy.yaml".to_string(), @@ -12347,6 +12366,7 @@ mod tests { handle_update_provider_profiles( &state, with_user(Request::new(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(rotated_profile), source: "custom-token.yaml".to_string(), @@ -12506,6 +12526,7 @@ mod tests { handle_attach_sandbox_provider( &state, with_user(Request::new(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -12546,6 +12567,7 @@ mod tests { handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -12598,6 +12620,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { source: "custom-api.yaml".to_string(), profile: Some(ProviderProfile { @@ -12678,6 +12701,7 @@ mod tests { handle_attach_sandbox_provider( &state, with_user(Request::new(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, @@ -12721,6 +12745,7 @@ mod tests { handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "attach-lifecycle".to_string(), provider_name: "work-custom".to_string(), expected_resource_version: 0, @@ -13369,6 +13394,7 @@ mod tests { let skipped = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { + request_id: String::new(), name: sandbox_name.to_string(), include_security_flagged: false, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13399,6 +13425,7 @@ mod tests { let approved = handle_approve_all_draft_chunks( &state, with_user(Request::new(ApproveAllDraftChunksRequest { + request_id: String::new(), name: sandbox_name.to_string(), include_security_flagged: true, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13477,6 +13504,7 @@ mod tests { handle_edit_draft_chunk( &state, with_user(Request::new(EditDraftChunkRequest { + request_id: String::new(), name: sandbox_name.to_string(), chunk_id: chunk_id.clone(), proposed_rule: Some(private_rule), @@ -13770,6 +13798,7 @@ mod tests { handle_edit_draft_chunk( &state, with_user(Request::new(EditDraftChunkRequest { + request_id: String::new(), name: sandbox_name.to_string(), chunk_id: chunk_id.clone(), proposed_rule: Some(finding_rule), @@ -13927,6 +13956,7 @@ mod tests { let approve = handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13979,6 +14009,7 @@ mod tests { let undo = handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -14045,6 +14076,7 @@ mod tests { let cleared = handle_clear_draft_chunks( &state, authed_request(ClearDraftChunksRequest { + request_id: String::new(), name: sandbox_name.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -14150,6 +14182,7 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: guidance.to_string(), @@ -14949,6 +14982,7 @@ mod tests { let error = handle_approve_draft_chunk( &state, with_user(Request::new(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name, chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -16356,6 +16390,7 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name, chunk_id, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -16719,6 +16754,7 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: sandbox_name, chunk_id: second.accepted_chunk_ids[0].clone(), reason: "redraft test".to_string(), @@ -17188,6 +17224,7 @@ mod tests { handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), reason: "scope too broad".to_string(), @@ -17202,6 +17239,7 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17216,6 +17254,7 @@ mod tests { handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { + request_id: String::new(), name: sandbox_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17359,6 +17398,7 @@ mod tests { let approve_err = handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: other_name.clone(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17374,6 +17414,7 @@ mod tests { let reject_err = handle_reject_draft_chunk( &state, authed_request(RejectDraftChunkRequest { + request_id: String::new(), name: other_name.clone(), chunk_id: chunk_id.clone(), reason: "wrong sandbox".to_string(), @@ -17389,6 +17430,7 @@ mod tests { let edit_err = handle_edit_draft_chunk( &state, authed_request(EditDraftChunkRequest { + request_id: String::new(), name: other_name.clone(), chunk_id: chunk_id.clone(), proposed_rule: Some(proposed_rule.clone()), @@ -17404,6 +17446,7 @@ mod tests { handle_approve_draft_chunk( &state, authed_request(ApproveDraftChunkRequest { + request_id: String::new(), name: sandbox_a.object_name().to_string(), chunk_id: chunk_id.clone(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -17418,6 +17461,7 @@ mod tests { let undo_err = handle_undo_draft_chunk( &state, authed_request(UndoDraftChunkRequest { + request_id: String::new(), name: other_name, chunk_id, workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -19510,6 +19554,7 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), @@ -19608,6 +19653,7 @@ mod tests { let response = handle_update_config( &state, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "annotated-backfill".to_string(), policy: Some(ProtoSandboxPolicy::default()), setting_key: String::new(), @@ -20640,6 +20686,7 @@ mod tests { let err = handle_update_config( &state, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), @@ -20741,6 +20788,7 @@ mod tests { handle_update_config( &state_clone, authed_request(UpdateConfigRequest { + request_id: String::new(), name: "test-sandbox".to_string(), policy: Some(new_policy), setting_key: String::new(), diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 5cfc8c0e6c..58713dff6d 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -47,7 +47,7 @@ const GATEWAY_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_GATEWAY_SPIFFE_WORKL /// response. Key names are preserved so callers can display credential counts /// and key listings. Internal server paths (sandbox env /// injection) read credentials from the store directly and are unaffected. -fn redact_provider_credentials(mut provider: Provider) -> Provider { +pub(super) fn redact_provider_credentials(mut provider: Provider) -> Provider { for value in provider.credentials.values_mut() { *value = "REDACTED".to_string(); } @@ -2684,6 +2684,7 @@ pub(super) async fn handle_import_provider_profiles( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let workspace = authorize_and_resolve_profile_workspace( state, @@ -2753,6 +2754,7 @@ pub(super) async fn handle_import_provider_profiles( if let Some(metadata) = stored.metadata.as_mut() { metadata.resource_version = result.resource_version; } + replay_facts.resource(&stored)?; let resource_version = stored_profile_resource_version(&stored); imported.push(profile_response_payload( stored.profile.unwrap_or_default(), @@ -2772,6 +2774,7 @@ pub(super) async fn handle_update_provider_profiles( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let workspace = authorize_and_resolve_profile_workspace( state, @@ -2886,6 +2889,7 @@ pub(super) async fn handle_update_provider_profiles( if let Some(metadata) = stored.metadata.as_mut() { metadata.resource_version = result.resource_version; } + replay_facts.resource(&stored)?; let resource_version = stored_profile_resource_version(&stored); let profile = profile_response_payload(stored.profile.unwrap_or_default(), resource_version); @@ -4299,6 +4303,7 @@ pub(super) async fn handle_configure_provider_refresh( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -4684,6 +4689,7 @@ pub(super) async fn handle_configure_provider_refresh( .await?; } + replay_facts.refresh(&state_record)?; Ok(Response::new(ConfigureProviderRefreshResponse { status: Some(crate::provider_refresh::refresh_status_from_state( &state_record, @@ -4696,6 +4702,7 @@ pub(super) async fn handle_rotate_provider_credential( request: Request, ) -> Result, Status> { let principal = super::extract_principal(&request)?; + let replay_facts = super::mutation_replay::ordinary::Facts::from_request(&request); let request = request.into_inner(); let authz = authorize_workspace_selector( &state.store, @@ -4726,6 +4733,7 @@ pub(super) async fn handle_rotate_provider_credential( ) .await?; + replay_facts.refresh(&refresh_state)?; Ok(Response::new(RotateProviderCredentialResponse { status: Some(crate::provider_refresh::refresh_status_from_state( &refresh_state, @@ -5159,6 +5167,7 @@ mod tests { handle_import_provider_profiles( state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: format!("{id}.yaml"), @@ -5299,6 +5308,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "grant-new.yaml".to_string(), @@ -5327,6 +5337,7 @@ mod tests { handle_import_provider_profiles( &task_state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("guarded-import")), source: "guarded-import.yaml".to_string(), @@ -5380,6 +5391,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(updated_profile.clone()), source: "custom-api.yaml".to_string(), @@ -5425,6 +5437,7 @@ mod tests { let built_in = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), @@ -5447,6 +5460,7 @@ mod tests { let missing = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("missing-custom")), source: "missing-custom.yaml".to_string(), @@ -5479,6 +5493,7 @@ mod tests { let missing_version = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -5502,6 +5517,7 @@ mod tests { let stale_error = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(stale_profile), source: "custom-api.yaml".to_string(), @@ -5551,6 +5567,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(edited_payload), source: "profile-a.yaml".to_string(), @@ -5635,6 +5652,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(profile), source: "grant-updated.yaml".to_string(), @@ -5813,6 +5831,7 @@ mod tests { handle_import_provider_profiles( state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: format!("{id}.yaml"), @@ -6038,6 +6057,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -6098,6 +6118,7 @@ mod tests { let imported = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(initial_profile), source: "fanout.yaml".to_string(), @@ -6166,6 +6187,7 @@ mod tests { let response = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), profile: Some(ProviderProfileImportItem { profile: Some(conflicting_profile), source: "fanout.yaml".to_string(), @@ -6206,6 +6228,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("github")), source: "github.yaml".to_string(), @@ -6234,6 +6257,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-llm")), source: "custom-llm.yaml".to_string(), @@ -6269,6 +6293,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile(" alex-api ")), @@ -6307,6 +6332,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("alex-api")), source: "alex-api.yaml".to_string(), @@ -6334,6 +6360,7 @@ mod tests { let deleted = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: " Alex-API ".to_string(), workspace: "default".to_string(), @@ -6355,6 +6382,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ ProviderProfileImportItem { profile: Some(custom_profile("bulk-one")), @@ -6404,6 +6432,7 @@ mod tests { let response = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ProviderProfile { id: "advanced-api".to_string(), @@ -6531,6 +6560,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-lint")), source: "scoped-lint.yaml".to_string(), @@ -6604,6 +6634,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -6617,6 +6648,7 @@ mod tests { let builtin_err = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "github".to_string(), workspace: "default".to_string(), @@ -6658,6 +6690,7 @@ mod tests { let in_use_err = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "custom-api".to_string(), workspace: "default".to_string(), @@ -6671,6 +6704,7 @@ mod tests { let attached = super::super::sandbox::handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "sandbox-custom".to_string(), provider_name: "custom-provider".to_string(), expected_resource_version: 0, @@ -6710,6 +6744,7 @@ mod tests { let err = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "global-custom".to_string(), workspace: String::new(), @@ -6759,6 +6794,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -6848,6 +6884,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -6883,6 +6920,7 @@ mod tests { let deleted = handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), @@ -6949,6 +6987,7 @@ mod tests { .await .unwrap(); let request = |client_secret: &str| ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "configure-conflict".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7071,6 +7110,7 @@ mod tests { first_state.credentials.clone(), )); let request = |client_secret: &str| ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "configure-create-race".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7202,6 +7242,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7283,6 +7324,7 @@ mod tests { handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "provider-a".to_string(), credential_key: "REFRESH_TOKEN".to_string(), @@ -7342,6 +7384,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "vertex-sa".to_string(), credential_key: "GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, @@ -7415,6 +7458,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7465,6 +7509,7 @@ mod tests { let deleted = handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), @@ -7527,6 +7572,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -7579,6 +7625,7 @@ mod tests { handle_delete_provider_refresh( &state, authed_request(DeleteProviderRefreshRequest { + request_id: String::new(), allow_missing: false, provider: "aws-delete".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), @@ -7752,6 +7799,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "refreshing-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7834,6 +7882,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "first-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7855,6 +7904,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "second-graph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7916,6 +7966,7 @@ mod tests { let endpoint_override = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7943,6 +7994,7 @@ mod tests { let missing_material = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -7999,6 +8051,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "msgraph".to_string(), credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: strategy as i32, @@ -8032,6 +8085,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("custom-api")), source: "custom-api.yaml".to_string(), @@ -8045,6 +8099,7 @@ mod tests { let deleted = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "custom-api".to_string(), workspace: "default".to_string(), @@ -8085,6 +8140,7 @@ mod tests { handle_delete_provider_profile( &task_state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "guarded-delete".to_string(), workspace: "default".to_string(), @@ -8118,6 +8174,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("guarded-create")), source: "guarded-create.yaml".to_string(), @@ -8137,6 +8194,7 @@ mod tests { handle_create_provider( &task_state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8569,6 +8627,7 @@ mod tests { let err = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_handle( "openai-ref", "openai", @@ -8592,6 +8651,7 @@ mod tests { let err = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_values("legacy-gitlab", "gitlab")), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -8614,6 +8674,7 @@ mod tests { let response = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "pypi".to_string(), @@ -8645,6 +8706,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("gitlab")), source: "custom-gitlab.yaml".to_string(), @@ -8658,6 +8720,7 @@ mod tests { let response = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "private-gitlab".to_string(), @@ -8705,6 +8768,7 @@ mod tests { let imported = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "enterprise-github.yaml".to_string(), @@ -8720,6 +8784,7 @@ mod tests { let provider = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "enterprise-github", "gh", @@ -8768,6 +8833,7 @@ mod tests { let response = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "openai-local", "openai", @@ -8870,6 +8936,7 @@ mod tests { let imported = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "provider-profile.yaml".to_string(), @@ -8885,6 +8952,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "exchange", "spiffe-token-exchange-demo", @@ -8923,6 +8991,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_handle( "openai-local", "openai", @@ -9180,6 +9249,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ProviderProfile { id: "delegated-refresh-api".to_string(), @@ -9270,6 +9340,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(mixed_required_profile), source: "mixed-required-api.yaml".to_string(), @@ -9313,6 +9384,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(optional_static_profile), source: "optional-static-api.yaml".to_string(), @@ -9857,6 +9929,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(profile), source: "stable-refresh-provider.yaml".to_string(), @@ -9884,6 +9957,7 @@ mod tests { .await .unwrap(); let configure = || ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "stable-refresh".to_string(), credential_key: "ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, @@ -9948,6 +10022,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "stable-refresh".to_string(), @@ -11770,6 +11845,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "profile-backed-openai", "openai", @@ -11800,6 +11876,7 @@ mod tests { let error = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(update), credential_expires_at_ms: HashMap::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -11820,6 +11897,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "required-openai", "openai", @@ -11849,6 +11927,7 @@ mod tests { let error = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(update), credential_expires_at_ms: HashMap::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -11874,6 +11953,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -11904,6 +11984,7 @@ mod tests { let response = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(updated_provider.clone()), credential_expires_at_ms: HashMap::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -11944,6 +12025,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -11974,6 +12056,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -12021,6 +12104,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider_with_credential_value( "openai-local", "openai", @@ -12051,6 +12135,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(stale_provider), credential_expires_at_ms: HashMap::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -12094,6 +12179,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some(provider.clone()), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), @@ -12127,6 +12213,7 @@ mod tests { handle_update_provider( &state_clone, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(updated), credential_expires_at_ms: HashMap::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -12216,6 +12303,7 @@ mod tests { let response = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "my-aws-v2".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12274,6 +12362,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-endpoint-override".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12353,6 +12442,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-partial-source".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12408,6 +12498,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-lone-session".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12466,6 +12557,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-outputs".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12546,6 +12638,7 @@ mod tests { handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-update-guard".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12572,6 +12665,7 @@ mod tests { let err = handle_update_provider( &state, authed_request(UpdateProviderRequest { + request_id: String::new(), provider: Some(Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { name: "aws-update-guard".to_string(), @@ -12640,6 +12734,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "generic-no-profile".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12694,6 +12789,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "aws-wrong-key".to_string(), credential_key: "AWS_SECRET_ACCESS_KEY".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12874,6 +12970,7 @@ mod tests { let err = handle_configure_provider_refresh( &state, authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: "new-aws-provider".to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -12951,6 +13048,7 @@ mod tests { let configure = |provider: &str| { authed_request(ConfigureProviderRefreshRequest { + request_id: String::new(), provider: provider.to_string(), credential_key: "AWS_ACCESS_KEY_ID".to_string(), strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, @@ -13157,6 +13255,7 @@ mod tests { let created_default = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -13189,6 +13288,7 @@ mod tests { let created_beta = handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -13286,6 +13386,7 @@ mod tests { let deleted = handle_delete_provider( &state, authed_request(DeleteProviderRequest { + request_id: String::new(), allow_missing: false, name: "shared-name".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -13335,6 +13436,7 @@ mod tests { handle_create_provider( &state, authed_request(CreateProviderRequest { + request_id: String::new(), provider: Some({ let mut p = make_provider(); p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -13407,6 +13509,7 @@ mod tests { let import_error = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), workspace: String::new(), profiles: Vec::new(), }), @@ -13423,6 +13526,7 @@ mod tests { let update_error = handle_update_provider_profiles( &state, authed_request(UpdateProviderProfilesRequest { + request_id: String::new(), id: "nonexistent".to_string(), workspace: String::new(), ..UpdateProviderProfilesRequest::default() @@ -13456,6 +13560,7 @@ mod tests { let delete_error = handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id: "nonexistent".to_string(), workspace: String::new(), @@ -13648,6 +13753,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("ws-custom")), source: "ws-custom.yaml".to_string(), @@ -13701,6 +13807,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile(&id)), source: format!("{id}.yaml"), @@ -13778,6 +13885,7 @@ mod tests { handle_delete_provider_profile( &state, authed_request(DeleteProviderProfileRequest { + request_id: String::new(), allow_missing: false, id, workspace, @@ -13811,6 +13919,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile(&id)), source: format!("{id}.yaml"), @@ -13848,6 +13957,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("scoped-api")), source: "scoped-api.yaml".to_string(), @@ -13893,6 +14003,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("platform-only")), source: "platform-only.yaml".to_string(), @@ -13930,6 +14041,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-target")), source: "shadow-target.yaml".to_string(), @@ -13945,6 +14057,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "shadow-target.yaml".to_string(), @@ -13978,6 +14091,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-warn")), source: "shadow-warn.yaml".to_string(), @@ -13991,6 +14105,7 @@ mod tests { let resp = handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("shadow-warn")), source: "shadow-warn.yaml".to_string(), @@ -14020,6 +14135,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("global-only")), source: "global-only.yaml".to_string(), @@ -14033,6 +14149,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(custom_profile("ws-only")), source: "ws-only.yaml".to_string(), @@ -14074,6 +14191,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(platform_profile), source: "scope-test.yaml".to_string(), @@ -14089,6 +14207,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "scope-test.yaml".to_string(), @@ -14123,6 +14242,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(platform_profile), source: "scope-test-ws.yaml".to_string(), @@ -14138,6 +14258,7 @@ mod tests { handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { + request_id: String::new(), profiles: vec![ProviderProfileImportItem { profile: Some(ws_profile), source: "scope-test-ws.yaml".to_string(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index cf9358b751..3229a132e1 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3157,6 +3157,7 @@ mod tests { #[test] fn sandbox_create_telemetry_uses_resolved_template_gpu_request() { let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], policy: Some(openshell_core::proto::SandboxPolicy::default()), @@ -3196,6 +3197,7 @@ mod tests { #[test] fn sandbox_create_telemetry_falls_back_to_request_for_unresolved_template() { let request = CreateSandboxRequest { + request_id: String::new(), spec: Some(SandboxSpec { providers: vec!["github".to_string()], ..SandboxSpec::default() @@ -3633,6 +3635,7 @@ mod tests { handle_delete_sandbox_inner( &delete_state, authed_request(DeleteSandboxRequest { + request_id: String::new(), allow_missing: false, name: "reused-name".to_string(), workspace_scope: Some(openshell_core::proto::workspace_selector( @@ -3696,6 +3699,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3737,6 +3741,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3775,6 +3780,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3826,6 +3832,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3851,6 +3858,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-github".to_string(), expected_resource_version: 0, @@ -3898,6 +3906,7 @@ mod tests { let error = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "work-gcp".to_string(), expected_resource_version: 0, @@ -3966,6 +3975,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "missing".to_string(), expected_resource_version: 0, @@ -4137,6 +4147,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "collision".to_string(), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -4165,6 +4176,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "user-catalog".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -4199,6 +4211,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "reserved-policy-key".to_string(), spec: Some(SandboxSpec { policy: Some(policy), @@ -4566,6 +4579,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "annotated".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -4624,6 +4638,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "partial-id".to_string(), spec: Some(SandboxSpec { policy: Some(policy), @@ -4689,6 +4704,7 @@ mod tests { let response = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "kube-partial-id".to_string(), spec: Some(SandboxSpec { policy: Some(policy), @@ -4724,6 +4740,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-label".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::from([("team".to_string(), "x".repeat(512))]), @@ -4755,6 +4772,7 @@ mod tests { handle_create_sandbox( &task_state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "guarded-create".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], @@ -5341,6 +5359,7 @@ mod tests { let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-template".to_string(), spec: Some(SandboxSpec { providers: vec!["work-github".to_string()], @@ -5434,6 +5453,7 @@ mod tests { let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-template".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5486,6 +5506,7 @@ mod tests { let created = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-template".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5525,6 +5546,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "from-corrupt".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5562,6 +5584,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-template-create".to_string(), spec: Some(SandboxSpec { environment: HashMap::from([("INLINE".to_string(), "blocked".to_string())]), @@ -5590,6 +5613,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-template-create".to_string(), spec: Some(SandboxSpec::default()), labels: HashMap::new(), @@ -5615,6 +5639,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-template-create".to_string(), spec: Some(SandboxSpec { providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), @@ -5643,6 +5668,7 @@ mod tests { let err = handle_create_sandbox( &state, authed_request(CreateSandboxRequest { + request_id: String::new(), name: "bad-direct-create".to_string(), spec: Some(SandboxSpec { providers: (0..=MAX_PROVIDERS).map(|i| format!("p-{i}")).collect(), @@ -5686,6 +5712,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "provider-b".to_string(), expected_resource_version: 0, @@ -5733,6 +5760,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "provider-31".to_string(), expected_resource_version: 0, @@ -5788,6 +5816,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "provider-32".to_string(), expected_resource_version: 0, @@ -5835,6 +5864,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, @@ -5862,6 +5892,7 @@ mod tests { let err = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: long_name, expected_resource_version: 0, @@ -6071,6 +6102,7 @@ mod tests { let response = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, @@ -6123,6 +6155,7 @@ mod tests { let err = handle_attach_sandbox_provider( &state, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, @@ -6186,6 +6219,7 @@ mod tests { let response = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: current_version, @@ -6238,6 +6272,7 @@ mod tests { let err = handle_detach_sandbox_provider( &state, authed_request(DetachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: "github".to_string(), expected_resource_version: 99, @@ -6319,6 +6354,7 @@ mod tests { handle_attach_sandbox_provider( &state_clone, authed_request(AttachSandboxProviderRequest { + request_id: String::new(), sandbox_name: "work".to_string(), provider_name: format!("provider-{i}"), expected_resource_version: initial_version, @@ -6700,6 +6736,7 @@ mod tests { let err = handle_delete_sandbox( &state, non_member_request(DeleteSandboxRequest { + request_id: String::new(), allow_missing: false, workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), @@ -6717,6 +6754,7 @@ mod tests { handle_stop_sandbox( &state, non_member_request(StopSandboxRequest { + request_id: String::new(), workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), }), @@ -6725,6 +6763,7 @@ mod tests { handle_start_sandbox( &state, non_member_request(StartSandboxRequest { + request_id: String::new(), workspace_scope: Some(openshell_core::proto::workspace_selector("no-such-ws")), name: "any".into(), }), diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 8fb93a9b20..d5ead9fa69 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -432,6 +432,7 @@ mod tests { let exposed = handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -484,6 +485,7 @@ mod tests { let deleted = handle_delete_service( &state, authed_request(DeleteServiceRequest { + request_id: String::new(), allow_missing: false, sandbox: "my-sandbox".to_string(), service: "web".to_string(), @@ -542,6 +544,7 @@ mod tests { handle_expose_service( &state1, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -559,6 +562,7 @@ mod tests { handle_expose_service( &state2, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -610,6 +614,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 7070, @@ -628,6 +633,7 @@ mod tests { handle_expose_service( &state1, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -645,6 +651,7 @@ mod tests { handle_expose_service( &state2, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -734,6 +741,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 8080, @@ -749,6 +757,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "web".to_string(), target_port: 9090, @@ -838,6 +847,7 @@ mod tests { let deleted = handle_delete_service( &state, authed_request(DeleteServiceRequest { + request_id: String::new(), allow_missing: false, sandbox: "my-sandbox".to_string(), service: "web".to_string(), @@ -890,6 +900,7 @@ mod tests { handle_expose_service( &state, authed_request(ExposeServiceRequest { + request_id: String::new(), sandbox: "my-sandbox".to_string(), service: "api".to_string(), target_port: 3000, diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index f03aa4d07f..2985b9d048 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -447,11 +447,14 @@ where let context = gateway_interceptor_context(req.extensions()); let principal = req.extensions().get::().cloned(); - let (parts, body) = req.into_parts(); + let (mut parts, body) = req.into_parts(); let mut body = match collect_intercepted_grpc_body(body).await { Ok(body) => body, Err(status) => return Ok(status.into_http()), }; + // Retain only in memory. evaluate_request below validates the single + // uncompressed frame before this extension reaches typed dispatch. + let original_body = body.clone(); if let Some(state) = state.as_ref() { body = match hydrate_update_provider_identity(&path, body, state, principal.as_ref()) @@ -467,6 +470,12 @@ where Err(status) => return Ok(status.into_http()), }; + parts + .extensions + .insert(crate::grpc::mutation_replay::OriginalMutation( + original_body[GRPC_FRAME_HEADER_LEN..].to_vec(), + )); + let req = Request::from_parts( parts, boxed_body_from_bytes(Bytes::from(intercepted.body.clone())), @@ -474,6 +483,10 @@ where let response = inner.ready().await?.call(req).await?; if grpc_status_from_response(&response) != "0" + || response + .headers() + .get("openshell-replayed") + .is_some_and(|value| value == "true") || !interceptors.has_post_commit(&intercepted) { return Ok(response); @@ -1846,6 +1859,196 @@ mod tests { interceptor_task.abort(); } + #[derive(Clone, Default)] + struct ReplayTestInterceptor { + modifications: Arc, + validations: Arc, + observations: Arc, + deny: Arc, + name: Arc>, + } + + #[tonic::async_trait] + impl GatewayInterceptor for ReplayTestInterceptor { + async fn describe( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let mut manifest = PostCommitTestInterceptor + .describe(request) + .await? + .into_inner(); + manifest.bindings.push(InterceptorBinding { + id: "validate-create".into(), + selector: manifest.bindings[0].selector.clone(), + phases: vec![ + GatewayInterceptorPhase::ModifyOperation.into(), + GatewayInterceptorPhase::Validate.into(), + ], + failure_policy: "fail_closed".into(), + }); + Ok(tonic::Response::new(manifest)) + } + + async fn evaluate( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + use openshell_core::proto::gateway_interceptor::v1::{ + JsonPatch, interceptor_evaluation::Phase, + }; + let mut result = InterceptorResult { + allowed: true, + ..Default::default() + }; + match request.into_inner().phase.unwrap() { + Phase::ModifyOperation(_) => { + self.modifications.fetch_add(1, Ordering::SeqCst); + result.patches.push(JsonPatch { + op: "replace".into(), + path: "/name".into(), + value: Some(prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue( + self.name.lock().unwrap().clone(), + )), + }), + ..Default::default() + }); + } + Phase::Validate(_) => { + self.validations.fetch_add(1, Ordering::SeqCst); + result.allowed = !self.deny.load(Ordering::SeqCst); + result.reason = "current policy denies creation".into(); + result.status_code = "PERMISSION_DENIED".into(); + } + Phase::PostCommit(_) => { + self.observations.fetch_add(1, Ordering::SeqCst); + } + } + Ok(tonic::Response::new(result)) + } + + async fn snapshot_provider_profiles( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + PostCommitTestInterceptor + .snapshot_provider_profiles(request) + .await + } + } + + #[tokio::test] + async fn mutation_replay_revalidates_interceptors_and_observes_commit_once() { + use openshell_core::proto::{ + SandboxResponse, SandboxSpec, open_shell_server::OpenShellServer, + }; + let interceptor = ReplayTestInterceptor::default(); + *interceptor.name.lock().unwrap() = "intercepted-create".into(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let service = interceptor.clone(); + let task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(GatewayInterceptorServer::new(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + let runtime = openshell_gateway_interceptors::initialize(vec![GatewayInterceptorConfig { + name: "post-commit-test".into(), + grpc_endpoint: format!("http://{address}"), + ..Default::default() + }]) + .await + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let key = directory.path().join("key"); + std::fs::write(&key, b"test-only-private-key").unwrap(); + let mut state = crate::grpc::test_support::test_server_state().await; + Arc::get_mut(&mut state).unwrap().config.gateway_jwt = + Some(openshell_core::config::GatewayJwtConfig { + signing_key_path: key, + public_key_path: directory.path().join("public"), + kid_path: directory.path().join("kid"), + gateway_id: "test".into(), + ttl_secs: None, + }); + let inner = OpenShellServer::new(OpenShellService::new(state.clone())); + let mut service = GatewayInterceptorGrpcService::new(inner, runtime, Some(state)); + let input = CreateSandboxRequest { + name: "client-original".into(), + request_id: uuid::Uuid::new_v4().to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + spec: Some(SandboxSpec::default()), + ..Default::default() + }; + let request = || { + let principal = crate::grpc::test_support::authed_request(()) + .extensions() + .get::() + .unwrap() + .clone(); + Request::builder() + .uri("/openshell.v1.OpenShell/CreateSandbox") + .header("content-type", "application/grpc") + .header("openshell-replayed", "true") + .extension(principal) + .body(boxed_body_from_bytes(grpc_frame(&input.encode_to_vec()))) + .unwrap() + }; + let first = service + .ready() + .await + .unwrap() + .call(request()) + .await + .unwrap(); + assert!(!first.headers().contains_key("openshell-replayed")); + let first = first.into_body().collect().await.unwrap(); + assert_eq!(first.trailers().unwrap().get("grpc-status").unwrap(), "0"); + let first = decode_unary_grpc_message::(&first.to_bytes()).unwrap(); + assert_eq!( + first + .sandbox + .as_ref() + .unwrap() + .metadata + .as_ref() + .unwrap() + .name, + "intercepted-create" + ); + let replay = service + .ready() + .await + .unwrap() + .call(request()) + .await + .unwrap(); + assert_eq!(replay.headers().get("openshell-replayed").unwrap(), "true"); + let replay = replay.into_body().collect().await.unwrap(); + assert_eq!( + decode_unary_grpc_message::(&replay.to_bytes()).unwrap(), + first + ); + assert_eq!(interceptor.modifications.load(Ordering::SeqCst), 2); + assert_eq!(interceptor.validations.load(Ordering::SeqCst), 2); + assert_eq!(interceptor.observations.load(Ordering::SeqCst), 1); + interceptor.deny.store(true, Ordering::SeqCst); + let denied = service + .ready() + .await + .unwrap() + .call(request()) + .await + .unwrap(); + assert_eq!(denied.headers().get("grpc-status").unwrap(), "7"); + assert_eq!(interceptor.validations.load(Ordering::SeqCst), 3); + assert_eq!(interceptor.observations.load(Ordering::SeqCst), 1); + task.abort(); + } + #[test] fn grpc_trailer_status_takes_precedence_over_headers() { let response = Response::builder() diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 1018180470..78b440e280 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -119,7 +119,7 @@ mod tests { const STORAGE_V1_SCHEMA_SHA256: &str = "79c72615d957fc0653c672f61998bf7d8d21b757bc05d07b3fff92bd70fc8f52"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "8fab4ae6475cc3b768db710c1fc4c0f2ed75000682749e303388ba908dda5b59"; + "5de04dd390599ebd165111df4f14c6dd05ae77aa748866a0e3ec1a22abd63133"; const DURABLE_SCHEMA_SHA256: &str = "369b36511c2e38b9df9621704a00123516c7538d8ee89a499158d7de5cee1882"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 9ea75e45d4..236d610758 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -815,6 +815,7 @@ async fn handle_sandbox_delete(app: &mut App, tx: mpsc::UnboundedSender) } let req = openshell_core::proto::DeleteSandboxRequest { + request_id: String::new(), allow_missing: true, name: sandbox_name, workspace_scope: Some(named_workspace_scope(app.selected_sandbox_workspace())), @@ -1474,6 +1475,7 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { }; let req = openshell_core::proto::CreateSandboxRequest { + request_id: String::new(), name, spec: Some(openshell_core::proto::SandboxSpec { providers: selected_providers, @@ -1748,6 +1750,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }; let req = openshell_core::proto::CreateProviderRequest { + request_id: String::new(), provider: Some(openshell_core::proto::Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -1865,6 +1868,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { } let req = openshell_core::proto::UpdateProviderRequest { + request_id: String::new(), provider: Some(openshell_core::proto::Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -1914,6 +1918,7 @@ fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::DeleteProviderRequest { + request_id: String::new(), allow_missing: true, name, workspace_scope: Some(named_workspace_scope(workspace)), @@ -1966,6 +1971,7 @@ fn spawn_draft_approve(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::ApproveDraftChunkRequest { + request_id: String::new(), name, chunk_id, workspace_scope: Some(named_workspace_scope(workspace)), @@ -2011,6 +2017,7 @@ fn spawn_draft_reject(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let req = openshell_core::proto::RejectDraftChunkRequest { + request_id: String::new(), name, chunk_id, reason: String::new(), @@ -2061,6 +2068,7 @@ fn spawn_draft_approve_all( }) .collect(); let req = openshell_core::proto::ApproveAllDraftChunksRequest { + request_id: String::new(), name, include_security_flagged: false, workspace_scope: Some(named_workspace_scope(workspace)), diff --git a/docs/reference/api-errors.mdx b/docs/reference/api-errors.mdx index 947c198610..464dc966d1 100644 --- a/docs/reference/api-errors.mdx +++ b/docs/reference/api-errors.mdx @@ -33,7 +33,7 @@ Recognized gateway reasons include the following. | `PROFILE_SOURCE_UNAVAILABLE` | `UNAVAILABLE` | Retry a profile snapshot read after at least the supplied delay. | | `REQUEST_ID_PAYLOAD_MISMATCH` | `FAILED_PRECONDITION` | Keep the original payload for that request ID. Inspect the original operation before submitting a different one. | | `REQUEST_OUTCOME_UNCERTAIN` | `FAILED_PRECONDITION` | An attempt is admitted but has no confirmed replayable success. Observe resource state and reconcile effects. Do not switch to a new ID to bypass the admission. | -| `REQUEST_REPLAY_UNAVAILABLE` | `FAILED_PRECONDITION` | The original workspace or created resource no longer exists at its recorded version. The gateway does not execute the request again. | +| `REQUEST_REPLAY_UNAVAILABLE` | `FAILED_PRECONDITION` | The original scope, resource, interceptor transformation, or fingerprint key is no longer replayable. Reconcile effects; the gateway does not execute the request again. Missing private-key material can also reject admission before work starts. | ## Status and retry guidance @@ -85,13 +85,18 @@ No SDK automatically retries a mutation as a result of decoding these details. ## Durable request admission -Six unary RPCs accept an optional `request_id` in their protobuf requests. +Thirty user-callable unary RPCs accept an optional `request_id` in their protobuf requests. | Resource | RPCs | |---|---| | Workspace | `CreateWorkspace`, `DeleteWorkspace` | | Workspace member | `AddWorkspaceMember`, `RemoveWorkspaceMember` | | Sandbox template | `CreateSandboxTemplate`, `DeleteSandboxTemplate` | +| Sandbox | `CreateSandbox`, `DeleteSandbox`, `StartSandbox`, `StopSandbox`, `AttachSandboxProvider`, `DetachSandboxProvider` | +| Service | `ExposeService`, `DeleteService` | +| Provider | `CreateProvider`, `UpdateProvider`, `DeleteProvider`, `ConfigureProviderRefresh`, `DeleteProviderRefresh`, `RotateProviderCredential` | +| Provider profile | `ImportProviderProfiles`, `UpdateProviderProfiles`, `DeleteProviderProfile` | +| Policy and config | `UpdateConfig`, `ApproveDraftChunk`, `RejectDraftChunk`, `EditDraftChunk`, `UndoDraftChunk`, `ApproveAllDraftChunks`, `ClearDraftChunks` | Use a nonzero, hyphenated UUID of exactly 36 characters. Uppercase and lowercase forms identify the same request. An empty ID preserves the existing behavior @@ -116,14 +121,44 @@ execution. Do not retry an old operation after that window without reconciling its effects. Unresolved admissions never expire. Every replay checks current authorization, including membership and permission -to assign workspace administrators. Create replays load the original resource -by its UUID and require its recorded resource version. Deletion or modification -of that resource makes replay unavailable, even within 24 hours. A replacement -workspace also makes workspace-scoped replay unavailable. Delete replays return -the recorded outcome without deleting a same-name replacement. - -Replay rows contain fingerprints, resource references, and deletion outcomes, -not copies of template contents or credential-bearing responses. Each caller can +to assign workspace administrators. A replacement workspace makes workspace-scoped +replay unavailable. Target readiness, conditional-write versions, and draft review +tokens are first-execution preconditions, not conditions to execute again. + +| Result family | Replay behavior | +|---|---| +| Workspace, membership, template, provider, and profile resources | Load the original UUID at its recorded version. Missing or changed resources make replay unavailable. Provider credentials remain redacted. | +| Sandbox resources | Load the original UUID with its current state, including newer status or configuration. Attach/detach flags describe the original operation. A missing original sandbox makes replay unavailable. | +| Service endpoint | Require the original endpoint version and sandbox identity. Return the recorded URL. | +| Refresh status | Return current status for the original provider, refresh record, and authorization epoch. Reconfiguration or removal makes replay unavailable. Rotation is not repeated. | +| Policy and config | Return the original versions, counts, and nonsecret annotations while the original sandbox exists. Global config has no sandbox guard. | +| Deletion | Return the original outcome without requiring the deleted target or parent to exist and without deleting a replacement. `ACCEPTED` remains an acknowledgment of the original cleanup, not proof of completion. | + +Profile imports and updates also preserve their original public diagnostics and +success flags. Profile declarations, diagnostic source labels, and annotations +must not contain secrets. + +For sandbox, service, provider, profile, and policy/config methods, fingerprints +use HMAC with domain-separated material derived from the configured gateway JWT +private key, or the primary TLS private key when JWT signing is not configured. +These methods require readable, stable private-key material when you supply an +ID. Replicas sharing a database must share that material. Changing it makes +existing protected receipts unavailable until their successful replay window +expires; unresolved admissions remain protected indefinitely. Workspace, +membership, and template methods retain their original fingerprint format. + +Gateway interceptors run their current modification and validation phases on +every attempt, including replay. They cannot add, remove, or change `request_id`. +The gateway fingerprints the original client payload and separately guards the +effective transformed payload and workspace. A changed transformation makes +replay unavailable. A current denial remains authoritative. Successful replay +does not invoke post-commit observers again. Post-commit observation remains +best-effort; this is not a durable delivery queue. + +Replay rows contain fingerprints, resource references, and explicitly selected +public diagnostic or scalar outcomes, not credential-bearing response snapshots. +Completed receipts are limited to 64 KiB. A failure to capture a bounded receipt +after execution leaves the admission unresolved. Each caller can hold at most 1,000 durable admissions. At capacity, the gateway removes expired successes; if no space remains, it rejects new admissions with `RESOURCE_EXHAUSTED`. Existing records remain protected. Unresolved records @@ -132,9 +167,10 @@ The gateway also limits detached admission workers to 64 per process and these request messages to 4 MiB. Supply IDs through generated/raw RPC clients. Curated SDK request-ID helpers and -automatic retry policies are not part of this contract yet. Other mutations, -including sandbox creation, provider writes, exec, SSH sessions, and rootfs -staging, do not gain deduplication from this feature. Check gateway compatibility +automatic retry policies are not part of this contract yet. Exec, SSH sessions, +rootfs staging, and supervisor-authenticated mutations do not gain deduplication +from this feature. Sandbox principals cannot opt into `UpdateConfig` admission. +Check gateway compatibility before relying on IDs; older protobuf servers can silently ignore unknown fields. ## Deletion outcomes diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 5407bdf69e..9afd6866f6 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -3,10 +3,12 @@ from __future__ import annotations +import contextlib import threading +import uuid from typing import TYPE_CHECKING -from openshell._proto import openshell_pb2 +from openshell._proto import datamodel_pb2, openshell_pb2, sandbox_pb2 if TYPE_CHECKING: from collections.abc import Callable @@ -14,6 +16,71 @@ from openshell import Sandbox, SandboxClient, WorkspaceClient +def test_mutation_replay_preserves_sandbox_lifecycle_and_replacement( + sandbox_client: SandboxClient, +) -> None: + name = f"replay-{uuid.uuid4().hex[:8]}" + scope = datamodel_pb2.WorkspaceSelector(workspace="default") + stub = sandbox_client._stub + create = openshell_pb2.CreateSandboxRequest( + name=name, + spec=openshell_pb2.SandboxSpec(), + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + + def replay(method, request): + result, call = method.with_call(request, timeout=60) + assert dict(call.initial_metadata())["openshell-replayed"] == "true" + return result + + try: + original = stub.CreateSandbox(create, timeout=60).sandbox.metadata.id + sandbox_client.wait_ready(name, workspace="default", timeout_seconds=300) + assert replay(stub.CreateSandbox, create).sandbox.metadata.id == original + stop = openshell_pb2.StopSandboxRequest( + name=name, + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + stub.StopSandbox(stop, timeout=60) + sandbox_client.wait_stopped(name, workspace="default", timeout_seconds=120) + assert replay(stub.StopSandbox, stop).sandbox.metadata.id == original + start = openshell_pb2.StartSandboxRequest( + name=name, + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + stub.StartSandbox(start, timeout=60) + sandbox_client.wait_ready(name, workspace="default", timeout_seconds=300) + assert replay(stub.StartSandbox, start).sandbox.metadata.id == original + update = openshell_pb2.UpdateConfigRequest( + name=name, + workspace_scope=scope, + setting_key="ocsf_json_enabled", + setting_value=sandbox_pb2.SettingValue(bool_value=True), + request_id=str(uuid.uuid4()), + ) + updated = stub.UpdateConfig(update, timeout=30) + assert replay(stub.UpdateConfig, update) == updated + delete = openshell_pb2.DeleteSandboxRequest( + name=name, + workspace_scope=scope, + request_id=str(uuid.uuid4()), + ) + deleted = stub.DeleteSandbox(delete, timeout=60) + assert deleted.sandbox_id == original + sandbox_client.wait_deleted(name, workspace="default", timeout_seconds=120) + replacement = sandbox_client.create(workspace="default", name=name) + assert replacement.id != original + assert replay(stub.DeleteSandbox, delete) == deleted + assert sandbox_client.get(name, workspace="default").id == replacement.id + finally: + with contextlib.suppress(Exception): + sandbox_client.delete(name, workspace="default", allow_missing=True) + sandbox_client.wait_deleted(name, workspace="default", timeout_seconds=120) + + def test_sandbox_api_crud_and_exec( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, @@ -132,7 +199,7 @@ def requests(): def test_list_scoped_and_for_all_workspaces( sandbox_client: SandboxClient, - workspace_client: "WorkspaceClient", + workspace_client: WorkspaceClient, ) -> None: import contextlib import uuid @@ -150,9 +217,7 @@ def test_list_scoped_and_for_all_workspaces( ) created_default.append(ref_default.name) - ref_other = sandbox_client.create( - workspace=other_ws, name=f"ls-oth-{suffix}" - ) + ref_other = sandbox_client.create(workspace=other_ws, name=f"ls-oth-{suffix}") created_other.append(ref_other.name) default_ids = set(sandbox_client.list_ids(workspace="default")) @@ -206,15 +271,29 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: # Labels round-trip through create and get. assert ref_a.labels["role"] == "primary" - assert dict(sandbox_client.get(job_a, workspace="default").labels)["role"] == "primary" - assert dict(sandbox_client.get(job_b, workspace="default").labels)["role"] == "secondary" + assert ( + dict(sandbox_client.get(job_a, workspace="default").labels)["role"] + == "primary" + ) + assert ( + dict(sandbox_client.get(job_b, workspace="default").labels)["role"] + == "secondary" + ) # A specific selector filters to exactly the primary sandbox. assert { - s.name for s in sandbox_client.list_all(workspace="default", label_selector=primary_selector) + s.name + for s in sandbox_client.list_all( + workspace="default", label_selector=primary_selector + ) } == {job_a} # The shared group label returns both. - assert {s.name for s in sandbox_client.list_all(workspace="default", label_selector=group_selector)} == { + assert { + s.name + for s in sandbox_client.list_all( + workspace="default", label_selector=group_selector + ) + } == { job_a, job_b, } @@ -223,15 +302,20 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: assert sandbox_client.delete(job_a, workspace="default") sandbox_client.wait_deleted(job_a, workspace="default") created.remove(job_a) - assert {s.name for s in sandbox_client.list_all(workspace="default", label_selector=group_selector)} == { - job_b - } + assert { + s.name + for s in sandbox_client.list_all( + workspace="default", label_selector=group_selector + ) + } == {job_b} # Final deletion leaves no matching sandboxes. assert sandbox_client.delete(job_b, workspace="default") sandbox_client.wait_deleted(job_b, workspace="default") created.remove(job_b) - assert not sandbox_client.list_all(workspace="default", label_selector=group_selector) + assert not sandbox_client.list_all( + workspace="default", label_selector=group_selector + ) finally: for name in created: with contextlib.suppress(Exception): diff --git a/proto/openshell.proto b/proto/openshell.proto index 50695c1d22..a903bd51d0 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1134,6 +1134,9 @@ message CreateSandboxRequest { string workload_template_name = 7; // Explicit workspace for the sandbox. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 9; } message CreateSandboxTemplateRequest { @@ -1279,6 +1282,9 @@ message AttachSandboxProviderRequest { uint64 expected_resource_version = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } // Detach provider from sandbox request. @@ -1296,6 +1302,9 @@ message DetachSandboxProviderRequest { uint64 expected_resource_version = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } // Delete sandbox request. @@ -1309,6 +1318,9 @@ message DeleteSandboxRequest { // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. bool allow_missing = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Stop sandbox request. @@ -1319,6 +1331,9 @@ message StopSandboxRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Start sandbox request. @@ -1329,6 +1344,9 @@ message StartSandboxRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Sandbox response. @@ -1426,6 +1444,9 @@ message ExposeServiceRequest { bool domain = 4; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 6; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 7; } // Request to fetch an exposed sandbox service endpoint. @@ -1474,6 +1495,9 @@ message DeleteServiceRequest { // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; bool allow_missing = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } // Response for deleting an exposed sandbox service endpoint. @@ -1720,6 +1744,9 @@ message CreateProviderRequest { openshell.datamodel.v1.Provider provider = 1; // Explicit workspace for the provider. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Get provider request. @@ -1755,6 +1782,9 @@ message UpdateProviderRequest { map credential_expires_at_ms = 2; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Delete provider request. @@ -1765,6 +1795,9 @@ message DeleteProviderRequest { // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; bool allow_missing = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Provider response. @@ -2001,6 +2034,9 @@ message ConfigureProviderRefreshRequest { optional int64 expires_at_ms = 6; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 9; } message ConfigureProviderRefreshResponse { @@ -2014,6 +2050,9 @@ message RotateProviderCredentialRequest { string credential_key = 2; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } message RotateProviderCredentialResponse { @@ -2028,6 +2067,9 @@ message DeleteProviderRefreshRequest { // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; bool allow_missing = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message DeleteProviderRefreshResponse { @@ -2091,6 +2133,9 @@ message ImportProviderProfilesRequest { // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). // When empty, profiles are platform-scoped (Platform Admin). string workspace = 2; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 3; } // Import custom provider profiles response. @@ -2113,6 +2158,9 @@ message UpdateProviderProfilesRequest { // Workspace scope. When set, targets workspace-scoped profile. When empty, // targets platform-scoped profile. string workspace = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } // Update one custom provider profile response. @@ -2150,6 +2198,9 @@ message DeleteProviderProfileRequest { // targets platform-scoped profile. string workspace = 2; bool allow_missing = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } // Delete custom provider profile response. @@ -2281,6 +2332,9 @@ message UpdateConfigRequest { // Explicit workspace scope for sandbox-scoped updates. Omit only when // `global` is true; the all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 11; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 12; } message PolicyMergeOperation { @@ -2852,6 +2906,9 @@ message ApproveDraftChunkRequest { string review_token = 4; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message ApproveDraftChunkResponse { @@ -2873,6 +2930,9 @@ message RejectDraftChunkRequest { string reason = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message RejectDraftChunkResponse {} @@ -2895,6 +2955,9 @@ message ApproveAllDraftChunksRequest { repeated DraftChunkApproval approvals = 4; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message ApproveAllDraftChunksResponse { @@ -2921,6 +2984,9 @@ message EditDraftChunkRequest { openshell.sandbox.v1.NetworkPolicyRule proposed_rule = 3; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 5; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 6; } message EditDraftChunkResponse {} @@ -2935,6 +3001,9 @@ message UndoDraftChunkRequest { string chunk_id = 2; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 5; } message UndoDraftChunkResponse { @@ -2952,6 +3021,9 @@ message ClearDraftChunksRequest { string name = 1; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 3; + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + string request_id = 4; } message ClearDraftChunksResponse { diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index bcd32fd39a..967464f6c3 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -2548,8 +2548,11 @@ type CreateSandboxRequest struct { WorkloadTemplateName string `protobuf:"bytes,7,opt,name=workload_template_name,json=workloadTemplateName,proto3" json:"workload_template_name,omitempty"` // Explicit workspace for the sandbox. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,8,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,9,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSandboxRequest) Reset() { @@ -2631,6 +2634,13 @@ func (x *CreateSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelecto return nil } +func (x *CreateSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type CreateSandboxTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Template *SandboxWorkloadTemplate `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` @@ -3375,8 +3385,11 @@ type AttachSandboxProviderRequest struct { ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AttachSandboxProviderRequest) Reset() { @@ -3437,6 +3450,13 @@ func (x *AttachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *AttachSandboxProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Detach provider from sandbox request. type DetachSandboxProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3451,8 +3471,11 @@ type DetachSandboxProviderRequest struct { ExpectedResourceVersion uint64 `protobuf:"varint,3,opt,name=expected_resource_version,json=expectedResourceVersion,proto3" json:"expected_resource_version,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DetachSandboxProviderRequest) Reset() { @@ -3513,6 +3536,13 @@ func (x *DetachSandboxProviderRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *DetachSandboxProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete sandbox request. type DeleteSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3522,7 +3552,10 @@ type DeleteSandboxRequest struct { WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` // Succeed with ALREADY_ABSENT if the target is missing. Does not wait for // asynchronous cleanup and does not suppress authorization or parent errors. - AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3578,6 +3611,13 @@ func (x *DeleteSandboxRequest) GetAllowMissing() bool { return false } +func (x *DeleteSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Stop sandbox request. type StopSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3585,8 +3625,11 @@ type StopSandboxRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StopSandboxRequest) Reset() { @@ -3633,6 +3676,13 @@ func (x *StopSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } +func (x *StopSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Start sandbox request. type StartSandboxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3640,8 +3690,11 @@ type StartSandboxRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StartSandboxRequest) Reset() { @@ -3688,6 +3741,13 @@ func (x *StartSandboxRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } +func (x *StartSandboxRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Sandbox response. type SandboxResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4164,8 +4224,11 @@ type ExposeServiceRequest struct { Domain bool `protobuf:"varint,4,opt,name=domain,proto3" json:"domain,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,6,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,7,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExposeServiceRequest) Reset() { @@ -4233,6 +4296,13 @@ func (x *ExposeServiceRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelecto return nil } +func (x *ExposeServiceRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Request to fetch an exposed sandbox service endpoint. type GetServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4436,8 +4506,11 @@ type DeleteServiceRequest struct { // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` AllowMissing bool `protobuf:"varint,5,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteServiceRequest) Reset() { @@ -4498,6 +4571,13 @@ func (x *DeleteServiceRequest) GetAllowMissing() bool { return false } +func (x *DeleteServiceRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Response for deleting an exposed sandbox service endpoint. type DeleteServiceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5998,8 +6078,11 @@ type CreateProviderRequest struct { Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` // Explicit workspace for the provider. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateProviderRequest) Reset() { @@ -6046,6 +6129,13 @@ func (x *CreateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *CreateProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Get provider request. type GetProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6175,8 +6265,11 @@ type UpdateProviderRequest struct { CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,2,rep,name=credential_expires_at_ms,json=credentialExpiresAtMs,proto3" json:"credential_expires_at_ms,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateProviderRequest) Reset() { @@ -6230,6 +6323,13 @@ func (x *UpdateProviderRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *UpdateProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete provider request. type DeleteProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6237,8 +6337,11 @@ type DeleteProviderRequest struct { // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` AllowMissing bool `protobuf:"varint,4,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteProviderRequest) Reset() { @@ -6292,6 +6395,13 @@ func (x *DeleteProviderRequest) GetAllowMissing() bool { return false } +func (x *DeleteProviderRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Provider response. type ProviderResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -7569,8 +7679,11 @@ type ConfigureProviderRefreshRequest struct { ExpiresAtMs *int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3,oneof" json:"expires_at_ms,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,8,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,9,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConfigureProviderRefreshRequest) Reset() { @@ -7652,6 +7765,13 @@ func (x *ConfigureProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.Works return nil } +func (x *ConfigureProviderRefreshRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ConfigureProviderRefreshResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` @@ -7702,8 +7822,11 @@ type RotateProviderCredentialRequest struct { CredentialKey string `protobuf:"bytes,2,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RotateProviderCredentialRequest) Reset() { @@ -7757,6 +7880,13 @@ func (x *RotateProviderCredentialRequest) GetWorkspaceScope() *datamodelv1.Works return nil } +func (x *RotateProviderCredentialRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type RotateProviderCredentialResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status *ProviderCredentialRefreshStatus `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` @@ -7808,8 +7938,11 @@ type DeleteProviderRefreshRequest struct { // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` AllowMissing bool `protobuf:"varint,5,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeleteProviderRefreshRequest) Reset() { @@ -7870,6 +8003,13 @@ func (x *DeleteProviderRefreshRequest) GetAllowMissing() bool { return false } +func (x *DeleteProviderRefreshRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type DeleteProviderRefreshResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Outcome DeletionOutcome `protobuf:"varint,2,opt,name=outcome,proto3,enum=openshell.v1.DeletionOutcome" json:"outcome,omitempty"` @@ -8168,7 +8308,10 @@ type ImportProviderProfilesRequest struct { Profiles []*ProviderProfileImportItem `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` // Workspace scope. When set, profiles are workspace-scoped (Workspace Admin). // When empty, profiles are platform-scoped (Platform Admin). - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,3,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8217,6 +8360,13 @@ func (x *ImportProviderProfilesRequest) GetWorkspace() string { return "" } +func (x *ImportProviderProfilesRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Import custom provider profiles response. type ImportProviderProfilesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -8291,7 +8441,10 @@ type UpdateProviderProfilesRequest struct { Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` // Workspace scope. When set, targets workspace-scoped profile. When empty, // targets platform-scoped profile. - Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` + Workspace string `protobuf:"bytes,4,opt,name=workspace,proto3" json:"workspace,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8354,6 +8507,13 @@ func (x *UpdateProviderProfilesRequest) GetWorkspace() string { return "" } +func (x *UpdateProviderProfilesRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Update one custom provider profile response. type UpdateProviderProfilesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -8574,8 +8734,11 @@ type DeleteProviderProfileRequest struct { Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Workspace scope. When set, targets workspace-scoped profile. When empty, // targets platform-scoped profile. - Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` - AllowMissing bool `protobuf:"varint,3,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + AllowMissing bool `protobuf:"varint,3,opt,name=allow_missing,json=allowMissing,proto3" json:"allow_missing,omitempty"` + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8631,6 +8794,13 @@ func (x *DeleteProviderProfileRequest) GetAllowMissing() bool { return false } +func (x *DeleteProviderProfileRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + // Delete custom provider profile response. type DeleteProviderProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -9135,8 +9305,11 @@ type UpdateConfigRequest struct { // Explicit workspace scope for sandbox-scoped updates. Omit only when // `global` is true; the all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,11,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,12,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateConfigRequest) Reset() { @@ -9239,6 +9412,13 @@ func (x *UpdateConfigRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector return nil } +func (x *UpdateConfigRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type PolicyMergeOperation struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Operation: @@ -12679,8 +12859,11 @@ type ApproveDraftChunkRequest struct { ReviewToken string `protobuf:"bytes,4,opt,name=review_token,json=reviewToken,proto3" json:"review_token,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ApproveDraftChunkRequest) Reset() { @@ -12741,6 +12924,13 @@ func (x *ApproveDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSel return nil } +func (x *ApproveDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ApproveDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // New policy version after merge. @@ -12806,8 +12996,11 @@ type RejectDraftChunkRequest struct { Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RejectDraftChunkRequest) Reset() { @@ -12868,6 +13061,13 @@ func (x *RejectDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSele return nil } +func (x *RejectDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type RejectDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -12968,8 +13168,11 @@ type ApproveAllDraftChunksRequest struct { Approvals []*DraftChunkApproval `protobuf:"bytes,4,rep,name=approvals,proto3" json:"approvals,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ApproveAllDraftChunksRequest) Reset() { @@ -13030,6 +13233,13 @@ func (x *ApproveAllDraftChunksRequest) GetWorkspaceScope() *datamodelv1.Workspac return nil } +func (x *ApproveAllDraftChunksRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ApproveAllDraftChunksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // New policy version after merge. @@ -13114,8 +13324,11 @@ type EditDraftChunkRequest struct { ProposedRule *sandboxv1.NetworkPolicyRule `protobuf:"bytes,3,opt,name=proposed_rule,json=proposedRule,proto3" json:"proposed_rule,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,5,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,6,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EditDraftChunkRequest) Reset() { @@ -13176,6 +13389,13 @@ func (x *EditDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *EditDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type EditDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -13221,8 +13441,11 @@ type UndoDraftChunkRequest struct { ChunkId string `protobuf:"bytes,2,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,4,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,5,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UndoDraftChunkRequest) Reset() { @@ -13276,6 +13499,13 @@ func (x *UndoDraftChunkRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelect return nil } +func (x *UndoDraftChunkRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type UndoDraftChunkResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // New policy version after removal. @@ -13337,8 +13567,11 @@ type ClearDraftChunksRequest struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Explicit workspace scope. The all-workspaces selection is invalid. WorkspaceScope *datamodelv1.WorkspaceSelector `protobuf:"bytes,3,opt,name=workspace_scope,json=workspaceScope,proto3" json:"workspace_scope,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional nonzero UUID for durable at-most-once admission. Successful results + // can be replayed for 24 hours; see the API errors and retries reference. + RequestId string `protobuf:"bytes,4,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ClearDraftChunksRequest) Reset() { @@ -13385,6 +13618,13 @@ func (x *ClearDraftChunksRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSele return nil } +func (x *ClearDraftChunksRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + type ClearDraftChunksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Number of chunks cleared. @@ -14668,7 +14908,7 @@ const file_openshell_proto_rawDesc = "" + "\bmetadata\x18\x06 \x03(\v2).openshell.v1.PlatformEvent.MetadataEntryR\bmetadata\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xd1\x04\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf0\x04\n" + "\x14CreateSandboxRequest\x12-\n" + "\x04spec\x18\x01 \x01(\v2\x19.openshell.v1.SandboxSpecR\x04spec\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12F\n" + @@ -14676,7 +14916,9 @@ const file_openshell_proto_rawDesc = "" + "\vannotations\x18\x04 \x03(\v23.openshell.v1.CreateSandboxRequest.AnnotationsEntryR\vannotations\x12A\n" + "\x1dawait_main_process_attachment\x18\x06 \x01(\bR\x1aawaitMainProcessAttachment\x124\n" + "\x16workload_template_name\x18\a \x01(\tR\x14workloadTemplateName\x12R\n" + - "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a9\n" + + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\t \x01(\tR\trequestId\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + @@ -14732,27 +14974,37 @@ const file_openshell_proto_rawDesc = "" + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\tworkspaceR\x0eall_workspaces\"\xa5\x01\n" + "\x1bListSandboxProvidersRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x87\x02\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xa6\x02\n" + "\x1cAttachSandboxProviderRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x87\x02\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\xa6\x02\n" + "\x1cDetachSandboxProviderRequest\x12!\n" + "\fsandbox_name\x18\x01 \x01(\tR\vsandboxName\x12#\n" + "\rprovider_name\x18\x02 \x01(\tR\fproviderName\x12:\n" + "\x19expected_resource_version\x18\x03 \x01(\x04R\x17expectedResourceVersion\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\xb4\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\xd3\x01\n" + "\x14DeleteSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissingJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\xac\x01\n" + "\x12StopSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8e\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\xad\x01\n" + "\x13StartSandboxRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"B\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"B\n" + "\x0fSandboxResponse\x12/\n" + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxR\asandbox\"t\n" + "\x15ListSandboxesResponse\x123\n" + @@ -14781,14 +15033,16 @@ const file_openshell_proto_rawDesc = "" + "\fgateway_port\x18\x04 \x01(\rR\vgatewayPort\x12%\n" + "\x0egateway_scheme\x18\x05 \x01(\tR\rgatewayScheme\x120\n" + "\x14host_key_fingerprint\x18\a \x01(\tR\x12hostKeyFingerprint\x12\"\n" + - "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\"\xe8\x01\n" + + "\rexpires_at_ms\x18\b \x01(\x03R\vexpiresAtMs\"\x87\x02\n" + "\x14ExposeServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12\x1f\n" + "\vtarget_port\x18\x03 \x01(\rR\n" + "targetPort\x12\x16\n" + "\x06domain\x18\x04 \x01(\bR\x06domain\x12R\n" + - "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x05\x10\x06R\tworkspace\"\xac\x01\n" + + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\a \x01(\tR\trequestIdJ\x04\b\x05\x10\x06R\tworkspace\"\xac\x01\n" + "\x11GetServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12R\n" + @@ -14801,12 +15055,14 @@ const file_openshell_proto_rawDesc = "" + "\x0fworkspace_scope\x18\x06 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\tworkspaceR\x0eall_workspaces\"\x81\x01\n" + "\x14ListServicesResponse\x12A\n" + "\bservices\x18\x01 \x03(\v2%.openshell.v1.ServiceEndpointResponseR\bservices\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xd4\x01\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xf3\x01\n" + "\x14DeleteServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x05 \x01(\bR\fallowMissingJ\x04\b\x03\x10\x04R\tworkspace\"_\n" + + "\rallow_missing\x18\x05 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"_\n" + "\x15DeleteServiceResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xef\x01\n" + "\x0fServiceEndpoint\x12>\n" + @@ -14918,10 +15174,12 @@ const file_openshell_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"0\n" + "\x14SandboxStreamWarning\x12\x18\n" + - "\amessage\x18\x01 \x01(\tR\amessage\"\xba\x01\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"\xd9\x01\n" + "\x15CreateProviderRequest\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"\x8d\x01\n" + "\x12GetProviderRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xcd\x01\n" + @@ -14929,18 +15187,22 @@ const file_openshell_proto_rawDesc = "" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\xfd\x02\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\tworkspaceR\x0eall_workspaces\"\x9c\x03\n" + "\x15UpdateProviderRequest\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12w\n" + "\x18credential_expires_at_ms\x18\x02 \x03(\v2>.openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1aH\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestId\x1aH\n" + "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01J\x04\b\x03\x10\x04R\tworkspace\"\xb5\x01\n" + + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01J\x04\b\x03\x10\x04R\tworkspace\"\xd4\x01\n" + "\x15DeleteProviderRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x04 \x01(\bR\fallowMissingJ\x04\b\x02\x10\x03R\tworkspace\"P\n" + + "\rallow_missing\x18\x04 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"P\n" + "\x10ProviderResponse\x12<\n" + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\"\x7f\n" + "\x15ListProvidersResponse\x12>\n" + @@ -15047,7 +15309,7 @@ const file_openshell_proto_rawDesc = "" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"s\n" + " GetProviderRefreshStatusResponse\x12O\n" + - "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\x9f\x04\n" + + "\vcredentials\x18\x01 \x03(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\vcredentials\"\xbe\x04\n" + "\x1fConfigureProviderRefreshRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12K\n" + @@ -15055,24 +15317,30 @@ const file_openshell_proto_rawDesc = "" + "\bmaterial\x18\x04 \x03(\v2;.openshell.v1.ConfigureProviderRefreshRequest.MaterialEntryB\x04\x88\xb5\x18\x01R\bmaterial\x120\n" + "\x14secret_material_keys\x18\x05 \x03(\tR\x12secretMaterialKeys\x12'\n" + "\rexpires_at_ms\x18\x06 \x01(\x03H\x00R\vexpiresAtMs\x88\x01\x01\x12R\n" + - "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a;\n" + + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\t \x01(\tR\trequestId\x1a;\n" + "\rMaterialEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x10\n" + "\x0e_expires_at_msJ\x04\b\a\x10\bR\tworkspace\"i\n" + " ConfigureProviderRefreshResponse\x12E\n" + - "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xc9\x01\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xe8\x01\n" + "\x1fRotateProviderCredentialRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"i\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"i\n" + " RotateProviderCredentialResponse\x12E\n" + - "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xeb\x01\n" + + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\x8a\x02\n" + "\x1cDeleteProviderRefreshRequest\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x02 \x01(\tR\rcredentialKey\x12R\n" + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12#\n" + - "\rallow_missing\x18\x05 \x01(\bR\fallowMissingJ\x04\b\x03\x10\x04R\tworkspace\"g\n" + + "\rallow_missing\x18\x05 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"g\n" + "\x1dDeleteProviderRefreshResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xd8\x05\n" + "\x0fProviderProfile\x12\x0e\n" + @@ -15097,19 +15365,23 @@ const file_openshell_proto_rawDesc = "" + "\aprofile\x18\x01 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\"\x81\x01\n" + "\x1cListProviderProfilesResponse\x129\n" + "\bprofiles\x18\x01 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x82\x01\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa1\x01\n" + "\x1dImportProviderProfilesRequest\x12C\n" + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\x12\x1c\n" + - "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xc2\x01\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\x12\x1d\n" + + "\n" + + "request_id\x18\x03 \x01(\tR\trequestId\"\xc2\x01\n" + "\x1eImportProviderProfilesResponse\x12I\n" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x129\n" + "\bprofiles\x18\x02 \x03(\v2\x1d.openshell.v1.ProviderProfileR\bprofiles\x12\x1a\n" + - "\bimported\x18\x03 \x01(\bR\bimported\"\xcc\x01\n" + + "\bimported\x18\x03 \x01(\bR\bimported\"\xeb\x01\n" + "\x1dUpdateProviderProfilesRequest\x12A\n" + "\aprofile\x18\x01 \x01(\v2'.openshell.v1.ProviderProfileImportItemR\aprofile\x12:\n" + "\x19expected_resource_version\x18\x02 \x01(\x04R\x17expectedResourceVersion\x12\x0e\n" + "\x02id\x18\x03 \x01(\tR\x02id\x12\x1c\n" + - "\tworkspace\x18\x04 \x01(\tR\tworkspace\"\xbe\x01\n" + + "\tworkspace\x18\x04 \x01(\tR\tworkspace\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestId\"\xbe\x01\n" + "\x1eUpdateProviderProfilesResponse\x12I\n" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x127\n" + "\aprofile\x18\x02 \x01(\v2\x1d.openshell.v1.ProviderProfileR\aprofile\x12\x18\n" + @@ -15121,11 +15393,13 @@ const file_openshell_proto_rawDesc = "" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x12\x14\n" + "\x05valid\x18\x02 \x01(\bR\x05valid\"`\n" + "\x16DeleteProviderResponse\x127\n" + - "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"q\n" + + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\x90\x01\n" + "\x1cDeleteProviderProfileRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tworkspace\x18\x02 \x01(\tR\tworkspace\x12#\n" + - "\rallow_missing\x18\x03 \x01(\bR\fallowMissing\"g\n" + + "\rallow_missing\x18\x03 \x01(\bR\fallowMissing\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestId\"g\n" + "\x1dDeleteProviderProfileResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\x94\x01\n" + "$GetSandboxProviderEnvironmentRequest\x12\x1d\n" + @@ -15170,7 +15444,7 @@ const file_openshell_proto_rawDesc = "" + "\n" + "expires_in\x18\x02 \x01(\x03R\texpiresIn\x12\x1d\n" + "\n" + - "token_type\x18\x03 \x01(\tR\ttokenType\"\x95\x05\n" + + "token_type\x18\x03 \x01(\tR\ttokenType\"\xb4\x05\n" + "\x13UpdateConfigRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12;\n" + "\x06policy\x18\x02 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x1f\n" + @@ -15182,7 +15456,9 @@ const file_openshell_proto_rawDesc = "" + "\x10merge_operations\x18\a \x03(\v2\".openshell.v1.PolicyMergeOperationR\x0fmergeOperations\x12:\n" + "\x19expected_resource_version\x18\b \x01(\x04R\x17expectedResourceVersion\x12T\n" + "\vannotations\x18\t \x03(\v22.openshell.v1.UpdateConfigRequest.AnnotationsEntryR\vannotations\x12R\n" + - "\x0fworkspace_scope\x18\v \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1a>\n" + + "\x0fworkspace_scope\x18\v \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\f \x01(\tR\trequestId\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\n" + @@ -15452,53 +15728,65 @@ const file_openshell_proto_rawDesc = "" + "\x06chunks\x18\x01 \x03(\v2\x19.openshell.v1.PolicyChunkR\x06chunks\x12'\n" + "\x0frolling_summary\x18\x02 \x01(\tR\x0erollingSummary\x12#\n" + "\rdraft_version\x18\x03 \x01(\x04R\fdraftVersion\x12-\n" + - "\x13last_analyzed_at_ms\x18\x04 \x01(\x03R\x10lastAnalyzedAtMs\"\xd1\x01\n" + + "\x13last_analyzed_at_ms\x18\x04 \x01(\x03R\x10lastAnalyzedAtMs\"\xf0\x01\n" + "\x18ApproveDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12!\n" + "\freview_token\x18\x04 \x01(\tR\vreviewToken\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"c\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"c\n" + "\x19ApproveDraftChunkResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + - "policyHash\"\xc5\x01\n" + + "policyHash\"\xe4\x01\n" + "\x17RejectDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12\x16\n" + "\x06reason\x18\x03 \x01(\tR\x06reason\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x1a\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\x1a\n" + "\x18RejectDraftChunkResponse\"R\n" + "\x12DraftChunkApproval\x12\x19\n" + "\bchunk_id\x18\x01 \x01(\tR\achunkId\x12!\n" + - "\freview_token\x18\x02 \x01(\tR\vreviewToken\"\x91\x02\n" + + "\freview_token\x18\x02 \x01(\tR\vreviewToken\"\xb0\x02\n" + "\x1cApproveAllDraftChunksRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x128\n" + "\x18include_security_flagged\x18\x02 \x01(\bR\x16includeSecurityFlagged\x12>\n" + "\tapprovals\x18\x04 \x03(\v2 .openshell.v1.DraftChunkApprovalR\tapprovals\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"\xb7\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"\xb7\x01\n" + "\x1dApproveAllDraftChunksResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + "policyHash\x12'\n" + "\x0fchunks_approved\x18\x03 \x01(\rR\x0echunksApproved\x12%\n" + - "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\xf9\x01\n" + + "\x0echunks_skipped\x18\x04 \x01(\rR\rchunksSkipped\"\x98\x02\n" + "\x15EditDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12L\n" + "\rproposed_rule\x18\x03 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\fproposedRule\x12R\n" + - "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x04\x10\x05R\tworkspace\"\x18\n" + - "\x16EditDraftChunkResponse\"\xab\x01\n" + + "\x0fworkspace_scope\x18\x05 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x06 \x01(\tR\trequestIdJ\x04\b\x04\x10\x05R\tworkspace\"\x18\n" + + "\x16EditDraftChunkResponse\"\xca\x01\n" + "\x15UndoDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"`\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x05 \x01(\tR\trequestIdJ\x04\b\x03\x10\x04R\tworkspace\"`\n" + "\x16UndoDraftChunkResponse\x12%\n" + "\x0epolicy_version\x18\x01 \x01(\rR\rpolicyVersion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + - "policyHash\"\x92\x01\n" + + "policyHash\"\xb1\x01\n" + "\x17ClearDraftChunksRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12R\n" + - "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"A\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12\x1d\n" + + "\n" + + "request_id\x18\x04 \x01(\tR\trequestIdJ\x04\b\x02\x10\x03R\tworkspace\"A\n" + "\x18ClearDraftChunksResponse\x12%\n" + "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\"\x91\x01\n" + "\x16GetDraftHistoryRequest\x12\x12\n" +