diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index ceb6d45405..4eac50fefc 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -497,12 +497,12 @@ use openshell_core::proto::{ ..Default::default() }; ``` -- `SandboxLogLine` proto fields: `sandbox_id`, `timestamp_ms`, `level`, `target`, `message`, `source`, `fields` (HashMap). +- `SandboxLogLine` proto fields: `sandbox_id`, `event_time` (`Option`), `level`, `target`, `message`, `source`, `fields` (`HashMap`). - Workspace-scoped request fields use `workspace_scope: Option`. Select one workspace with `Some(workspace_selector(name))`. List requests that explicitly support cross-workspace access also accept `Some(all_workspaces_selector())`; do not use that marker on other requests. -- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), +- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_time` (`Option`), `sources` (Vec), `min_level` (String), `workspace_scope`. - `ListSandboxesRequest` fields: `page_size` (i32), `page_token` (String), `label_selector` (String), `workspace_scope`. diff --git a/Cargo.lock b/Cargo.lock index c9c14fdca8..6d5c8afd55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4486,6 +4486,7 @@ dependencies = [ "noyalib", "openshell-core", "openshell-policy", + "prost-types", "serde", "serde_json", "thiserror 2.0.18", diff --git a/architecture/gateway.md b/architecture/gateway.md index 6b465a0508..48e3ccc9f6 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -362,10 +362,13 @@ Storage-only messages live in the private, versioned `openshell.storage.v1` package under `crates/openshell-server/proto`. The server generates these types separately, so the public descriptor set and the Rust, Go, Python, and TypeScript client generation inputs do not advertise them. +When a frozen scalar storage field cannot distinguish absence from its zero +value, gateway-owned object metadata annotations carry that presence bit rather +than extending the frozen message. | Storage classification | Protobuf messages | Durable use | |---|---|---| -| Encoded storage roots | `StoredProviderCredentialRefreshState`, `StoredProviderProfile`, `PolicyRevisionPayload`, `DraftChunkPayload` | Complete protobuf payload stored in an object row or a scoped policy row. | +| Encoded storage roots | `StoredProviderCredentialRefreshStateV2`, `StoredProviderProfile`, `PolicyRevisionPayload`, `DraftChunkPayload` | Complete protobuf payload stored in an object row or a scoped policy row. The frozen V1 refresh state remains available only for transactional upgrade decoding. | | Nested storage-only type | `StoredRefreshMaterialDeletion` | Repeated child records inside provider refresh state. | | SQL materializations | `StoredPolicyRevision`, `StoredDraftChunk` | Server-only typed results assembled from indexed columns and decoded payloads; not public RPC messages. | | Public messages used directly as encoded storage roots | `Sandbox`, `SandboxWorkloadTemplate`, `Provider`, `Workspace`, `WorkspaceMember`, `SshSession`, `ServiceEndpoint` | The generated public type is also the persisted payload. `SshSession` is not in the current public RPC message closure. | @@ -445,6 +448,19 @@ For in-memory SQLite, the adapter retains a dedicated keepalive connection for the store lifetime. Operational connection replacement therefore preserves the shared in-memory schema and objects instead of creating an empty database. +Public protobuf APIs represent absolute times with `google.protobuf.Timestamp` +and elapsed time with `google.protobuf.Duration`. The integer +`created_at_ms` and `updated_at_ms` database columns are intentionally internal +bookkeeping values, not part of that public convention. On startup, both +storage backends transactionally rewrite legacy scalar time fields inside +protobuf payloads before serving requests. A malformed affected payload aborts +and rolls back startup migration. Legacy driver-provided condition strings that +cannot be represented as timestamps are dropped so an accepted historical +value cannot make the upgraded gateway unavailable. +Gateway and Sandbox Protocol token responses follow the same convention: a +present expiration timestamp carries the absolute deadline, while absence means +the issued token does not expire. + The SQLite adapter tightens the on-disk database file to mode `0o600` on every connect so that provider API keys, SSH session tokens, and sandbox metadata are not readable by other local users on shared hosts. The same restriction is diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index b85fef0013..1005bfd256 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -34,6 +34,12 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; use tonic::{Code, Status}; +fn proto_timestamp_ms(timestamp: Option<&prost_types::Timestamp>) -> i64 { + timestamp + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .unwrap_or_default() +} + fn aggregate_delete_failures(resource: &str, failures: &[String]) -> Result<()> { if failures.is_empty() { Ok(()) @@ -475,17 +481,17 @@ async fn auto_create_provider( metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: exact_name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: workspace.to_string(), credential_handles: HashMap::new(), }), @@ -523,17 +529,17 @@ async fn auto_create_provider( metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.clone(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: workspace.to_string(), credential_handles: HashMap::new(), }), @@ -1104,17 +1110,24 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.clone(), credentials: credential_map, config: config_map, - credential_expires_at_ms: oidc_credential_expires_at_ms, + credential_expiration_times: oidc_credential_expires_at_ms + .into_iter() + .map(|(key, value)| { + openshell_core::time::timestamp_from_millis(value) + .map(|timestamp| (key, timestamp)) + }) + .collect::, _>>() + .into_diagnostic()?, profile_workspace: profile_workspace.to_string(), credential_handles: HashMap::new(), }), @@ -1147,7 +1160,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> "client_secret".to_string(), "refresh_token".to_string(), ], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await @@ -1291,19 +1304,26 @@ fn provider_to_json(provider: &Provider) -> serde_json::Value { serde_json::json!(meta.resource_version), ); } - if meta.created_at_ms != 0 { + if meta.created_time.is_some() { obj.insert( "created_at".to_string(), - serde_json::json!(format_epoch_ms(meta.created_at_ms)), + serde_json::json!(format_epoch_ms(proto_timestamp_ms( + meta.created_time.as_ref() + ))), ); } } // Credential expiration times (only if present) - if !provider.credential_expires_at_ms.is_empty() { + if !provider.credential_expiration_times.is_empty() { + let expirations: HashMap<_, _> = provider + .credential_expiration_times + .iter() + .map(|(key, value)| (key, proto_timestamp_ms(Some(value)))) + .collect(); obj.insert( "credential_expires_at_ms".to_string(), - serde_json::json!(provider.credential_expires_at_ms), + serde_json::json!(expirations), ); } @@ -1809,7 +1829,11 @@ pub async fn provider_refresh_config( strategy: strategy as i32, material, secret_material_keys, - expires_at_ms: input.credential_expires_at_ms, + expiration_time: input + .credential_expires_at_ms + .map(openshell_core::time::timestamp_from_millis) + .transpose() + .into_diagnostic()?, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), }) .await @@ -1925,22 +1949,17 @@ fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String { provider_refresh_strategy_name(strategy), status.status, provider_refresh_recovery_action_name(recovery_action), - format_optional_epoch_ms(status.expires_at_ms), - format_refresh_next_at_ms(status.next_refresh_at_ms), - format_optional_epoch_ms(status.last_refresh_at_ms), + format_optional_epoch_ms(proto_timestamp_ms(status.expiration_time.as_ref())), + status.next_refresh_time.as_ref().map_or_else( + || "manual".to_string(), + |value| format_optional_epoch_ms(proto_timestamp_ms(Some(value))), + ), + format_optional_epoch_ms(proto_timestamp_ms(status.last_refresh_time.as_ref())), status.failure_code, truncate_status_field(&status.last_error, 72), ) } -fn format_refresh_next_at_ms(next_refresh_at_ms: i64) -> String { - if next_refresh_at_ms == i64::MAX { - "-".to_string() - } else { - format_optional_epoch_ms(next_refresh_at_ms) - } -} - fn provider_refresh_recovery_action_name( action: ProviderCredentialRefreshRecoveryAction, ) -> &'static str { @@ -2287,6 +2306,11 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { let mut config_map = parse_key_value_pairs(config, "--config")?; let mut credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; credential_expires_at_ms.extend(oidc_credential_expires_at_ms); + let clear_credential_expiration_keys = credential_expires_at_ms + .iter() + .filter_map(|(key, expires_at_ms)| (*expires_at_ms == 0).then_some(key.clone())) + .collect::>(); + credential_expires_at_ms.retain(|_, expires_at_ms| *expires_at_ms != 0); if from_existing { let stored = existing.as_ref().expect("checked above"); @@ -2314,12 +2338,12 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: existing .as_ref() @@ -2327,15 +2351,23 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { .unwrap_or_default(), credentials: credential_map, config: config_map, - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: existing .as_ref() .map(|provider| provider.profile_workspace.clone()) .unwrap_or_default(), credential_handles: HashMap::new(), }), - credential_expires_at_ms, + credential_expiration_times: credential_expires_at_ms + .into_iter() + .map(|(key, value)| { + openshell_core::time::timestamp_from_millis(value) + .map(|timestamp| (key, timestamp)) + }) + .collect::, _>>() + .into_diagnostic()?, workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), + clear_credential_expiration_keys, }) .await .into_diagnostic()?; @@ -2419,7 +2451,10 @@ mod tests { ("Z_URL".to_string(), "https://secret.example".to_string()), ("A_MODE".to_string(), "sensitive-config".to_string()), ]), - credential_expires_at_ms: HashMap::from([("Z_TOKEN".to_string(), 123)]), + credential_expiration_times: HashMap::from([( + "Z_TOKEN".to_string(), + openshell_core::time::timestamp_from_millis(123).unwrap(), + )]), profile_workspace: "internal".to_string(), credential_handles: HashMap::from([ ( @@ -2482,7 +2517,7 @@ mod tests { "https://api.custom.example".to_string(), )) .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }], @@ -2514,15 +2549,15 @@ mod tests { credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, status: "error".to_string(), - expires_at_ms: 1_767_225_600_000, - next_refresh_at_ms: i64::MAX, - last_refresh_at_ms: 1_767_225_000_000, + expiration_time: openshell_core::time::timestamp_from_millis(1_767_225_600_000).ok(), + next_refresh_time: None, + last_refresh_time: openshell_core::time::timestamp_from_millis(1_767_225_000_000).ok(), last_error: "token endpoint returned a very long error message that should be truncated for table readability" .to_string(), recovery_action: ProviderCredentialRefreshRecoveryAction::Reauthorize as i32, failure_code: "oauth_rotated_refresh_token_handle_missing".to_string(), provider_error_subtype: "invalid_rapt".to_string(), - last_error_at_ms: 1_767_225_000_000, + last_error_time: openshell_core::time::timestamp_from_millis(1_767_225_000_000).ok(), }); assert!(row.contains("my-graph")); @@ -2806,7 +2841,7 @@ mod tests { r#type: "anthropic".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -2830,7 +2865,7 @@ mod tests { r#type: "anthropic".to_string(), credentials, config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -2869,7 +2904,7 @@ mod tests { r#type: "custom".to_string(), credentials: HashMap::new(), config, - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -2901,7 +2936,7 @@ mod tests { r#type: "anthropic".to_string(), credentials: HashMap::new(), config: HashMap::new(), // Empty config - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -2923,11 +2958,11 @@ mod tests { id: "prov-123".to_string(), name: "test-provider".to_string(), resource_version: 42, - created_at_ms: 1_234_567_890_000, + created_time: openshell_core::time::timestamp_from_millis(1_234_567_890_000).ok(), labels, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }; let provider = Provider { @@ -2935,7 +2970,7 @@ mod tests { r#type: "anthropic".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -2962,7 +2997,7 @@ mod tests { r#type: "anthropic".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -2993,7 +3028,15 @@ mod tests { r#type: "oauth".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms, + credential_expiration_times: credential_expires_at_ms + .into_iter() + .map(|(key, value)| { + ( + key, + openshell_core::time::timestamp_from_millis(value).unwrap(), + ) + }) + .collect(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -3011,7 +3054,7 @@ mod tests { let metadata = ObjectMeta { id: "prov-123".to_string(), name: "test-provider".to_string(), - created_at_ms: 1_609_459_200_000, // 2021-01-01 00:00:00 + created_time: openshell_core::time::timestamp_from_millis(1_609_459_200_000).ok(), // 2021-01-01 00:00:00 ..Default::default() }; @@ -3020,7 +3063,7 @@ mod tests { r#type: "anthropic".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 998c7a03a3..0dc97c1c28 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -71,6 +71,21 @@ use tonic::{Code, Status}; const PROVISIONAL_CONTAINER_EXIT_RECONCILIATION_TIMEOUT: Duration = Duration::from_secs(5); +fn proto_timestamp_ms(timestamp: Option<&prost_types::Timestamp>) -> i64 { + timestamp + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .unwrap_or_default() +} + +fn proto_execution_timeout(timeout_seconds: u32) -> Result> { + if timeout_seconds == 0 { + return Ok(None); + } + openshell_core::time::duration_from_std(Duration::from_secs(timeout_seconds.into())) + .map(Some) + .into_diagnostic() +} + // Re-export SSH functions for backward compatibility pub use crate::ssh::{Editor, print_ssh_config}; pub use crate::ssh::{ @@ -759,7 +774,7 @@ pub async fn sandbox_create( log_tail_lines: 200, event_tail: 50, stop_on_terminal: false, - log_since_ms: 0, + since_time: None, log_sources: vec!["gateway".to_string()], log_min_level: String::new(), }) @@ -1792,7 +1807,7 @@ pub async fn sandbox_exec_grpc( command: command.to_vec(), workdir: workdir.unwrap_or_default().to_string(), environment: environment.clone(), - timeout_seconds, + execution_timeout: proto_execution_timeout(timeout_seconds)?, stdin: stdin_payload, tty, cols, @@ -2177,7 +2192,7 @@ async fn sandbox_exec_interactive_grpc( workdir: workdir.unwrap_or_default().to_string(), environment: environment.clone(), no_login_shell, - timeout_seconds, + execution_timeout: proto_execution_timeout(timeout_seconds)?, stdin: Vec::new(), tty: true, cols, @@ -2416,7 +2431,12 @@ pub async fn sandbox_list( Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(), _ => phase.to_string(), }; - let created = format_epoch_ms(sandbox.metadata.as_ref().map_or(0, |m| m.created_at_ms)); + let created = format_epoch_ms( + sandbox + .metadata + .as_ref() + .map_or(0, |m| proto_timestamp_ms(m.created_time.as_ref())), + ); if all_workspaces { println!( "{: serde_json::Value { "ports": endpoint.ports, "path": endpoint.path, "last_result": endpoint_result_name(endpoint.last_result()), - "last_reported_at": endpoint.last_reported_at, + "last_reported_at": endpoint.last_reported_time.as_ref().map(ToString::to_string).unwrap_or_default(), }) }) .collect::>() @@ -2485,7 +2505,7 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { "labels": labels, "annotations": annotations, "resource_version": meta.map_or(0, |m| m.resource_version), - "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), + "created_at": format_epoch_ms(meta.map_or(0, |m| proto_timestamp_ms(m.created_time.as_ref()))), "phase": phase_name(sandbox.phase()), "current_policy_version": sandbox.current_policy_version(), "exit_code": sandbox.status.as_ref().and_then(|status| status.exit_code), @@ -2496,12 +2516,17 @@ fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { } fn sandbox_condition_to_json(condition: &SandboxCondition) -> serde_json::Value { + let transition_time = condition + .transition_time + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); serde_json::json!({ "type": condition.r#type, "status": condition.status, "reason": condition.reason, "message": condition.message, - "last_transition_time": condition.last_transition_time, + "last_transition_time": transition_time, }) } @@ -2520,11 +2545,8 @@ fn sandbox_condition_display_lines(condition: &SandboxCondition) -> Vec "{}: {}{reason}{message}", condition.r#type, condition.status )]; - if !condition.last_transition_time.is_empty() { - lines.push(format!( - "Last transition: {}", - condition.last_transition_time - )); + if let Some(transition_time) = &condition.transition_time { + lines.push(format!("Last transition: {transition_time}")); } lines } @@ -2564,8 +2586,11 @@ fn endpoint_status_display_lines(endpoint: &EndpointStatus) -> Vec { EndpointResult::UpstreamRejected => "Server rejected the request (HTTP 400 or higher).", }; // The gateway supplies acceptance time, which can follow the actual - // exchange. An empty timestamp means there is no accepted observation. - let reported_at = non_empty_or(&endpoint.last_reported_at, "no report yet"); + // exchange. An absent timestamp means there is no accepted observation. + let reported_at = endpoint + .last_reported_time + .as_ref() + .map_or_else(|| "no report yet".to_string(), ToString::to_string); vec![ format!( "{} (ports: {ports}; path: {})", @@ -2663,12 +2688,12 @@ pub async fn sandbox_template_create( metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels, resource_version: 0, annotations, workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxWorkloadTemplateSpec { workload: Some(SandboxWorkloadConfig { @@ -2868,10 +2893,12 @@ fn sandbox_template_to_json(template: &SandboxWorkloadTemplate) -> serde_json::V serde_json::json!(metadata.resource_version), ); } - if metadata.created_at_ms != 0 { + if metadata.created_time.is_some() { obj.insert( "created_at".to_string(), - serde_json::json!(format_epoch_ms(metadata.created_at_ms)), + serde_json::json!(format_epoch_ms(proto_timestamp_ms( + metadata.created_time.as_ref() + ))), ); } if !metadata.labels.is_empty() { @@ -2967,11 +2994,11 @@ fn print_sandbox_template_detail(template: &SandboxWorkloadTemplate) { "Resource version:".dimmed(), metadata.resource_version ); - if metadata.created_at_ms != 0 { + if metadata.created_time.is_some() { println!( " {} {}", "Created:".dimmed(), - format_epoch_ms(metadata.created_at_ms) + format_epoch_ms(proto_timestamp_ms(metadata.created_time.as_ref())) ); } let labels = labels_display(&metadata.labels); @@ -3379,7 +3406,7 @@ async fn wait_for_lifecycle_phase( log_tail_lines: 0, event_tail: 0, stop_on_terminal: false, - log_since_ms: 0, + since_time: None, log_sources: Vec::new(), log_min_level: String::new(), }) @@ -3824,11 +3851,11 @@ pub async fn workspace_get(server: &str, name: &str, tls: &TlsOptions) -> Result "Resource version:".dimmed(), meta.resource_version ); - if meta.created_at_ms != 0 { + if meta.created_time.is_some() { println!( " {} {}", "Created:".dimmed(), - format_epoch_ms(meta.created_at_ms) + format_epoch_ms(proto_timestamp_ms(meta.created_time.as_ref())) ); } if !meta.labels.is_empty() { @@ -3903,10 +3930,9 @@ pub async fn workspace_list( for workspace in &workspaces { let status = workspace_phase_display(workspace); - let created = workspace - .metadata - .as_ref() - .map_or_else(String::new, |m| format_epoch_ms(m.created_at_ms)); + let created = workspace.metadata.as_ref().map_or_else(String::new, |m| { + format_epoch_ms(proto_timestamp_ms(m.created_time.as_ref())) + }); let labels = workspace.metadata.as_ref().map_or_else(String::new, |m| { m.labels .iter() @@ -4130,10 +4156,12 @@ fn workspace_to_json(workspace: &openshell_core::proto::Workspace) -> serde_json "resource_version".to_string(), serde_json::json!(meta.resource_version), ); - if meta.created_at_ms != 0 { + if meta.created_time.is_some() { obj.insert( "created_at".to_string(), - serde_json::json!(format_epoch_ms(meta.created_at_ms)), + serde_json::json!(format_epoch_ms(proto_timestamp_ms( + meta.created_time.as_ref() + ))), ); } if !meta.labels.is_empty() { @@ -5096,11 +5124,21 @@ where writeln!(stdout, "Hash: {}", rev.policy_hash).into_diagnostic()?; writeln!(stdout, "Status: {status:?}").into_diagnostic()?; writeln!(stdout, "Active: {}", inner.active_version).into_diagnostic()?; - if rev.created_at_ms > 0 { - writeln!(stdout, "Created: {} ms", rev.created_at_ms).into_diagnostic()?; + if let Some(created_time) = rev.created_time.as_ref() { + writeln!( + stdout, + "Created: {} ms", + proto_timestamp_ms(Some(created_time)) + ) + .into_diagnostic()?; } - if rev.loaded_at_ms > 0 { - writeln!(stdout, "Loaded: {} ms", rev.loaded_at_ms).into_diagnostic()?; + if let Some(loaded_time) = rev.loaded_time.as_ref() { + writeln!( + stdout, + "Loaded: {} ms", + proto_timestamp_ms(Some(loaded_time)) + ) + .into_diagnostic()?; } if !rev.load_error.is_empty() { writeln!(stdout, "Error: {}", rev.load_error).into_diagnostic()?; @@ -5278,11 +5316,14 @@ pub async fn sandbox_policy_get_global( println!("Version: {}", rev.version); println!("Hash: {}", rev.policy_hash); println!("Status: {status:?}"); - if rev.created_at_ms > 0 { - println!("Created: {} ms", rev.created_at_ms); + if let Some(created_time) = rev.created_time.as_ref() { + println!( + "Created: {} ms", + proto_timestamp_ms(Some(created_time)) + ); } - if rev.loaded_at_ms > 0 { - println!("Loaded: {} ms", rev.loaded_at_ms); + if let Some(loaded_time) = rev.loaded_time.as_ref() { + println!("Loaded: {} ms", proto_timestamp_ms(Some(loaded_time))); } if view.includes_policy() { @@ -5338,16 +5379,16 @@ fn policy_revision_to_json( serde_json::json!(active_version), ); } - if rev.created_at_ms > 0 { + if rev.created_time.is_some() { obj.insert( "created_at_ms".to_string(), - serde_json::json!(rev.created_at_ms), + serde_json::json!(proto_timestamp_ms(rev.created_time.as_ref())), ); } - if rev.loaded_at_ms > 0 { + if rev.loaded_time.is_some() { obj.insert( "loaded_at_ms".to_string(), - serde_json::json!(rev.loaded_at_ms), + serde_json::json!(proto_timestamp_ms(rev.loaded_time.as_ref())), ); } if !rev.load_error.is_empty() { @@ -5516,7 +5557,7 @@ fn print_policy_revision_table(revisions: &[openshell_core::proto::SandboxPolicy rev.version, hash_short, format!("{status:?}"), - rev.created_at_ms, + proto_timestamp_ms(rev.created_time.as_ref()), error_short, ); } @@ -5584,7 +5625,8 @@ pub async fn sandbox_logs( log_tail_lines: lines, event_tail: 0, stop_on_terminal: false, - log_since_ms: since_ms, + since_time: openshell_core::time::optional_timestamp_from_legacy_millis(since_ms) + .into_diagnostic()?, log_sources: source_filter, log_min_level: level.to_uppercase(), }) @@ -5606,7 +5648,8 @@ pub async fn sandbox_logs( .get_sandbox_logs(GetSandboxLogsRequest { sandbox_id: sandbox.object_id().to_string(), lines, - since_ms, + since_time: openshell_core::time::optional_timestamp_from_legacy_millis(since_ms) + .into_diagnostic()?, sources: source_filter, min_level: level.to_uppercase(), workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)), @@ -5641,8 +5684,9 @@ fn format_log_line(log: &openshell_core::proto::SandboxLogLine) -> String { } else { &log.source }; - let secs = log.timestamp_ms / 1000; - let millis = log.timestamp_ms % 1000; + let timestamp_ms = proto_timestamp_ms(log.event_time.as_ref()); + let secs = timestamp_ms / 1000; + let millis = timestamp_ms % 1000; if log.fields.is_empty() { format!( "[{secs}.{millis:03}] [{source:<7}] [{:<5}] [{}] {}", @@ -5770,8 +5814,8 @@ pub async fn sandbox_draft_get( " {} {} (first seen {}, last seen {})", "Hits:".dimmed(), chunk.hit_count, - format_epoch_ms(chunk.first_seen_ms), - format_epoch_ms(chunk.last_seen_ms), + format_epoch_ms(proto_timestamp_ms(chunk.first_seen_time.as_ref())), + format_epoch_ms(proto_timestamp_ms(chunk.last_seen_time.as_ref())), ); } println!(); @@ -5963,7 +6007,7 @@ pub async fn sandbox_draft_history( println!( " {} {} [{}] {}", - format_timestamp_ms(entry.timestamp_ms).dimmed(), + format_timestamp_ms(proto_timestamp_ms(entry.event_time.as_ref())).dimmed(), event_colored, entry.chunk_id.get(..8).unwrap_or(&entry.chunk_id), entry.description, @@ -6031,11 +6075,23 @@ mod tests { format_log_line, git_sync_files, has_main_process_result, parse_cli_setting_value, parse_credential_expiry_cli_value, parse_driver_config_json, parse_secret_material_env_pairs, policy_revision_list_json, policy_revision_to_json, - provisioning_timeout_message, ready_false_condition_message, resolve_from, - rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, sandbox_upload_plan, - service_endpoint_to_json, service_expose_status_error, service_url_for_gateway, - workspace_member_to_json, + proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message, + resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, + sandbox_upload_plan, service_endpoint_to_json, service_expose_status_error, + service_url_for_gateway, workspace_member_to_json, }; + + #[test] + fn zero_exec_timeout_is_omitted() { + assert!(proto_execution_timeout(0).unwrap().is_none()); + assert_eq!( + proto_execution_timeout(30).unwrap().unwrap(), + prost_types::Duration { + seconds: 30, + nanos: 0, + } + ); + } use crate::TEST_ENV_LOCK; use crate::commands::common::{ parse_credential_expiry_pairs, parse_credential_pairs, progress_step_from_metadata, @@ -6096,8 +6152,8 @@ mod tests { policy_hash: "0123456789abcdef".to_string(), status: PolicyStatus::Failed as i32, load_error: load_error.to_string(), - created_at_ms: 100, - loaded_at_ms: 200, + created_time: openshell_core::time::timestamp_from_millis(100).ok(), + loaded_time: openshell_core::time::timestamp_from_millis(200).ok(), policy: Some(SandboxPolicy::default()), provenance: std::collections::HashMap::from([( "source".to_string(), @@ -6920,7 +6976,7 @@ mod tests { status: "False".to_string(), reason: "Unschedulable".to_string(), message: "Another GPU sandbox may already be using the available GPU.".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }; @@ -6941,7 +6997,7 @@ mod tests { status: "True".to_string(), reason: "Scheduled".to_string(), message: "Sandbox scheduled".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }; @@ -7257,7 +7313,7 @@ mod tests { id: "sb-123".to_string(), name: "test-sb".to_string(), resource_version: 5, - created_at_ms: 1_609_459_200_000, + created_time: openshell_core::time::timestamp_from_millis(1_609_459_200_000).ok(), ..Default::default() }), created_from_workload_template: Some(SandboxWorkloadTemplateProvenance { @@ -7324,14 +7380,14 @@ mod tests { ports: vec![443, 8443], path: "/mcp".to_string(), last_result: EndpointResult::TransportFailed as i32, - last_reported_at: "2026-09-05T10:01:00Z".to_string(), + last_reported_time: Some("2026-09-05T10:01:00Z".parse().unwrap()), }], conditions: vec![SandboxCondition { r#type: "Ready".to_string(), status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Supervisor session connected".to_string(), - last_transition_time: "2026-09-05T10:00:00Z".to_string(), + transition_time: "2026-09-05T10:00:00Z".parse().ok(), }], ..Default::default() }), @@ -7385,7 +7441,7 @@ mod tests { ports: vec![443, 8443], path: "/mcp".to_string(), last_result: EndpointResult::TransportFailed as i32, - last_reported_at: "2026-09-05T11:01:00Z".to_string(), + last_reported_time: Some("2026-09-05T11:01:00Z".parse().unwrap()), }; assert_eq!( @@ -7406,7 +7462,7 @@ mod tests { ports: vec![443], path: "/**".to_string(), last_result: EndpointResult::NoObservedExchange as i32, - last_reported_at: String::new(), + last_reported_time: None, }; assert_eq!( @@ -7446,7 +7502,7 @@ mod tests { ports: vec![443], path: "/mcp".to_string(), last_result: EndpointResult::HttpResponseReceived as i32, - last_reported_at: "2026-09-05T11:01:00Z".to_string(), + last_reported_time: Some("2026-09-05T11:01:00Z".parse().unwrap()), ..Default::default() }; assert_eq!( @@ -7494,7 +7550,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Supervisor session connected".to_string(), - last_transition_time: String::new(), + transition_time: None, }; assert_eq!( super::sandbox_condition_display_lines(&ordinary), @@ -7511,7 +7567,7 @@ mod tests { ) -> openshell_core::proto::SandboxLogLine { openshell_core::proto::SandboxLogLine { sandbox_id: "sb-1".to_string(), - timestamp_ms: 1_234_567, + event_time: openshell_core::time::timestamp_from_millis(1_234_567).ok(), level: level.to_string(), target: target.to_string(), message: message.to_string(), @@ -7589,10 +7645,10 @@ mod tests { #[test] fn format_log_line_zero_pads_millis() { let mut log = log_line("INFO", "t", "m", "sandbox", &[]); - log.timestamp_ms = 1_000_007; + log.event_time = openshell_core::time::timestamp_from_millis(1_000_007).ok(); assert_eq!(format_log_line(&log), "[1000.007] [sandbox] [INFO ] [t] m"); - log.timestamp_ms = 0; + log.event_time = None; assert_eq!(format_log_line(&log), "[0.000] [sandbox] [INFO ] [t] m"); } diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 0f69287d97..ca36ed6554 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -59,17 +59,17 @@ impl TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -420,38 +420,34 @@ impl OpenShell for TestOpenShell { } base }; - let merge_expiry = |mut base: HashMap, incoming: HashMap| { - if incoming.is_empty() { - return base; - } - for (k, v) in incoming { - if v <= 0 { - base.remove(&k); - } else { - base.insert(k, v); + let merge_expiry = + |mut base: HashMap, + incoming: HashMap| { + if incoming.is_empty() { + return base; } - } - base - }; + base.extend(incoming); + base + }; let existing_metadata = existing.metadata.clone().unwrap_or_default(); let provider_metadata = provider.metadata.clone().unwrap_or_default(); let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: existing_metadata.id, name: provider_metadata.name, - created_at_ms: existing_metadata.created_at_ms, + created_time: existing_metadata.created_time, labels: existing_metadata.labels, resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), - credential_expires_at_ms: merge_expiry( - existing.credential_expires_at_ms, - provider.credential_expires_at_ms, + credential_expiration_times: merge_expiry( + existing.credential_expiration_times, + provider.credential_expiration_times, ), profile_workspace: existing.profile_workspace, credential_handles: if provider.credential_handles.is_empty() { diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index b7431bfc6d..12f9be0bd7 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -190,12 +190,12 @@ impl OpenShell for TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("sb-{name}"), name, - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 1, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, status: None, @@ -688,38 +688,34 @@ impl OpenShell for TestOpenShell { } base }; - let merge_expiry = |mut base: HashMap, incoming: HashMap| { - if incoming.is_empty() { - return base; - } - for (k, v) in incoming { - if v <= 0 { - base.remove(&k); - } else { - base.insert(k, v); + let merge_expiry = + |mut base: HashMap, + incoming: HashMap| { + if incoming.is_empty() { + return base; } - } - base - }; + base.extend(incoming); + base + }; let existing_metadata = existing.metadata.clone().unwrap_or_default(); let provider_metadata = provider.metadata.clone().unwrap_or_default(); let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: existing_metadata.id, name: provider_metadata.name, - created_at_ms: existing_metadata.created_at_ms, + created_time: existing_metadata.created_time, labels: existing_metadata.labels, resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), - credential_expires_at_ms: merge_expiry( - existing.credential_expires_at_ms, - provider.credential_expires_at_ms, + credential_expiration_times: merge_expiry( + existing.credential_expiration_times, + provider.credential_expiration_times, ), profile_workspace: existing.profile_workspace, credential_handles: if provider.credential_handles.is_empty() { @@ -780,7 +776,10 @@ impl OpenShell for TestOpenShell { credential_key: request.credential_key.clone(), material: request.material.clone(), secret_material_keys: request.secret_material_keys.clone(), - expires_at_ms: request.expires_at_ms, + expires_at_ms: request + .expiration_time + .as_ref() + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()), }); let configure_failure = self .state @@ -801,14 +800,14 @@ impl OpenShell for TestOpenShell { credential_key: request.credential_key.clone(), strategy: request.strategy, status: "configured".to_string(), - expires_at_ms: request.expires_at_ms.unwrap_or_default(), - next_refresh_at_ms: 0, - last_refresh_at_ms: 0, + expiration_time: request.expiration_time, + next_refresh_time: None, + last_refresh_time: None, last_error: String::new(), recovery_action: 0, failure_code: String::new(), provider_error_subtype: String::new(), - last_error_at_ms: 0, + last_error_time: None, }; drop(providers); self.state @@ -847,9 +846,9 @@ impl OpenShell for TestOpenShell { .get_mut(&(provider_name.clone(), credential_key.clone())) .ok_or_else(|| Status::not_found("provider refresh state not found"))?; status.status = "refreshed".to_string(); - status.last_refresh_at_ms = 1; - status.next_refresh_at_ms = 3_600_000; - status.expires_at_ms = 3_600_000; + status.last_refresh_time = openshell_core::time::timestamp_from_millis(1).ok(); + status.next_refresh_time = openshell_core::time::timestamp_from_millis(3_600_000).ok(); + status.expiration_time = openshell_core::time::timestamp_from_millis(3_600_000).ok(); let status = status.clone(); drop(refresh_statuses); let mut providers = self.state.providers.lock().await; @@ -859,9 +858,10 @@ impl OpenShell for TestOpenShell { provider .credentials .insert(credential_key.clone(), format!("minted-{credential_key}")); - provider - .credential_expires_at_ms - .insert(credential_key, 3_600_000); + provider.credential_expiration_times.insert( + credential_key, + openshell_core::time::timestamp_from_millis(3_600_000).unwrap(), + ); Ok(Response::new(RotateProviderCredentialResponse { status: Some(status), })) @@ -2347,7 +2347,7 @@ async fn provider_update_from_existing_uses_profile_discovery() { r#type: "custom-update-discovery".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index d3cccefb76..b2a447029c 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -150,12 +150,12 @@ impl OpenShell for TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("id-{sandbox_name}"), name: sandbox_name, - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Sandbox::default() }; @@ -188,12 +188,12 @@ impl OpenShell for TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), name, - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Sandbox::default() }; @@ -223,7 +223,7 @@ impl OpenShell for TestOpenShell { template.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("template-{name}"), name, - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: template .metadata .as_ref() @@ -234,7 +234,7 @@ impl OpenShell for TestOpenShell { workspace: selected_workspace(&request.workspace_scope) .unwrap_or("default") .to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }); self.state .template_create_requests @@ -261,14 +261,14 @@ impl OpenShell for TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("template-{}", request.name), name: request.name, - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 1, annotations: HashMap::new(), workspace: selected_workspace(&request.workspace_scope) .unwrap_or("default") .to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, }), @@ -609,12 +609,12 @@ impl OpenShell for TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.clone(), name: sandbox_id.trim_start_matches("id-").to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Sandbox::default() }; @@ -627,7 +627,7 @@ impl OpenShell for TestOpenShell { status: "False".to_string(), reason: "ProcessExited".to_string(), message: "VM process exited with status 0".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }), @@ -710,7 +710,7 @@ impl OpenShell for TestOpenShell { .send(Ok(SandboxStreamEvent { payload: Some(sandbox_stream_event::Payload::Log(SandboxLogLine { sandbox_id: sandbox_id.clone(), - timestamp_ms: 0, + event_time: None, level: "INFO".to_string(), target: "test".to_string(), message: message.to_string(), @@ -1428,17 +1428,17 @@ async fn add_provider(server: &TestServer, name: &str, provider_type: &str) { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("provider-{name}"), name: name.to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }); diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 9fb985c0fb..6fb1cabcf6 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -133,12 +133,12 @@ impl OpenShell for TestOpenShell { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "test-id".to_string(), name, - created_at_ms: 0, + created_time: None, labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }), @@ -489,8 +489,8 @@ impl OpenShell for TestOpenShell { version: 7, policy_hash: "sha256:test-policy".to_string(), status: PolicyStatus::Loaded.into(), - created_at_ms: 1_700_000_000_000, - loaded_at_ms: 1_700_000_000_500, + created_time: openshell_core::time::timestamp_from_millis(1_700_000_000_000).ok(), + loaded_time: openshell_core::time::timestamp_from_millis(1_700_000_000_500).ok(), policy: Some(policy), ..Default::default() }), diff --git a/crates/openshell-core/src/endpoint_status.rs b/crates/openshell-core/src/endpoint_status.rs index 1da64e7bd3..66bcf278c4 100644 --- a/crates/openshell-core/src/endpoint_status.rs +++ b/crates/openshell-core/src/endpoint_status.rs @@ -84,7 +84,7 @@ pub fn initial_endpoint_status( ports, path: path.to_string(), last_result: crate::proto::EndpointResult::NoObservedExchange.into(), - last_reported_at: String::new(), + last_reported_time: None, } } @@ -672,7 +672,7 @@ mod tests { descriptor.last_result, crate::proto::EndpointResult::NoObservedExchange as i32, ); - assert!(descriptor.last_reported_at.is_empty()); + assert!(descriptor.last_reported_time.is_none()); let reconstructed = endpoint( &descriptor.host, diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index a17edbdee2..c0504d57e5 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -1093,7 +1093,7 @@ mod tests { gateway_host: "gateway.example.com".to_string(), gateway_port: 443, host_key_fingerprint: String::new(), - expires_at_ms: 0, + expiration_time: None, } } diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index af40578159..f9274701bd 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -33,6 +33,7 @@ use crate::proto::{ UpdateConfigRequest, open_shell_client::OpenShellClient, workspace_selector, }; use crate::sandbox_env; +use crate::time::{duration_to_std, timestamp_to_millis}; use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_extension_core::{BearerTokenSlot, ExtensionCredentialStore}; use tonic::Status; @@ -122,7 +123,13 @@ fn validate_sandbox_refresh( ) -> std::result::Result { let token = crate::jwt::SecretJwt::parse(response.sandbox_token.clone())?; let credential_epoch = crate::jwt::CredentialEpoch::new(response.credential_epoch)?; - let expires_at = response.sandbox_expires_at_ms / 1000; + let expiration_time = response + .sandbox_expiration_time + .as_ref() + .ok_or(crate::jwt::SessionJwtError::InvalidLifetime)?; + crate::time::validate_timestamp(expiration_time) + .map_err(|_| crate::jwt::SessionJwtError::InvalidLifetime)?; + let expires_at = expiration_time.seconds; crate::jwt::SessionBearerTokenSlot::new(token.clone(), expires_at, credential_epoch)?; Ok(ValidatedSandboxRefresh { token, @@ -558,10 +565,11 @@ async fn refresh_extension_credentials_with_client( "gateway returned an unexpected or duplicate extension credential" )); } - validated.insert( - credential.service_name, - (credential.token, credential.expires_at_ms), - ); + let expiration_time = credential.expiration_time.as_ref().ok_or_else(|| { + miette::miette!("gateway returned an extension credential without an expiration time") + })?; + let expires_at_ms = timestamp_to_millis(expiration_time).into_diagnostic()?; + validated.insert(credential.service_name, (credential.token, expires_at_ms)); } if validated.len() != expected.len() { return Err(miette::miette!( @@ -657,10 +665,47 @@ mod auth_tests { #[cfg(feature = "jwt")] #[test] - fn sandbox_refresh_validation_rejects_invalid_lifetime_before_installation() { + fn sandbox_refresh_validation_rejects_epoch_expiration() { + let response = crate::proto::RefreshSandboxTokenResponse { + sandbox_token: "sandbox-token".to_string(), + sandbox_expiration_time: Some(prost_types::Timestamp { + seconds: 0, + nanos: 0, + }), + credential_epoch: 2, + ..Default::default() + }; + + assert_eq!( + validate_sandbox_refresh(&response).err(), + Some(crate::jwt::SessionJwtError::InvalidLifetime) + ); + } + + #[cfg(feature = "jwt")] + #[test] + fn sandbox_refresh_validation_rejects_missing_expiration() { + let response = crate::proto::RefreshSandboxTokenResponse { + sandbox_token: "sandbox-token".to_string(), + credential_epoch: 2, + ..Default::default() + }; + + assert_eq!( + validate_sandbox_refresh(&response).err(), + Some(crate::jwt::SessionJwtError::InvalidLifetime) + ); + } + + #[cfg(feature = "jwt")] + #[test] + fn sandbox_refresh_validation_rejects_malformed_expiration() { let response = crate::proto::RefreshSandboxTokenResponse { sandbox_token: "sandbox-token".to_string(), - sandbox_expires_at_ms: 999, + sandbox_expiration_time: Some(prost_types::Timestamp { + seconds: 1, + nanos: -1, + }), credential_epoch: 2, ..Default::default() }; @@ -671,6 +716,23 @@ mod auth_tests { ); } + #[cfg(feature = "jwt")] + #[test] + fn sandbox_refresh_validation_accepts_canonical_fractional_expiration() { + let response = crate::proto::RefreshSandboxTokenResponse { + sandbox_token: "sandbox-token".to_string(), + sandbox_expiration_time: Some(prost_types::Timestamp { + seconds: 1_900_000_000, + nanos: 500_000_000, + }), + credential_epoch: 2, + ..Default::default() + }; + + let refresh = validate_sandbox_refresh(&response).expect("valid refresh"); + assert_eq!(refresh.expires_at, 1_900_000_000); + } + #[test] fn parse_jwt_exp_reads_unsigned_payload() { use base64::Engine as _; @@ -961,10 +1023,19 @@ pub async fn fetch_provider_environment( .into_diagnostic()?; let inner = response.into_inner(); + let credential_expires_at_ms = inner + .credential_expiration_times + .iter() + .map(|(name, expiration_time)| { + timestamp_to_millis(expiration_time) + .map(|value| (name.clone(), value)) + .into_diagnostic() + }) + .collect::>>()?; Ok(ProviderEnvironmentResult { environment: inner.environment, provider_env_revision: inner.provider_env_revision, - credential_expires_at_ms: inner.credential_expires_at_ms, + credential_expires_at_ms, dynamic_credentials: inner.dynamic_credentials, static_credential_bindings: inner.static_credential_bindings, non_secret_environment_keys: inner.non_secret_environment_keys, @@ -997,9 +1068,18 @@ pub async fn exchange_provider_subject_token( .await .map_err(provider_subject_token_exchange_status)?; let inner = response.into_inner(); + let expires_in = inner + .expires_after + .as_ref() + .map(duration_to_std) + .transpose() + .into_diagnostic()? + .map_or(0, |value| { + i64::try_from(value.as_secs()).unwrap_or(i64::MAX) + }); Ok(ProviderSubjectTokenExchangeResult { access_token: inner.access_token, - expires_in: inner.expires_in, + expires_in, token_type: inner.token_type, }) } diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index d1d59f2a8c..a4023bfb1c 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -190,7 +190,7 @@ impl<'a> HttpRequestView<'a> { /// operation: SupervisorMiddlewareOperation::HttpRequest as i32, /// phase: SupervisorMiddlewarePhase::PreCredentials as i32, /// max_payload_bytes: 1024, -/// timeout: String::new(), +/// request_timeout: None, /// }], /// expected_audience: String::new(), /// } diff --git a/crates/openshell-core/src/time.rs b/crates/openshell-core/src/time.rs index 15dc0c40d3..1045f2ef4a 100644 --- a/crates/openshell-core/src/time.rs +++ b/crates/openshell-core/src/time.rs @@ -3,7 +3,35 @@ //! Time utilities shared across `OpenShell` crates. -use std::time::{SystemTime, UNIX_EPOCH}; +use prost_types::{Duration as ProtoDuration, Timestamp}; +use std::cmp::Ordering; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +/// Earliest second accepted by `google.protobuf.Timestamp` (0001-01-01 UTC). +pub const MIN_TIMESTAMP_SECONDS: i64 = -62_135_596_800; +/// Latest second accepted by `google.protobuf.Timestamp` (9999-12-31T23:59:59 UTC). +pub const MAX_TIMESTAMP_SECONDS: i64 = 253_402_300_799; +/// Largest absolute seconds component accepted by `google.protobuf.Duration`. +pub const MAX_DURATION_SECONDS: i64 = 315_576_000_000; + +/// Error returned when a protobuf well-known time value is not canonical or +/// cannot be represented by the requested Rust type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ProtoTimeError { + #[error("timestamp seconds are outside the protobuf range")] + TimestampOutOfRange, + #[error("timestamp nanos must be between 0 and 999999999")] + InvalidTimestampNanos, + #[error("duration seconds are outside the protobuf range")] + DurationOutOfRange, + #[error("duration nanos are outside the protobuf range or have a different sign than seconds")] + InvalidDurationNanos, + #[error("negative protobuf duration cannot be represented by std::time::Duration")] + NegativeDuration, + #[error("time conversion overflowed the destination type")] + Overflow, +} /// Return the current Unix timestamp in milliseconds, saturating to [`i64::MAX`] /// on overflow. Returns `0` if the system clock is before the Unix epoch. @@ -14,3 +42,236 @@ pub fn now_ms() -> i64 { .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) } + +/// Validate a protobuf timestamp's range and canonical nanosecond component. +pub fn validate_timestamp(value: &Timestamp) -> Result<(), ProtoTimeError> { + if !(MIN_TIMESTAMP_SECONDS..=MAX_TIMESTAMP_SECONDS).contains(&value.seconds) { + return Err(ProtoTimeError::TimestampOutOfRange); + } + if !(0..1_000_000_000).contains(&value.nanos) { + return Err(ProtoTimeError::InvalidTimestampNanos); + } + Ok(()) +} + +/// Compare two canonical protobuf timestamps without reducing their precision. +pub fn compare_timestamps(left: &Timestamp, right: &Timestamp) -> Result { + validate_timestamp(left)?; + validate_timestamp(right)?; + Ok((left.seconds, left.nanos).cmp(&(right.seconds, right.nanos))) +} + +/// Convert Unix epoch milliseconds to a canonical protobuf timestamp. +pub fn timestamp_from_millis(value: i64) -> Result { + let timestamp = Timestamp { + seconds: value.div_euclid(1_000), + nanos: i32::try_from(value.rem_euclid(1_000) * 1_000_000) + .map_err(|_| ProtoTimeError::Overflow)?, + }; + validate_timestamp(×tamp)?; + Ok(timestamp) +} + +/// Convert a legacy timestamp where zero meant "unset" to WKT presence. +pub fn optional_timestamp_from_legacy_millis( + value: i64, +) -> Result, ProtoTimeError> { + if value == 0 { + Ok(None) + } else { + timestamp_from_millis(value).map(Some) + } +} + +/// Convert a protobuf timestamp to Unix epoch milliseconds. +/// +/// Nanoseconds finer than one millisecond are truncated toward the start of the +/// represented second. New protobuf-facing code should retain the `Timestamp` +/// instead of using this compatibility helper. +pub fn timestamp_to_millis(value: &Timestamp) -> Result { + validate_timestamp(value)?; + value + .seconds + .checked_mul(1_000) + .and_then(|seconds| seconds.checked_add(i64::from(value.nanos / 1_000_000))) + .ok_or(ProtoTimeError::Overflow) +} + +/// Convert a Rust system time to a protobuf timestamp without losing nanos. +pub fn timestamp_from_system_time(value: SystemTime) -> Result { + let timestamp = match value.duration_since(UNIX_EPOCH) { + Ok(after_epoch) => Timestamp { + seconds: i64::try_from(after_epoch.as_secs()).map_err(|_| ProtoTimeError::Overflow)?, + nanos: i32::try_from(after_epoch.subsec_nanos()) + .map_err(|_| ProtoTimeError::Overflow)?, + }, + Err(before_epoch) => { + let duration = before_epoch.duration(); + let seconds = + i64::try_from(duration.as_secs()).map_err(|_| ProtoTimeError::Overflow)?; + if duration.subsec_nanos() == 0 { + Timestamp { + seconds: -seconds, + nanos: 0, + } + } else { + Timestamp { + seconds: seconds + .checked_neg() + .and_then(|v| v.checked_sub(1)) + .ok_or(ProtoTimeError::Overflow)?, + nanos: i32::try_from(1_000_000_000 - duration.subsec_nanos()) + .map_err(|_| ProtoTimeError::Overflow)?, + } + } + } + }; + validate_timestamp(×tamp)?; + Ok(timestamp) +} + +/// Convert a protobuf timestamp to `SystemTime` without losing nanos. +pub fn system_time_from_timestamp(value: &Timestamp) -> Result { + validate_timestamp(value)?; + let nanos = u32::try_from(value.nanos).map_err(|_| ProtoTimeError::Overflow)?; + if value.seconds >= 0 { + let seconds = u64::try_from(value.seconds).map_err(|_| ProtoTimeError::Overflow)?; + UNIX_EPOCH + .checked_add(Duration::new(seconds, nanos)) + .ok_or(ProtoTimeError::Overflow) + } else if value.nanos == 0 { + UNIX_EPOCH + .checked_sub(Duration::from_secs(value.seconds.unsigned_abs())) + .ok_or(ProtoTimeError::Overflow) + } else { + let seconds_before = value + .seconds + .unsigned_abs() + .checked_sub(1) + .ok_or(ProtoTimeError::Overflow)?; + UNIX_EPOCH + .checked_sub(Duration::new(seconds_before, 1_000_000_000 - nanos)) + .ok_or(ProtoTimeError::Overflow) + } +} + +/// Validate a protobuf duration's range and canonical sign relationship. +pub fn validate_duration(value: &ProtoDuration) -> Result<(), ProtoTimeError> { + if !(-MAX_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&value.seconds) { + return Err(ProtoTimeError::DurationOutOfRange); + } + if !(-999_999_999..=999_999_999).contains(&value.nanos) + || (value.seconds > 0 && value.nanos < 0) + || (value.seconds < 0 && value.nanos > 0) + { + return Err(ProtoTimeError::InvalidDurationNanos); + } + Ok(()) +} + +/// Convert a nonnegative Rust duration to a protobuf duration. +pub fn duration_from_std(value: Duration) -> Result { + let duration = ProtoDuration { + seconds: i64::try_from(value.as_secs()).map_err(|_| ProtoTimeError::Overflow)?, + nanos: i32::try_from(value.subsec_nanos()).map_err(|_| ProtoTimeError::Overflow)?, + }; + validate_duration(&duration)?; + Ok(duration) +} + +/// Convert a nonnegative protobuf duration to a Rust duration. +pub fn duration_to_std(value: &ProtoDuration) -> Result { + validate_duration(value)?; + if value.seconds < 0 || value.nanos < 0 { + return Err(ProtoTimeError::NegativeDuration); + } + let seconds = u64::try_from(value.seconds).map_err(|_| ProtoTimeError::Overflow)?; + let nanos = u32::try_from(value.nanos).map_err(|_| ProtoTimeError::Overflow)?; + Ok(Duration::new(seconds, nanos)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_zero_timestamp_is_absent() { + assert_eq!(optional_timestamp_from_legacy_millis(0), Ok(None)); + } + + #[test] + fn negative_millis_produce_canonical_timestamp() { + assert_eq!( + timestamp_from_millis(-1).unwrap(), + Timestamp { + seconds: -1, + nanos: 999_000_000, + } + ); + } + + #[test] + fn system_time_round_trip_preserves_sub_millisecond_precision() { + let original = UNIX_EPOCH - Duration::new(12, 345_678_901); + let timestamp = timestamp_from_system_time(original).unwrap(); + assert_eq!(system_time_from_timestamp(×tamp), Ok(original)); + } + + #[test] + fn timestamp_validation_rejects_invalid_range_and_nanos() { + assert_eq!( + validate_timestamp(&Timestamp { + seconds: MAX_TIMESTAMP_SECONDS + 1, + nanos: 0, + }), + Err(ProtoTimeError::TimestampOutOfRange) + ); + assert_eq!( + validate_timestamp(&Timestamp { + seconds: 0, + nanos: -1, + }), + Err(ProtoTimeError::InvalidTimestampNanos) + ); + } + + #[test] + fn timestamp_comparison_preserves_nanoseconds() { + let earlier = Timestamp { + seconds: 1, + nanos: 100, + }; + let later = Timestamp { + seconds: 1, + nanos: 900, + }; + + assert_eq!(compare_timestamps(&earlier, &later), Ok(Ordering::Less)); + assert_eq!(compare_timestamps(&later, &earlier), Ok(Ordering::Greater)); + } + + #[test] + fn duration_round_trip_preserves_nanos() { + let original = Duration::new(42, 123_456_789); + let proto = duration_from_std(original).unwrap(); + assert_eq!(duration_to_std(&proto), Ok(original)); + } + + #[test] + fn duration_validation_rejects_mixed_signs_and_negative_std_conversion() { + assert_eq!( + validate_duration(&ProtoDuration { + seconds: 1, + nanos: -1, + }), + Err(ProtoTimeError::InvalidDurationNanos) + ); + assert_eq!( + duration_to_std(&ProtoDuration { + seconds: 0, + nanos: -1, + }), + Err(ProtoTimeError::NegativeDuration) + ); + } +} diff --git a/crates/openshell-driver-db-credstore/src/lib.rs b/crates/openshell-driver-db-credstore/src/lib.rs index c9813870b2..85055a8862 100644 --- a/crates/openshell-driver-db-credstore/src/lib.rs +++ b/crates/openshell-driver-db-credstore/src/lib.rs @@ -304,7 +304,7 @@ impl DbCredstoreCredentialDriver { Ok::<_, Status>(ResolvedCredential { request_id: request.request_id, value, - expires_at_ms: 0, + expiration_time: None, }) }); futures::future::try_join_all(futures).await diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index b3f5f337a7..89d77b3d9d 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -2544,7 +2544,10 @@ impl DockerComputeDriver { self.publish_platform_event( sandbox_id.to_string(), DriverPlatformEvent { - timestamp_ms: openshell_core::time::now_ms(), + event_time: openshell_core::time::timestamp_from_millis( + openshell_core::time::now_ms(), + ) + .ok(), source: "docker".to_string(), r#type: "Normal".to_string(), reason: reason.to_string(), @@ -3392,7 +3395,7 @@ fn provisioning_condition() -> DriverCondition { status: "False".to_string(), reason: "Starting".to_string(), message: "Docker container is starting".to_string(), - last_transition_time: String::new(), + transition_time: None, } } @@ -3402,7 +3405,7 @@ fn error_condition(reason: &str, message: &str) -> DriverCondition { status: "False".to_string(), reason: reason.to_string(), message: message.to_string(), - last_transition_time: String::new(), + transition_time: None, } } @@ -3428,7 +3431,8 @@ fn platform_event( message: String, ) -> DriverPlatformEvent { DriverPlatformEvent { - timestamp_ms: openshell_core::time::now_ms(), + event_time: openshell_core::time::timestamp_from_millis(openshell_core::time::now_ms()) + .ok(), source: source.to_string(), r#type: event_type.to_string(), reason: reason.to_string(), @@ -3456,7 +3460,8 @@ fn docker_pull_progress_event(image: &str, info: &CreateImageInfo) -> Option DriverSandbox { status: "False".to_string(), reason: reason.to_string(), message: "Container exited".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() diff --git a/crates/openshell-driver-kubernetes-secrets/src/lib.rs b/crates/openshell-driver-kubernetes-secrets/src/lib.rs index 91224212bd..ae733d62e7 100644 --- a/crates/openshell-driver-kubernetes-secrets/src/lib.rs +++ b/crates/openshell-driver-kubernetes-secrets/src/lib.rs @@ -329,7 +329,7 @@ impl KubernetesSecretsCredentialDriver { Ok::<_, Status>(ResolvedCredential { request_id: request.request_id, value, - expires_at_ms: 0, + expiration_time: None, }) }); futures::future::try_join_all(futures).await diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index b1bee60a9a..c9e2682983 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -4989,7 +4989,7 @@ fn mark_sandbox_runtime_bootstrapping(sandbox: &mut Sandbox) { status: "True".to_string(), reason: "SandboxRuntimeGenerationStarting".to_string(), message: "replacement sandbox-runtime generation is starting".to_string(), - last_transition_time: String::new(), + transition_time: None, }); } mark_sandbox_runtime_control_unavailable(sandbox); @@ -5015,7 +5015,7 @@ fn mark_sandbox_runtime_control_unavailable(sandbox: &mut Sandbox) { status: "False".to_string(), reason: REASON.to_string(), message: MESSAGE.to_string(), - last_transition_time: String::new(), + transition_time: None, }); } } @@ -5103,7 +5103,7 @@ fn map_kube_event_to_platform( Some(( sandbox_id, PlatformEvent { - timestamp_ms: ts, + event_time: openshell_core::time::timestamp_from_millis(ts).ok(), source: "kubernetes".to_string(), r#type: obj.type_.clone().unwrap_or_default(), reason: obj.reason.clone().unwrap_or_default(), @@ -6549,11 +6549,10 @@ fn condition_from_value(value: &serde_json::Value) -> Option { .and_then(|val| val.as_str()) .unwrap_or_default() .to_string(), - last_transition_time: obj + transition_time: obj .get("lastTransitionTime") .and_then(|val| val.as_str()) - .unwrap_or_default() - .to_string(), + .and_then(|value| value.parse().ok()), }) } diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index b0264c0e2b..708aa3ae90 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -179,7 +179,7 @@ fn platform_event(sandbox_id: String, reason: &str, message: String) -> WatchSan WatchSandboxesPlatformEvent { sandbox_id, event: Some(DriverPlatformEvent { - timestamp_ms: 0, + event_time: None, source: "mxc-driver".into(), r#type: "Warning".into(), reason: reason.to_string(), @@ -582,7 +582,7 @@ impl MxcComputeBackend { status: "False".into(), reason: "Starting".into(), message: "MXC lifecycle starting".into(), - last_transition_time: String::new(), + transition_time: None, }, false, ); @@ -682,7 +682,7 @@ impl MxcComputeBackend { status: "False".into(), reason: "Stopped".into(), message: "MXC sandbox stopped".into(), - last_transition_time: String::new(), + transition_time: None, }, false, ); @@ -1000,7 +1000,7 @@ async fn run_lifecycle( status: "True".into(), reason: "AgentRunning".into(), message: format!("Agent exec launched: {command_line}"), - last_transition_time: String::new(), + transition_time: None, }, false, ); @@ -1109,7 +1109,7 @@ async fn monitor_exec( status: "True".into(), reason: "AgentCompleted".into(), message: "Agent exec finished successfully (exit code 0)".into(), - last_transition_time: String::new(), + transition_time: None, }, false, ); @@ -1137,7 +1137,7 @@ async fn monitor_exec( status: "False".into(), reason: "ExecFailed".into(), message: format!("Agent exec exited {code}"), - last_transition_time: String::new(), + transition_time: None, }, false, ); @@ -1170,7 +1170,7 @@ async fn set_failed( status: "False".into(), reason: "ProvisionFailed".into(), message: message.to_string(), - last_transition_time: String::new(), + transition_time: None, }, false, ); diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 6e5eb52f27..3ddf85b5f5 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -338,7 +338,7 @@ async fn map_podman_event( status: "Unknown".to_string(), reason: "InspectFailed".to_string(), message: format!("Container inspect failed: {e}"), - last_transition_time: String::new(), + transition_time: None, }, false, ))) @@ -495,7 +495,7 @@ pub fn driver_sandbox_from_list_entry(entry: &ContainerListEntry) -> Option DriverCondition { ), }; - // Use Podman's state timestamps for last_transition_time: + // Use Podman's state timestamps for transition_time: // - Running/healthy states use started_at // - Stopped/exited states use finished_at - let last_transition_time = match state.status.as_str() { + let transition_time = match state.status.as_str() { "running" => state.started_at.clone().unwrap_or_default(), "exited" | "stopped" => state.finished_at.clone().unwrap_or_default(), _ => String::new(), - }; + } + .parse() + .ok(); DriverCondition { r#type: "Ready".to_string(), status: status_val.to_string(), reason: reason.to_string(), message, - last_transition_time, + transition_time, } } @@ -719,7 +721,7 @@ mod tests { assert_eq!(cond.r#type, "Ready"); assert_eq!(cond.status, "True"); assert_eq!(cond.reason, "HealthCheckPassed"); - assert_eq!(cond.last_transition_time, "2026-04-14T10:00:00Z"); + assert_eq!(cond.transition_time, "2026-04-14T10:00:00Z".parse().ok()); } #[test] @@ -738,7 +740,7 @@ mod tests { assert_eq!(cond.status, "True"); assert_eq!(cond.reason, CONDITION_RUNNING); assert_eq!(cond.message, "Container is running"); - assert_eq!(cond.last_transition_time, "2026-04-14T10:00:00Z"); + assert_eq!(cond.transition_time, "2026-04-14T10:00:00Z".parse().ok()); } #[test] @@ -774,7 +776,7 @@ mod tests { let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); assert_eq!(cond.reason, "OOMKilled"); - assert_eq!(cond.last_transition_time, "2026-04-14T11:00:00Z"); + assert_eq!(cond.transition_time, "2026-04-14T11:00:00Z".parse().ok()); } #[test] @@ -879,7 +881,7 @@ mod tests { status: "Unknown".to_string(), reason: "InspectFailed".to_string(), message: "Container inspect failed: connection refused".to_string(), - last_transition_time: String::new(), + transition_time: None, }; let sandbox = DriverSandbox { diff --git a/crates/openshell-driver-vault/src/lib.rs b/crates/openshell-driver-vault/src/lib.rs index 1041ce5f60..4ae5c9c8ad 100644 --- a/crates/openshell-driver-vault/src/lib.rs +++ b/crates/openshell-driver-vault/src/lib.rs @@ -284,7 +284,7 @@ impl VaultCredentialDriver { Ok::<_, Status>(ResolvedCredential { request_id, value, - expires_at_ms: 0, + expiration_time: None, }) } }); diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index a9ab72b2c5..1ad10f654b 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -6758,7 +6758,7 @@ fn provisioning_condition() -> SandboxCondition { status: "False".to_string(), reason: "Starting".to_string(), message: "VM is starting".to_string(), - last_transition_time: String::new(), + transition_time: None, } } @@ -6768,7 +6768,7 @@ fn deleting_condition() -> SandboxCondition { status: "False".to_string(), reason: "Deleting".to_string(), message: "Sandbox is being deleted".to_string(), - last_transition_time: String::new(), + transition_time: None, } } @@ -6778,7 +6778,7 @@ fn stopped_condition() -> SandboxCondition { status: "True".to_string(), reason: "ComputeStopped".to_string(), message: "VM compute is stopped and persistent state is retained".to_string(), - last_transition_time: String::new(), + transition_time: None, } } @@ -6788,13 +6788,14 @@ fn error_condition(reason: &str, message: &str) -> SandboxCondition { status: "False".to_string(), reason: reason.to_string(), message: message.to_string(), - last_transition_time: String::new(), + transition_time: None, } } fn platform_event(source: &str, event_type: &str, reason: &str, message: String) -> PlatformEvent { let mut event = PlatformEvent { - timestamp_ms: openshell_core::time::now_ms(), + event_time: openshell_core::time::timestamp_from_millis(openshell_core::time::now_ms()) + .ok(), source: source.to_string(), r#type: event_type.to_string(), reason: reason.to_string(), diff --git a/crates/openshell-providers/Cargo.toml b/crates/openshell-providers/Cargo.toml index abf6a6f11a..89a00bd47c 100644 --- a/crates/openshell-providers/Cargo.toml +++ b/crates/openshell-providers/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +prost-types = { workspace = true } glob = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false } openshell-policy = { path = "../openshell-policy" } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index ba78a6c556..1ef224bd09 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -121,7 +121,66 @@ pub struct CredentialProfile { pub token_grant: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +/// Origin-aware protobuf duration presence retained across profile conversion. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[doc(hidden)] +pub enum ProfileDurationWkt { + /// The profile came from YAML and uses the compatibility seconds field. + #[default] + FromYaml, + /// The protobuf duration was absent. + Absent, + /// The protobuf duration was present, including an explicit zero. + Present(prost_types::Duration), +} + +impl ProfileDurationWkt { + fn from_proto(value: Option) -> Self { + value.map_or(Self::Absent, Self::Present) + } + + pub fn to_proto(self, legacy_seconds: i64) -> Option { + match self { + Self::FromYaml => (legacy_seconds != 0).then_some(prost_types::Duration { + seconds: legacy_seconds, + nanos: 0, + }), + Self::Absent => None, + Self::Present(value) => Some(value), + } + } +} + +impl Serialize for ProfileDurationWkt { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Present(value) => serializer.serialize_str(&value.to_string()), + Self::FromYaml | Self::Absent => serializer.serialize_none(), + } + } +} + +impl<'de> Deserialize<'de> for ProfileDurationWkt { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value + .parse::() + .map(Self::Present) + .map_err(de::Error::custom) + } +} + +fn profile_duration_is_legacy_or_absent(value: &ProfileDurationWkt) -> bool { + !matches!(value, ProfileDurationWkt::Present(_)) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct TokenGrantProfile { #[serde( default = "default_token_grant_type", @@ -141,6 +200,14 @@ pub struct TokenGrantProfile { pub scopes: Vec, #[serde(default, skip_serializing_if = "is_zero_i64")] pub cache_ttl_seconds: i64, + /// Exact protobuf value retained for lossless gRPC import/export. + #[serde( + default, + rename = "cache_ttl", + skip_serializing_if = "profile_duration_is_legacy_or_absent" + )] + #[doc(hidden)] + pub cache_ttl_wkt: ProfileDurationWkt, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub audience_overrides: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -149,6 +216,23 @@ pub struct TokenGrantProfile { pub requested_token_type: String, } +impl PartialEq for TokenGrantProfile { + fn eq(&self, other: &Self) -> bool { + self.grant_type == other.grant_type + && self.token_endpoint == other.token_endpoint + && self.audience == other.audience + && self.jwt_svid_audience == other.jwt_svid_audience + && self.client_assertion_type == other.client_assertion_type + && self.scopes == other.scopes + && self.cache_ttl_seconds == other.cache_ttl_seconds + && self.audience_overrides == other.audience_overrides + && self.subject_token == other.subject_token + && self.requested_token_type == other.requested_token_type + } +} + +impl Eq for TokenGrantProfile {} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct TokenGrantSubjectTokenProfile { pub source: String, @@ -170,7 +254,7 @@ pub struct TokenGrantAudienceOverrideProfile { pub scopes: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CredentialRefreshProfile { #[serde( default = "default_refresh_strategy", @@ -184,8 +268,24 @@ pub struct CredentialRefreshProfile { pub scopes: Vec, #[serde(default, skip_serializing_if = "is_zero_i64")] pub refresh_before_seconds: i64, + /// Exact protobuf value retained for lossless gRPC import/export. + #[serde( + default, + rename = "refresh_before", + skip_serializing_if = "profile_duration_is_legacy_or_absent" + )] + #[doc(hidden)] + pub refresh_before_wkt: ProfileDurationWkt, #[serde(default, skip_serializing_if = "is_zero_i64")] pub max_lifetime_seconds: i64, + /// Exact protobuf value retained for lossless gRPC import/export. + #[serde( + default, + rename = "max_lifetime", + skip_serializing_if = "profile_duration_is_legacy_or_absent" + )] + #[doc(hidden)] + pub max_lifetime_wkt: ProfileDurationWkt, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub material: Vec, /// Additional credentials this refresh mints beyond its primary credential. @@ -195,6 +295,20 @@ pub struct CredentialRefreshProfile { pub additional_outputs: Vec, } +impl PartialEq for CredentialRefreshProfile { + fn eq(&self, other: &Self) -> bool { + self.strategy == other.strategy + && self.token_url == other.token_url + && self.scopes == other.scopes + && self.refresh_before_seconds == other.refresh_before_seconds + && self.max_lifetime_seconds == other.max_lifetime_seconds + && self.material == other.material + && self.additional_outputs == other.additional_outputs + } +} + +impl Eq for CredentialRefreshProfile {} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct CredentialRefreshMaterialProfile { pub name: String, @@ -1261,8 +1375,10 @@ fn credential_refresh_from_proto(refresh: &ProviderCredentialRefresh) -> Credent .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified), token_url: refresh.token_url.clone(), scopes: refresh.scopes.clone(), - refresh_before_seconds: refresh.refresh_before_seconds, - max_lifetime_seconds: refresh.max_lifetime_seconds, + refresh_before_seconds: 0, + refresh_before_wkt: ProfileDurationWkt::from_proto(refresh.refresh_before), + max_lifetime_seconds: 0, + max_lifetime_wkt: ProfileDurationWkt::from_proto(refresh.max_lifetime), material: refresh .material .iter() @@ -1284,13 +1400,32 @@ fn credential_refresh_from_proto(refresh: &ProviderCredentialRefresh) -> Credent } } +fn profile_duration_to_proto( + exact: ProfileDurationWkt, + seconds: i64, +) -> Option { + exact.to_proto(seconds) +} + +fn validate_profile_duration(value: &prost_types::Duration) -> Result<(), String> { + openshell_core::time::duration_to_std(value) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + fn credential_refresh_to_proto(refresh: &CredentialRefreshProfile) -> ProviderCredentialRefresh { ProviderCredentialRefresh { strategy: refresh.strategy as i32, token_url: refresh.token_url.clone(), scopes: refresh.scopes.clone(), - refresh_before_seconds: refresh.refresh_before_seconds, - max_lifetime_seconds: refresh.max_lifetime_seconds, + refresh_before: profile_duration_to_proto( + refresh.refresh_before_wkt, + refresh.refresh_before_seconds, + ), + max_lifetime: profile_duration_to_proto( + refresh.max_lifetime_wkt, + refresh.max_lifetime_seconds, + ), material: refresh .material .iter() @@ -1325,7 +1460,8 @@ fn token_grant_from_proto( jwt_svid_audience: token_grant.jwt_svid_audience.clone(), client_assertion_type: token_grant.client_assertion_type.clone(), scopes: token_grant.scopes.clone(), - cache_ttl_seconds: token_grant.cache_ttl_seconds, + cache_ttl_seconds: 0, + cache_ttl_wkt: ProfileDurationWkt::from_proto(token_grant.cache_ttl), audience_overrides: token_grant .audience_overrides .iter() @@ -1349,7 +1485,10 @@ fn token_grant_to_proto( jwt_svid_audience: token_grant.jwt_svid_audience.clone(), client_assertion_type: token_grant.client_assertion_type.clone(), scopes: token_grant.scopes.clone(), - cache_ttl_seconds: token_grant.cache_ttl_seconds, + cache_ttl: profile_duration_to_proto( + token_grant.cache_ttl_wkt, + token_grant.cache_ttl_seconds, + ), audience_overrides: token_grant .audience_overrides .iter() @@ -2068,7 +2207,28 @@ pub fn validate_profile_set( "refresh strategy is required", )); } - if refresh.refresh_before_seconds < 0 { + if matches!(refresh.refresh_before_wkt, ProfileDurationWkt::Present(_)) + && refresh.refresh_before_seconds != 0 + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.refresh.refresh_before", + "refresh_before and refresh_before_seconds cannot both be set", + )); + } + if let ProfileDurationWkt::Present(value) = refresh.refresh_before_wkt + && let Err(error) = validate_profile_duration(&value) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.refresh.refresh_before", + format!("refresh_before must be a valid non-negative duration: {error}"), + )); + } else if matches!(refresh.refresh_before_wkt, ProfileDurationWkt::FromYaml) + && refresh.refresh_before_seconds < 0 + { diagnostics.push(ProfileValidationDiagnostic::error( source, profile_id, @@ -2076,7 +2236,18 @@ pub fn validate_profile_set( "refresh_before_seconds must be greater than or equal to 0", )); } - if refresh.max_lifetime_seconds < 0 { + if let ProfileDurationWkt::Present(value) = refresh.max_lifetime_wkt + && let Err(error) = validate_profile_duration(&value) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.refresh.max_lifetime", + format!("max_lifetime must be a valid non-negative duration: {error}"), + )); + } else if matches!(refresh.max_lifetime_wkt, ProfileDurationWkt::FromYaml) + && refresh.max_lifetime_seconds < 0 + { diagnostics.push(ProfileValidationDiagnostic::error( source, profile_id, @@ -2084,6 +2255,30 @@ pub fn validate_profile_set( "max_lifetime_seconds must be greater than or equal to 0", )); } + if matches!(refresh.max_lifetime_wkt, ProfileDurationWkt::Present(_)) + && refresh.max_lifetime_seconds != 0 + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.refresh.max_lifetime", + "max_lifetime and max_lifetime_seconds cannot both be set", + )); + } + if matches!( + refresh.max_lifetime_wkt, + ProfileDurationWkt::Present(prost_types::Duration { + seconds: 0, + nanos: 0 + }) + ) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.refresh.max_lifetime", + "max_lifetime must be greater than zero when present", + )); + } let mut material_names = HashSet::new(); for material in &refresh.material { let name = material.name.trim(); @@ -2235,15 +2430,44 @@ pub fn validate_profile_set( } } - if let Some(token_grant) = credential.token_grant.as_ref() - && let Err(message) = validate_token_grant_endpoint(&token_grant.token_endpoint) - { - diagnostics.push(ProfileValidationDiagnostic::error( - source, - profile_id, - "credentials.token_grant.token_endpoint", - message, - )); + if let Some(token_grant) = credential.token_grant.as_ref() { + if let ProfileDurationWkt::Present(value) = token_grant.cache_ttl_wkt + && let Err(error) = validate_profile_duration(&value) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.cache_ttl", + format!("cache_ttl must be a valid non-negative duration: {error}"), + )); + } else if matches!(token_grant.cache_ttl_wkt, ProfileDurationWkt::FromYaml) + && token_grant.cache_ttl_seconds < 0 + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.cache_ttl_seconds", + "cache_ttl_seconds must be greater than or equal to 0", + )); + } + if matches!(token_grant.cache_ttl_wkt, ProfileDurationWkt::Present(_)) + && token_grant.cache_ttl_seconds != 0 + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.cache_ttl", + "cache_ttl and cache_ttl_seconds cannot both be set", + )); + } + if let Err(message) = validate_token_grant_endpoint(&token_grant.token_endpoint) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.token_endpoint", + message, + )); + } } diagnostics.extend(validate_token_grant_subject_token( source, @@ -3250,10 +3474,12 @@ mod tests { use openshell_core::proto::{ProviderCredentialTokenGrantType, ProviderProfileCategory}; use super::{ - DiscoveryProfile, EndpointProfile, L7AllowProfile, L7QueryMatcherProfile, ProfileError, - ProviderTypeProfile, builtin_profiles, is_mcp_diagnostic_field, normalize_profile_id, - parse_profile_catalog_yamls, parse_profile_json, parse_profile_yaml, profile_to_json, - profile_to_yaml, profiles_to_json, profiles_to_yaml, validate_profile_set, + DiscoveryProfile, EndpointProfile, L7AllowProfile, L7QueryMatcherProfile, + ProfileDurationWkt, ProfileError, ProviderTypeProfile, builtin_profiles, + is_mcp_diagnostic_field, normalize_profile_id, parse_profile_catalog_yamls, + parse_profile_json, parse_profile_yaml, profile_duration_to_proto, profile_to_json, + profile_to_yaml, profiles_to_json, profiles_to_yaml, token_grant_from_proto, + token_grant_to_proto, validate_profile_duration, validate_profile_set, }; fn builtin_profile(id: &str) -> &'static ProviderTypeProfile { @@ -3263,6 +3489,142 @@ mod tests { .unwrap_or_else(|| panic!("built-in profile {id} should exist")) } + #[test] + fn profile_duration_conversion_preserves_presence_and_fractional_values() { + let fractional = prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }; + assert_eq!( + profile_duration_to_proto(ProfileDurationWkt::Present(fractional), 0), + Some(fractional) + ); + assert_eq!( + profile_duration_to_proto( + ProfileDurationWkt::Present(prost_types::Duration { + seconds: 0, + nanos: 0, + }), + 0, + ), + Some(prost_types::Duration { + seconds: 0, + nanos: 0, + }) + ); + assert_eq!( + profile_duration_to_proto(ProfileDurationWkt::Absent, 60), + None + ); + assert_eq!( + profile_duration_to_proto(ProfileDurationWkt::FromYaml, 60), + Some(prost_types::Duration { + seconds: 60, + nanos: 0, + }) + ); + assert!( + validate_profile_duration(&prost_types::Duration { + seconds: 1, + nanos: -1, + }) + .is_err() + ); + + let raw_grant = openshell_core::proto::ProviderCredentialTokenGrant { + cache_ttl: Some(prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }), + ..Default::default() + }; + assert_eq!( + token_grant_to_proto(&token_grant_from_proto(&raw_grant)).cache_ttl, + raw_grant.cache_ttl + ); + + let absent_grant = openshell_core::proto::ProviderCredentialTokenGrant::default(); + assert!( + token_grant_to_proto(&token_grant_from_proto(&absent_grant)) + .cache_ttl + .is_none() + ); + } + + #[test] + fn protobuf_profile_durations_round_trip_through_yaml_exactly() { + let profile = parse_profile_yaml( + r" +id: exact-durations +display_name: Exact Durations +credentials: + - name: access_token + refresh: + strategy: static + token_grant: + token_endpoint: https://auth.example.com/token +", + ) + .unwrap(); + let mut proto = profile.to_proto(); + let credential = &mut proto.credentials[0]; + let refresh = credential.refresh.as_mut().unwrap(); + refresh.refresh_before = Some(prost_types::Duration { + seconds: 0, + nanos: 0, + }); + refresh.max_lifetime = Some(prost_types::Duration { + seconds: 1, + nanos: 500_000_000, + }); + credential.token_grant.as_mut().unwrap().cache_ttl = Some(prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }); + + let exact = ProviderTypeProfile::from_proto(&proto); + let yaml = profile_to_yaml(&exact).unwrap(); + assert!(yaml.contains("refresh_before: \"0s\""), "{yaml}"); + assert!(yaml.contains("max_lifetime: \"1.500s\""), "{yaml}"); + assert!(yaml.contains("cache_ttl: \"0.500s\""), "{yaml}"); + assert!(!yaml.contains("refresh_before_seconds")); + assert!(!yaml.contains("max_lifetime_seconds")); + assert!(!yaml.contains("cache_ttl_seconds")); + + let reparsed = parse_profile_yaml(&yaml).unwrap().to_proto(); + assert_eq!( + reparsed.credentials[0].refresh, + proto.credentials[0].refresh + ); + assert_eq!( + reparsed.credentials[0].token_grant, + proto.credentials[0].token_grant + ); + } + + #[test] + fn canonical_zero_max_lifetime_is_rejected() { + let profile = parse_profile_yaml( + r#" +id: zero-max-lifetime +display_name: Zero Max Lifetime +credentials: + - name: access_token + refresh: + strategy: oauth2_client_credentials + token_url: https://auth.example.com/token + max_lifetime: "0s" +"#, + ) + .expect("profile should parse before semantic validation"); + + let diagnostics = validate_profile_set(&[("zero.yaml".to_string(), profile)]); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.field == "credentials.refresh.max_lifetime" + && diagnostic.message.contains("greater than zero") + })); + } + #[test] fn builtin_agent_conversation_defaults_preserve_own_and_foreign_body_text() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; @@ -4472,15 +4834,15 @@ credentials: ); assert_eq!(refresh.material.len(), 2); - let from_proto = ProviderTypeProfile::from_proto(&profile.to_proto()); - assert_eq!( - from_proto.credentials[0].refresh, - profile.credentials[0].refresh - ); + let proto = profile.to_proto(); + let from_proto = ProviderTypeProfile::from_proto(&proto); + assert_eq!(from_proto.to_proto(), proto); let exported = profile_to_yaml(&from_proto).expect("yaml"); assert!(exported.contains("oauth2_client_credentials")); assert!(exported.contains("client_secret")); + let reparsed = parse_profile_yaml(&exported).expect("exported profile should parse"); + assert_eq!(reparsed.to_proto(), proto); } #[test] diff --git a/crates/openshell-sandbox/src/mechanistic_mapper.rs b/crates/openshell-sandbox/src/mechanistic_mapper.rs index 0fdc4649ba..5e13d91916 100644 --- a/crates/openshell-sandbox/src/mechanistic_mapper.rs +++ b/crates/openshell-sandbox/src/mechanistic_mapper.rs @@ -84,8 +84,14 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { for denial in denials { total_count += denial.count; - first_seen_ms = first_seen_ms.min(denial.first_seen_ms); - last_seen_ms = last_seen_ms.max(denial.last_seen_ms); + if let Some(timestamp) = denial.first_seen_time.as_ref() { + first_seen_ms = first_seen_ms + .min(openshell_core::time::timestamp_to_millis(timestamp).unwrap_or(i64::MAX)); + } + if let Some(timestamp) = denial.last_seen_time.as_ref() { + last_seen_ms = last_seen_ms + .max(openshell_core::time::timestamp_to_millis(timestamp).unwrap_or_default()); + } if denial.denial_stage == "ssrf" { is_ssrf = true; } @@ -210,13 +216,13 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { security_notes, confidence, denial_summary_ids: vec![], - created_at_ms: 0, // Set by gateway on persist - decided_at_ms: 0, + created_time: None, // Set by gateway on persist + decided_time: None, stage, supersedes_chunk_id: String::new(), hit_count: total_count.cast_signed(), - first_seen_ms, - last_seen_ms, + first_seen_time: openshell_core::time::timestamp_from_millis(first_seen_ms).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(last_seen_ms).ok(), binary: binary.clone(), validation_result: String::new(), rejection_reason: String::new(), @@ -503,8 +509,8 @@ mod tests { binary: "/usr/bin/curl".to_string(), ancestors: vec![], deny_reason: "no matching policy".to_string(), - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), count: 5, suppressed_count: 0, total_count: 5, @@ -548,8 +554,8 @@ mod tests { binary: "/usr/bin/python3".to_string(), ancestors: vec![], deny_reason: "l7 deny".to_string(), - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), count: 3, suppressed_count: 0, total_count: 3, @@ -652,8 +658,8 @@ mod tests { port: 80, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -672,8 +678,8 @@ mod tests { port: 80, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -692,8 +698,8 @@ mod tests { port: 80, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -712,8 +718,8 @@ mod tests { port: 8080, binary: "/usr/bin/curl".to_string(), count: 3, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -732,8 +738,8 @@ mod tests { port: 443, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "connect".to_string(), ..Default::default() }]; diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index c236742739..af3863cd51 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -576,9 +576,11 @@ impl OpenShellClient { command: cmd.to_vec(), workdir: opts.workdir.unwrap_or_default(), environment: opts.environment, - timeout_seconds: opts + execution_timeout: opts .timeout - .map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX)), + .map(openshell_core::time::duration_from_std) + .transpose() + .map_err(|error| SdkError::invalid_config(error.to_string()))?, stdin: opts.stdin.unwrap_or_default(), tty: false, cols: 0, @@ -1005,9 +1007,11 @@ impl WorkspaceScopedClient { command: cmd.to_vec(), workdir: opts.workdir.unwrap_or_default(), environment: opts.environment, - timeout_seconds: opts + execution_timeout: opts .timeout - .map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX)), + .map(openshell_core::time::duration_from_std) + .transpose() + .map_err(|error| SdkError::invalid_config(error.to_string()))?, stdin: opts.stdin.unwrap_or_default(), tty: false, cols: 0, diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 6202f0bc2c..5e04c74257 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -18,6 +18,7 @@ use openshell_sdk::{ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; use tokio::net::TcpListener; use tokio::sync::Mutex; use tokio_stream::wrappers::TcpListenerStream; @@ -93,11 +94,11 @@ fn sandbox_with_phase_ws( metadata: Some(proto::datamodel::v1::ObjectMeta { id: format!("id-{name}"), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), annotations: HashMap::new(), resource_version: 1, - deletion_timestamp_ms: 0, + deletion_time: None, workspace: workspace.to_string(), }), spec: None, @@ -114,11 +115,11 @@ fn workspace_proto(name: &str, phase: proto::datamodel::v1::WorkspacePhase) -> p metadata: Some(proto::datamodel::v1::ObjectMeta { id: format!("ws-{name}"), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 1, - deletion_timestamp_ms: 0, + deletion_time: None, workspace: String::new(), }), status: Some(proto::datamodel::v1::WorkspaceStatus { @@ -132,11 +133,11 @@ fn workload_template_proto(name: &str, workspace: &str) -> proto::SandboxWorkloa metadata: Some(proto::datamodel::v1::ObjectMeta { id: format!("template-{workspace}-{name}"), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 1, - deletion_timestamp_ms: 0, + deletion_time: None, workspace: workspace.to_string(), }), spec: Some(proto::SandboxWorkloadTemplateSpec { @@ -1222,7 +1223,7 @@ async fn wait_ready_transitions_through_phases() { let client = connect(&endpoint).await; let sandbox = client - .wait_ready("my-box", std::time::Duration::from_secs(5)) + .wait_ready("my-box", Duration::from_secs(5)) .await .unwrap(); assert_eq!(sandbox.phase, SandboxPhase::Ready); @@ -1242,7 +1243,7 @@ async fn wait_ready_accepts_successful_completion() { let client = connect(&endpoint).await; let sandbox = client - .wait_ready("short-job", std::time::Duration::from_secs(5)) + .wait_ready("short-job", Duration::from_secs(5)) .await .unwrap(); assert_eq!(sandbox.phase, SandboxPhase::Completed); @@ -1258,7 +1259,7 @@ async fn wait_ready_surfaces_stopped_phase_without_timing_out() { let client = connect(&endpoint).await; let err = client - .wait_ready("failed-job", std::time::Duration::from_secs(5)) + .wait_ready("failed-job", Duration::from_secs(5)) .await .unwrap_err(); assert_eq!(err.code(), "connect"); @@ -1274,7 +1275,7 @@ async fn wait_ready_surfaces_error_phase() { let client = connect(&endpoint).await; let err = client - .wait_ready("my-box", std::time::Duration::from_secs(5)) + .wait_ready("my-box", Duration::from_secs(5)) .await .unwrap_err(); assert_eq!(err.code(), "connect"); @@ -1291,7 +1292,7 @@ async fn wait_deleted_returns_when_get_reports_not_found() { let client = connect(&endpoint).await; client - .wait_deleted("my-box", std::time::Duration::from_secs(5)) + .wait_deleted("my-box", Duration::from_secs(5)) .await .unwrap(); assert!(state.get_calls.load(Ordering::SeqCst) >= 3); @@ -1325,7 +1326,7 @@ async fn exec_buffers_stdout_stderr_and_exit() { &["echo".to_string(), "hello".to_string()], ExecOptions { workdir: Some("/work".to_string()), - timeout: Some(std::time::Duration::from_secs(10)), + timeout: Some(Duration::from_secs(10)), ..Default::default() }, ) @@ -1343,7 +1344,13 @@ async fn exec_buffers_stdout_stderr_and_exit() { vec!["echo".to_string(), "hello".to_string()] ); assert_eq!(observed.workdir, "/work"); - assert_eq!(observed.timeout_seconds, 10); + assert_eq!( + observed + .execution_timeout + .as_ref() + .and_then(|value| openshell_core::time::duration_to_std(value).ok()), + Some(Duration::from_secs(10)) + ); } /// Refresher that hands out a fixed "fresh-token" and counts invocations. diff --git a/crates/openshell-server/proto/storage.proto b/crates/openshell-server/proto/storage.proto index 2ca7946f83..7c1d7dce0c 100644 --- a/crates/openshell-server/proto/storage.proto +++ b/crates/openshell-server/proto/storage.proto @@ -8,6 +8,7 @@ syntax = "proto3"; package openshell.storage.v1; import "datamodel.proto"; +import "google/protobuf/duration.proto"; import "openshell.proto"; import "options.proto"; import "sandbox.proto"; @@ -61,6 +62,39 @@ message StoredProviderCredentialRefreshState { int64 last_error_at_ms = 24; } +// Current durable refresh state. V1 remains frozen so pre-upgrade records can +// be decoded and transactionally rewritten into this representation. +message StoredProviderCredentialRefreshStateV2 { + reserved 15, 16; + reserved "refresh_before_seconds", "max_lifetime_seconds"; + openshell.datamodel.v1.ObjectMeta metadata = 1; + string provider_id = 2; + string provider_name = 3; + string credential_key = 4; + openshell.v1.ProviderCredentialRefreshStrategy strategy = 5; + map material = 6 [(openshell.options.v1.secret) = true]; + repeated string secret_material_keys = 7; + int64 expires_at_ms = 8; + int64 next_refresh_at_ms = 9; + int64 last_refresh_at_ms = 10; + string status = 11; + string last_error = 12; + string token_url = 13; + repeated string scopes = 14; + map additional_output_keys = 17; + string authorization_epoch = 18; + map secret_material_handles = 19; + repeated StoredRefreshMaterialDeletion pending_secret_deletions = 20; + openshell.v1.ProviderCredentialRefreshRecoveryAction recovery_action = 21; + string failure_code = 22; + string provider_error_subtype = 23; + int64 last_error_at_ms = 24; + // Absence selects the profile defaults. Explicit zero refresh_before means + // refresh at expiry. max_lifetime must be positive when present. + google.protobuf.Duration refresh_before = 115; + google.protobuf.Duration max_lifetime = 116; +} + message StoredRefreshMaterialDeletion { // Original material name used to derive the credential driver's storage key. string material_key = 1; diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index 2341cb7874..e553909d08 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -304,12 +304,12 @@ mod tests { metadata: Some(ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: subject.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), principal_subject: subject.to_string(), role: role.into(), diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 7fceec2625..43b2ea78c0 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -1531,7 +1531,7 @@ impl ComputeRuntime { status: "False".to_string(), reason: reason.clone(), message: message.clone(), - last_transition_time: String::new(), + transition_time: None, }, ); }, @@ -2636,7 +2636,7 @@ impl ComputeRuntime { status: "False".to_string(), reason: reason.clone(), message: message.clone(), - last_transition_time: String::new(), + transition_time: None, }, ); }) @@ -2676,7 +2676,7 @@ impl ComputeRuntime { status: "False".to_string(), reason: "Resumed".to_string(), message: "Sandbox recovered during gateway startup".to_string(), - last_transition_time: String::new(), + transition_time: None, }, ); }) @@ -2963,6 +2963,7 @@ impl ComputeRuntime { } async fn apply_watch_event_inner(&self, event: WatchSandboxesEvent) -> Result<(), String> { + validate_driver_watch_event_timestamps(&event)?; match event.payload { Some(watch_sandboxes_event::Payload::Sandbox(sandbox)) => { if let Some(sandbox) = sandbox.sandbox { @@ -3849,7 +3850,7 @@ impl ComputeRuntime { reason: "ComputeResourceMissing".to_string(), message: "The compute driver could not find the retained sandbox resource; delete the sandbox to clean up its remaining state" .to_string(), - last_transition_time: String::new(), + transition_time: None, }, ); }, @@ -3911,13 +3912,14 @@ impl ComputeRuntime { { Ok(response) => { let sandbox = response.into_inner().sandbox; - if let Some(sandbox) = sandbox.as_ref() - && sandbox.id != sandbox_id - { - return Err(format!( - "compute driver returned sandbox '{}' for requested id '{sandbox_id}'", - sandbox.id - )); + if let Some(sandbox) = sandbox.as_ref() { + if sandbox.id != sandbox_id { + return Err(format!( + "compute driver returned sandbox '{}' for requested id '{sandbox_id}'", + sandbox.id + )); + } + validate_driver_sandbox_timestamps(sandbox)?; } Ok(sandbox) } @@ -3927,6 +3929,41 @@ impl ComputeRuntime { } } +fn validate_driver_watch_event_timestamps(event: &WatchSandboxesEvent) -> Result<(), String> { + match &event.payload { + Some(watch_sandboxes_event::Payload::Sandbox(update)) => { + if let Some(sandbox) = update.sandbox.as_ref() { + validate_driver_sandbox_timestamps(sandbox)?; + } + } + Some(watch_sandboxes_event::Payload::PlatformEvent(platform_event)) => { + if let Some(event_time) = platform_event + .event + .as_ref() + .and_then(|event| event.event_time.as_ref()) + { + openshell_core::time::validate_timestamp(event_time) + .map_err(|error| format!("platform_event.event_time: {error}"))?; + } + } + Some(watch_sandboxes_event::Payload::Deleted(_)) | None => {} + } + Ok(()) +} + +fn validate_driver_sandbox_timestamps(sandbox: &DriverSandbox) -> Result<(), String> { + if let Some(status) = sandbox.status.as_ref() { + for (index, condition) in status.conditions.iter().enumerate() { + if let Some(transition_time) = condition.transition_time.as_ref() { + openshell_core::time::validate_timestamp(transition_time).map_err(|error| { + format!("sandbox.status.conditions[{index}].transition_time: {error}") + })?; + } + } + } + Ok(()) +} + fn apply_main_process_exit(sandbox: &mut Sandbox, instance_id: &str, exit_code: i32) { let sandbox_name = sandbox.object_name().to_string(); // A driver can observe the container exit before the supervisor's @@ -3970,7 +4007,7 @@ fn apply_main_process_exit(sandbox: &mut Sandbox, instance_id: &str, exit_code: status: "False".to_string(), reason: reason.to_string(), message, - last_transition_time: String::new(), + transition_time: None, }, ); sandbox.set_phase(phase as i32); @@ -4381,7 +4418,7 @@ fn driver_condition_from_public(condition: &SandboxCondition) -> DriverCondition status: condition.status.clone(), reason: condition.reason.clone(), message: condition.message.clone(), - last_transition_time: condition.last_transition_time.clone(), + transition_time: condition.transition_time, } } @@ -4657,7 +4694,7 @@ fn ensure_supervisor_ready_status(status: &mut Option, sandbox_na status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Supervisor session connected".to_string(), - last_transition_time: String::new(), + transition_time: None, }, ); } @@ -4724,7 +4761,7 @@ fn ensure_supervisor_not_connected_status(status: &mut Option, sa status: "False".to_string(), reason: "SupervisorNotConnected".to_string(), message: "Backend ready; waiting for supervisor session".to_string(), - last_transition_time: String::new(), + transition_time: None, }, ); } @@ -4738,7 +4775,7 @@ fn ensure_supervisor_not_ready_status(status: &mut Option, sandbo status: "False".to_string(), reason: "DependenciesNotReady".to_string(), message: "Supervisor session disconnected".to_string(), - last_transition_time: String::new(), + transition_time: None, }, ); } @@ -4770,13 +4807,13 @@ fn public_condition_from_driver(condition: &DriverCondition) -> SandboxCondition status: condition.status.clone(), reason: condition.reason.clone(), message: condition.message.clone(), - last_transition_time: condition.last_transition_time.clone(), + transition_time: condition.transition_time, } } fn public_platform_event_from_driver(event: &DriverPlatformEvent) -> PlatformEvent { PlatformEvent { - timestamp_ms: event.timestamp_ms, + event_time: event.event_time, source: event.source.clone(), r#type: event.r#type.clone(), reason: event.reason.clone(), @@ -6093,12 +6130,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations, workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }; @@ -6451,7 +6488,7 @@ mod tests { status: "False".to_string(), reason: reason.to_string(), message: String::new(), - last_transition_time: String::new(), + transition_time: None, }); sandbox } @@ -6461,17 +6498,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: format!("session-{id}"), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: sandbox_id.to_string(), token: format!("token-{id}"), revoked: false, - expires_at_ms: 0, + expiration_time: None, } } @@ -6480,12 +6517,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: format!("{}--web", sandbox.object_name()), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: sandbox.object_workspace().to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: sandbox.object_name().to_string(), @@ -6618,7 +6655,7 @@ mod tests { status: "False".to_string(), reason: reason.to_string(), message: message.to_string(), - last_transition_time: String::new(), + transition_time: None, } } @@ -6651,7 +6688,7 @@ mod tests { status: "True".to_string(), reason: "BackendReady".to_string(), message: "Container is running".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() @@ -6659,6 +6696,49 @@ mod tests { } } + #[test] + fn driver_watch_timestamp_validation_rejects_malformed_observations() { + let mut sandbox = ready_driver_sandbox("sandbox-id", "sandbox-name"); + sandbox.status.as_mut().unwrap().conditions[0].transition_time = + Some(prost_types::Timestamp { + seconds: 0, + nanos: -1, + }); + let error = + validate_driver_watch_event_timestamps(&sandbox_watch_event(sandbox)).unwrap_err(); + assert!(error.contains("sandbox.status.conditions[0].transition_time")); + + let platform_event = WatchSandboxesEvent { + payload: Some(watch_sandboxes_event::Payload::PlatformEvent( + openshell_core::proto::compute::v1::WatchSandboxesPlatformEvent { + sandbox_id: "sandbox-id".to_string(), + event: Some(DriverPlatformEvent { + event_time: Some(prost_types::Timestamp { + seconds: openshell_core::time::MAX_TIMESTAMP_SECONDS + 1, + nanos: 0, + }), + ..Default::default() + }), + }, + )), + }; + let error = validate_driver_watch_event_timestamps(&platform_event).unwrap_err(); + assert!(error.contains("platform_event.event_time")); + } + + #[test] + fn driver_snapshot_timestamp_validation_rejects_malformed_observations() { + let mut sandbox = ready_driver_sandbox("sandbox-id", "sandbox-name"); + sandbox.status.as_mut().unwrap().conditions[0].transition_time = + Some(prost_types::Timestamp { + seconds: 0, + nanos: 1_000_000_000, + }); + + let error = validate_driver_sandbox_timestamps(&sandbox).unwrap_err(); + assert!(error.contains("sandbox.status.conditions[0].transition_time")); + } + #[test] fn driver_snapshot_preserves_endpoint_failure_and_ready_phase() { let mut sandbox = sandbox_record("sandbox-id", "sandbox-name", SandboxPhase::Ready); @@ -6668,7 +6748,7 @@ mod tests { ports: vec![443], path: "/mcp".to_string(), last_result: openshell_core::proto::EndpointResult::TransportFailed as i32, - last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + last_reported_time: Some("2026-09-05T01:01:00.000Z".parse().unwrap()), }; sandbox.status = Some(SandboxStatus { sandbox_name: "sandbox-name".to_string(), @@ -6748,7 +6828,7 @@ mod tests { status: "True".to_string(), reason: "AgentRunning".to_string(), message: "MXC workload is running".to_string(), - last_transition_time: String::new(), + transition_time: None, }); let composed = ComposedPhase::new(&status, false, true); @@ -6881,7 +6961,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Pod is Ready; Service Exists".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..make_driver_status(make_driver_condition("", "")) }; @@ -7016,7 +7096,7 @@ mod tests { status: "False".to_string(), reason: "Unschedulable".to_string(), message: "0/1 nodes are available: 1 Insufficient nvidia.com/gpu.".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }); @@ -7049,7 +7129,7 @@ mod tests { status: "False".to_string(), reason: "Unschedulable".to_string(), message: original.to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }); @@ -7824,7 +7904,7 @@ mod tests { status: "False".to_string(), reason: "PodTerminating".to_string(), message: "Pod is terminating. Sandbox is stopping".to_string(), - last_transition_time: String::new(), + transition_time: None, }, make_driver_condition("SandboxStopped", "Sandbox is stopping"), ], @@ -8197,14 +8277,14 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Sandbox is ready".to_string(), - last_transition_time: String::new(), + transition_time: None, }, DriverCondition { r#type: "Suspended".to_string(), status: "True".to_string(), reason: "PodTerminated".to_string(), message: "Pod terminated".to_string(), - last_transition_time: String::new(), + transition_time: None, }, ], ..Default::default() @@ -8263,7 +8343,7 @@ mod tests { status: "True".to_string(), reason: "GenerationStarting".to_string(), message: "Replacement generation is starting".to_string(), - last_transition_time: String::new(), + transition_time: None, }); bootstrapping.status = Some(status); @@ -8292,14 +8372,14 @@ mod tests { status: "True".to_string(), reason: "GenerationStarting".to_string(), message: "Replacement generation is starting".to_string(), - last_transition_time: String::new(), + transition_time: None, }); status.conditions.push(DriverCondition { r#type: "Suspended".to_string(), status: "True".to_string(), reason: "PodTerminated".to_string(), message: "Sandbox is suspended".to_string(), - last_transition_time: String::new(), + transition_time: None, }); let mut suspended = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()); suspended.status = Some(status); @@ -8402,7 +8482,7 @@ mod tests { status: "True".to_string(), reason: "BackendReady".to_string(), message: "Container is running".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() @@ -9763,7 +9843,7 @@ mod tests { status: "False".to_string(), reason: "Stopped".to_string(), message: "Sandbox compute is stopped".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }); @@ -9831,7 +9911,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Pod is Ready".to_string(), - last_transition_time: String::new(), + transition_time: None, }], current_policy_version: 7, ..Default::default() @@ -10019,7 +10099,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Supervisor session connected".to_string(), - last_transition_time: String::new(), + transition_time: None, }], ..Default::default() }); @@ -10069,7 +10149,7 @@ mod tests { status: "True".to_string(), reason: "BackendReady".to_string(), message: "Container is running".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() @@ -10087,7 +10167,7 @@ mod tests { status: "False".to_string(), reason: "Deleting".to_string(), message: "Container is being removed".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: true, ..Default::default() @@ -10365,7 +10445,7 @@ mod tests { status: "False".to_string(), reason: "DependenciesNotReady".to_string(), message: "Pod is Pending".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() @@ -10387,7 +10467,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Pod is Ready".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() @@ -10561,7 +10641,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Pod is Ready".to_string(), - last_transition_time: String::new(), + transition_time: None, })), workspace: "default".to_string(), }], @@ -10601,7 +10681,7 @@ mod tests { status: "True".to_string(), reason: "DependenciesReady".to_string(), message: "Pod is Ready".to_string(), - last_transition_time: String::new(), + transition_time: None, }], deleting: false, ..Default::default() @@ -11728,12 +11808,12 @@ mod tests { sandbox.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-new".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }); let created = runtime.create_sandbox(sandbox, None, false).await.unwrap(); diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index a1441d53ec..7408f20c9b 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -272,7 +272,21 @@ impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { name: config.name.clone(), grpc_endpoint: config.grpc_endpoint.clone(), max_payload_bytes: config.max_payload_bytes, - timeout: config.timeout.clone().unwrap_or_default(), + request_timeout: config + .timeout + .as_deref() + .map(openshell_core::middleware::parse_middleware_timeout) + .transpose() + .map_err(|_| ConfigFileError::InvalidValue { + field: "openshell.supervisor_middleware.services.timeout", + message: "must be a duration between 10ms and 30s", + })? + .map(openshell_core::time::duration_from_std) + .transpose() + .map_err(|_| ConfigFileError::InvalidValue { + field: "openshell.supervisor_middleware.services.timeout", + message: "duration is outside the protobuf range", + })?, tls_ca_cert_pem, audience: config .audience @@ -934,7 +948,7 @@ timeout = "2s" let registration = SupervisorMiddlewareService::try_from(&file.openshell.supervisor.middleware[0]) .expect("valid CA resolves"); - assert_eq!(registration.timeout, "2s"); + assert_eq!(registration.request_timeout.unwrap().seconds, 2); let registered_pem = String::from_utf8(registration.tls_ca_cert_pem) .expect("registered CA remains PEM text") .replace("\r\n", "\n"); diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index e1ea5994cc..6098357f01 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -684,31 +684,36 @@ impl CredentialRuntime { // Check provider-level expiration let provider_expires_at_ms = provider - .credential_expires_at_ms + .credential_expiration_times .get(&credential_key) - .copied() - .unwrap_or(0); - - // Compute effective expiration (earliest non-zero timestamp) - let effective_expires_at_ms = match (provider_expires_at_ms, response.expires_at_ms) - { - (0, driver) => driver, - (provider, 0) => provider, - (provider, driver) => provider.min(driver), - }; - - if effective_expires_at_ms > 0 && effective_expires_at_ms <= now_ms { - warn!( - provider_name = %provider_name, - credential_key = %credential_key, - provider_expires_at_ms, - driver_expires_at_ms = response.expires_at_ms, - effective_expires_at_ms, - "skipping expired handle-backed credential" - ); - continue; - } - if effective_expires_at_ms > 0 { + .map(openshell_core::time::timestamp_to_millis) + .transpose() + .map_err(|error| Status::invalid_argument(error.to_string()))?; + let driver_expires_at_ms = response + .expiration_time + .as_ref() + .map(openshell_core::time::timestamp_to_millis) + .transpose() + .map_err(|error| Status::internal(error.to_string()))?; + + // Compute effective expiration (earliest present timestamp). + let effective_expires_at_ms = effective_credential_expiration_ms( + provider_expires_at_ms, + driver_expires_at_ms, + ); + + if let Some(effective_expires_at_ms) = effective_expires_at_ms { + if effective_expires_at_ms <= now_ms { + warn!( + provider_name = %provider_name, + credential_key = %credential_key, + ?provider_expires_at_ms, + ?driver_expires_at_ms, + effective_expires_at_ms, + "skipping expired handle-backed credential" + ); + continue; + } resolved .expires_at_ms .insert(credential_key.clone(), effective_expires_at_ms); @@ -737,6 +742,18 @@ impl CredentialRuntime { } } +fn effective_credential_expiration_ms( + provider_expiration_ms: Option, + driver_expiration_ms: Option, +) -> Option { + match (provider_expiration_ms, driver_expiration_ms) { + (Some(provider), Some(driver)) => Some(provider.min(driver)), + (Some(provider), None) => Some(provider), + (None, Some(driver)) => Some(driver), + (None, None) => None, + } +} + fn refresh_material_storage_key(credential_key: &str, material_key: &str) -> String { let mut hasher = Sha256::new(); hasher.update(REFRESH_MATERIAL_CREDENTIAL_KEY_DOMAIN); @@ -1818,7 +1835,7 @@ impl CredentialDriver for TestStaticCredentialDriver { responses.push(ResolvedCredential { request_id: request.request_id, value, - expires_at_ms: 0, + expiration_time: None, }); } @@ -1883,6 +1900,17 @@ mod tests { } } + #[test] + fn effective_expiration_preserves_timestamp_presence() { + assert_eq!(effective_credential_expiration_ms(None, None), None); + assert_eq!(effective_credential_expiration_ms(Some(0), None), Some(0)); + assert_eq!(effective_credential_expiration_ms(None, Some(0)), Some(0)); + assert_eq!( + effective_credential_expiration_ms(Some(2_000), Some(1_000)), + Some(1_000) + ); + } + fn config_file(toml: &str) -> crate::config_file::ConfigFile { toml::from_str(toml).expect("config file TOML") } @@ -2042,6 +2070,16 @@ mod tests { resolved.values.get("OPENAI_API_KEY").map(String::as_str), Some("sk-test") ); + + provider.credential_expiration_times.insert( + "OPENAI_API_KEY".to_string(), + openshell_core::time::timestamp_from_millis(0).unwrap(), + ); + let expired = runtime + .resolve_provider_handles(&provider, 1_000) + .await + .unwrap(); + assert!(!expired.values.contains_key("OPENAI_API_KEY")); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 896d3006b6..a1141440e3 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -102,7 +102,10 @@ pub async fn handle_issue_sandbox_token( ); Ok(Response::new(IssueSandboxTokenResponse { token: minted.token, - expires_at_ms: minted.expires_at_ms, + expiration_time: openshell_core::time::optional_timestamp_from_legacy_millis( + minted.expires_at_ms, + ) + .map_err(|error| Status::internal(error.to_string()))?, })) } @@ -245,20 +248,28 @@ pub async fn handle_refresh_sandbox_token( .gateway_token .expose_secret() .to_string(), - expires_at_ms: authentication - .supervisor - .gateway_expires_at - .saturating_mul(1000), + expiration_time: openshell_core::time::optional_timestamp_from_legacy_millis( + authentication + .supervisor + .gateway_expires_at + .saturating_mul(1000), + ) + .map_err(|error| Status::internal(error.to_string()))?, extension_credentials, sandbox_token: authentication .supervisor .sandbox_token .expose_secret() .to_string(), - sandbox_expires_at_ms: authentication - .supervisor - .sandbox_expires_at - .saturating_mul(1000), + sandbox_expiration_time: Some( + openshell_core::time::timestamp_from_millis( + authentication + .supervisor + .sandbox_expires_at + .saturating_mul(1000), + ) + .map_err(|error| Status::internal(error.to_string()))?, + ), session_id: authentication.supervisor.runtime_generation.to_string(), credential_epoch: authentication.supervisor.auth_epoch.get(), })) @@ -353,7 +364,10 @@ fn mint_extension_credentials( Ok(ExtensionServiceCredential { service_name: name.clone(), token: minted.token, - expires_at_ms: minted.expires_at_ms, + expiration_time: openshell_core::time::optional_timestamp_from_legacy_millis( + minted.expires_at_ms, + ) + .map_err(|error| Status::internal(error.to_string()))?, }) }) .collect() @@ -450,12 +464,12 @@ mod tests { metadata: Some(ObjectMeta { id: sandbox_id.to_string(), name: sandbox_id.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::default(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -552,7 +566,8 @@ mod tests { .expect("refresh OK") .into_inner(); assert!(!resp.token.is_empty()); - assert!(resp.expires_at_ms > 0); + assert!(resp.expiration_time.is_some()); + assert!(resp.sandbox_expiration_time.is_some()); } #[tokio::test] @@ -583,7 +598,11 @@ mod tests { .into_inner(); assert_eq!(replayed.token, second.token); assert_eq!(replayed.sandbox_token, second.sandbox_token); - assert_eq!(replayed.expires_at_ms, second.expires_at_ms); + assert_eq!(replayed.expiration_time, second.expiration_time); + assert_eq!( + replayed.sandbox_expiration_time, + second.sandbox_expiration_time + ); let mut changed_retry = request(); changed_retry.get_mut().extension_service_names = vec!["content-guard".to_string()]; @@ -656,7 +675,7 @@ mod tests { assert_eq!(credentials.len(), 1); assert_eq!(credentials[0].service_name, "content-guard"); assert!(!credentials[0].token.is_empty()); - assert!(credentials[0].expires_at_ms > 0); + assert!(credentials[0].expiration_time.is_some()); let error = mint_extension_credentials( issuer, @@ -759,7 +778,7 @@ mod tests { .expect("issue OK") .into_inner(); assert!(!resp.token.is_empty()); - assert!(resp.expires_at_ms > 0); + assert!(resp.expiration_time.is_some()); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 8abdfd36d0..d986c5cba9 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -33,7 +33,7 @@ use crate::policy_store::{AtomicPolicyRevisionWrite, PolicyStoreExt}; use crate::provider_profile_sources::EffectiveProviderProfileCatalog; #[cfg(test)] use crate::provider_profile_sources::ProviderProfileSources; -use crate::storage_proto::StoredProviderCredentialRefreshState; +use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState; #[cfg(test)] use crate::storage_proto::StoredProviderProfile; use openshell_core::net::{is_always_blocked_ip, is_internal_ip}; @@ -2801,11 +2801,13 @@ async fn compute_provider_env_revision_with_catalog_and_policy_bindings( for key in credential_keys { hasher.update(key.as_bytes()); } - let mut expiry_keys: Vec<_> = provider.credential_expires_at_ms.keys().collect(); + let mut expiry_keys: Vec<_> = provider.credential_expiration_times.keys().collect(); expiry_keys.sort(); for key in expiry_keys { hasher.update(key.as_bytes()); - hasher.update(provider.credential_expires_at_ms[key].to_le_bytes()); + let value = &provider.credential_expiration_times[key]; + hasher.update(value.seconds.to_le_bytes()); + hasher.update(value.nanos.to_le_bytes()); } } None => { @@ -2862,11 +2864,13 @@ fn compute_provider_env_revision_from_records_and_policy_bindings( for key in credential_keys { hasher.update(key.as_bytes()); } - let mut expiry_keys: Vec<_> = provider.credential_expires_at_ms.keys().collect(); + let mut expiry_keys: Vec<_> = provider.credential_expiration_times.keys().collect(); expiry_keys.sort(); for key in expiry_keys { hasher.update(key.as_bytes()); - hasher.update(provider.credential_expires_at_ms[key].to_le_bytes()); + let value = &provider.credential_expiration_times[key]; + hasher.update(value.seconds.to_le_bytes()); + hasher.update(value.nanos.to_le_bytes()); } } @@ -3309,13 +3313,15 @@ pub(super) async fn handle_get_sandbox_provider_environment( "withholding unbound static provider credential from binding-capable supervisor" ); provider_environment.environment.remove(&key); - provider_environment.credential_expires_at_ms.remove(&key); + provider_environment + .credential_expiration_times + .remove(&key); provider_environment.static_credential_keys.remove(&key); } } else { for key in &provider_environment.static_credential_keys { provider_environment.environment.remove(key); - provider_environment.credential_expires_at_ms.remove(key); + provider_environment.credential_expiration_times.remove(key); } provider_environment.static_credential_bindings.clear(); } @@ -3335,10 +3341,20 @@ pub(super) async fn handle_get_sandbox_provider_environment( .cloned() .collect(); + let credential_expiration_times = provider_environment + .credential_expiration_times + .into_iter() + .filter_map(|(key, value)| { + openshell_core::time::optional_timestamp_from_legacy_millis(value) + .ok() + .flatten() + .map(|timestamp| (key, timestamp)) + }) + .collect(); Ok(Response::new(GetSandboxProviderEnvironmentResponse { environment: provider_environment.environment, provider_env_revision, - credential_expires_at_ms: provider_environment.credential_expires_at_ms, + credential_expiration_times, dynamic_credentials: provider_environment.dynamic_credentials, static_credential_bindings: provider_environment.static_credential_bindings, non_secret_environment_keys, @@ -4310,7 +4326,7 @@ pub(super) async fn handle_report_policy_status( sandbox, endpoints, &HashSet::new(), - "", + &prost_types::Timestamp::default(), ); } }, @@ -4367,14 +4383,25 @@ pub(super) async fn handle_get_sandbox_logs( let buffer_total = tail.len() as u32; + if let Some(since_time) = req.since_time.as_ref() { + openshell_core::time::validate_timestamp(since_time) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + } + let since_time = req.since_time; + let logs: Vec = tail .into_iter() .filter_map(|evt| { if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log(log)) = evt.payload { - if req.since_ms > 0 && log.timestamp_ms < req.since_ms { - return None; + if let Some(since_time) = since_time.as_ref() { + let event_time = log.event_time.as_ref()?; + if openshell_core::time::compare_timestamps(event_time, since_time).ok()? + == std::cmp::Ordering::Less + { + return None; + } } if !req.sources.is_empty() && !source_matches(&log.source, &req.sources) { return None; @@ -4421,7 +4448,10 @@ pub(super) async fn handle_push_sandbox_logs( ) .await?; - for log in batch.logs.into_iter().take(100) { + let logs: Vec<_> = batch.logs.into_iter().take(100).collect(); + validate_sandbox_log_timestamps(&logs)?; + + for log in logs { let mut log = log; log.source = "sandbox".to_string(); log.sandbox_id.clone_from(&batch.sandbox_id); @@ -4432,6 +4462,17 @@ pub(super) async fn handle_push_sandbox_logs( Ok(Response::new(PushSandboxLogsResponse {})) } +fn validate_sandbox_log_timestamps(logs: &[SandboxLogLine]) -> Result<(), Status> { + for (index, log) in logs.iter().enumerate() { + if let Some(event_time) = log.event_time.as_ref() { + openshell_core::time::validate_timestamp(event_time).map_err(|error| { + Status::invalid_argument(format!("logs[{index}].event_time: {error}")) + })?; + } + } + Ok(()) +} + async fn ensure_log_stream_sandbox_scope( state: &Arc, principal: &Principal, @@ -4617,6 +4658,38 @@ pub(super) async fn handle_submit_policy_analysis( rejection_reasons.push(format!("chunk '{}' missing proposed_rule", chunk.rule_name)); continue; } + let first_seen_ms = match chunk + .first_seen_time + .as_ref() + .map(openshell_core::time::timestamp_to_millis) + .transpose() + { + Ok(value) => value, + Err(error) => { + rejected += 1; + rejection_reasons.push(format!( + "chunk '{}' has invalid first_seen_time: {error}", + chunk.rule_name + )); + continue; + } + }; + let last_seen_ms = match chunk + .last_seen_time + .as_ref() + .map(openshell_core::time::timestamp_to_millis) + .transpose() + { + Ok(value) => value, + Err(error) => { + rejected += 1; + rejection_reasons.push(format!( + "chunk '{}' has invalid last_seen_time: {error}", + chunk.rule_name + )); + continue; + } + }; let rule_ref = chunk.proposed_rule.as_ref().expect("checked above"); if req.analysis_mode == "agent_authored" @@ -4752,16 +4825,8 @@ pub(super) async fn handle_submit_policy_analysis( port: ep_port, binary: ep_binary, hit_count: chunk.hit_count.clamp(1, 100), - first_seen_ms: if chunk.first_seen_ms > 0 { - chunk.first_seen_ms - } else { - now_ms - }, - last_seen_ms: if chunk.last_seen_ms > 0 { - chunk.last_seen_ms - } else { - now_ms - }, + first_seen_ms: first_seen_ms.unwrap_or(now_ms), + last_seen_ms: last_seen_ms.unwrap_or(now_ms), validation_result: evaluation.validation_result.clone(), rejection_reason: String::new(), application_error: evaluation.application_error.clone(), @@ -4939,7 +5004,10 @@ pub(super) async fn handle_get_draft_policy( .map(|r| draft_chunk_record_to_proto(&r)) .collect::, _>>()?; - let last_analyzed_at_ms = chunks.iter().map(|c| c.created_at_ms).max().unwrap_or(0); + let last_analyzed_time = chunks + .iter() + .filter_map(|chunk| chunk.created_time) + .max_by_key(|value| (value.seconds, value.nanos)); debug!( sandbox_id = %sandbox_id, @@ -4952,7 +5020,7 @@ pub(super) async fn handle_get_draft_policy( chunks, rolling_summary: String::new(), draft_version: u64::try_from(draft_version).unwrap_or(0), - last_analyzed_at_ms, + last_analyzed_time, })) } @@ -5805,7 +5873,11 @@ pub(super) async fn handle_get_draft_history( for chunk in &all_chunks { entries.push(DraftHistoryEntry { - timestamp_ms: chunk.created_at_ms, + event_time: openshell_core::time::optional_timestamp_from_legacy_millis( + chunk.created_at_ms, + ) + .ok() + .flatten(), event_type: "proposed".to_string(), description: format!( "Rule '{}' proposed (confidence: {:.0}%)", @@ -5817,7 +5889,9 @@ pub(super) async fn handle_get_draft_history( if let Some(decided_at) = chunk.decided_at_ms { entries.push(DraftHistoryEntry { - timestamp_ms: decided_at, + event_time: openshell_core::time::optional_timestamp_from_legacy_millis(decided_at) + .ok() + .flatten(), event_type: chunk.status.clone(), description: format!("Rule '{}' {}", chunk.rule_name, chunk.status), chunk_id: chunk.id.clone(), @@ -5825,7 +5899,12 @@ pub(super) async fn handle_get_draft_history( } } - entries.sort_by_key(|e| e.timestamp_ms); + entries.sort_by_key(|entry| { + entry + .event_time + .as_ref() + .map_or((0, 0), |value| (value.seconds, value.nanos)) + }); debug!( sandbox_id = %sandbox_id, @@ -5981,11 +6060,25 @@ fn draft_chunk_record_to_proto(record: &DraftChunkRecord) -> Result>(), + vec!["at-boundary", "after"] + ); + + let response = handle_get_sandbox_logs( + &state, + with_user(Request::new(GetSandboxLogsRequest { + sandbox_id: "sandbox-id".to_string(), + since_time: Some(prost_types::Timestamp { + seconds: 0, + nanos: 0, + }), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + let messages = response + .logs + .iter() + .map(|log| log.message.as_str()) + .collect::>(); + assert!(!messages.contains(&"pre-epoch")); + assert!(messages.contains(&"epoch")); + } + + #[tokio::test] + async fn submit_policy_analysis_rejects_only_chunks_with_invalid_timestamps() { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint, NetworkPolicyRule}; + + let state = test_server_state().await; + let sandbox_name = "invalid-policy-chunk-timestamps"; + state + .store + .put_message(&test_sandbox( + "sb-invalid-policy-chunk-timestamps", + sandbox_name, + ProtoSandboxPolicy::default(), + vec![], + )) + .await + .unwrap(); + let chunk = |name: &str| PolicyChunk { + rule_name: name.to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: format!("{name}.example.com"), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + }], + }), + ..Default::default() + }; + let invalid = prost_types::Timestamp { + seconds: 253_402_300_800, + nanos: 0, + }; + let mut invalid_first = chunk("invalid_first"); + invalid_first.first_seen_time = Some(invalid); + let mut invalid_last = chunk("invalid_last"); + invalid_last.last_seen_time = Some(invalid); + + let response = handle_submit_policy_analysis( + &state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + name: sandbox_name.to_string(), + analysis_mode: "agent_authored".to_string(), + proposed_chunks: vec![invalid_first, invalid_last, chunk("valid_absent")], + ..Default::default() + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!(response.accepted_chunks, 1); + assert_eq!(response.rejected_chunks, 2); + assert!( + response + .rejection_reasons + .iter() + .any(|reason| reason.contains("invalid first_seen_time")) + ); + assert!( + response + .rejection_reasons + .iter() + .any(|reason| reason.contains("invalid last_seen_time")) + ); + } + #[tokio::test] async fn update_config_global_requires_platform_admin() { use openshell_core::proto::datamodel::v1::ObjectMeta; @@ -8854,12 +9169,12 @@ mod tests { metadata: Some(ObjectMeta { id: "default-admin-member-id".to_string(), name: "test-user".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), principal_subject: "test-user".to_string(), role: WorkspaceRole::Admin.into(), @@ -9118,12 +9433,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -9154,12 +9469,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-self".to_string(), name: "self".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -9189,12 +9504,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -9227,12 +9542,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -9324,12 +9639,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-x".to_string(), name: "x".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -9407,12 +9722,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-no-policy".to_string(), name: "no-policy-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -9436,18 +9751,18 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("provider-{name}"), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } @@ -9538,12 +9853,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(policy), @@ -9795,12 +10110,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-generic".to_string(), name: "generic".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(openshell_core::proto::ProviderProfile { id: "generic".to_string(), @@ -9888,12 +10203,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-custom-api".to_string(), name: "custom-api".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -9959,12 +10274,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-custom-api".to_string(), name: "custom-api".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(openshell_core::proto::ProviderProfile { id: "custom-api".to_string(), @@ -10128,12 +10443,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("profile-{id}-{workspace}"), name: id.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(openshell_core::proto::ProviderProfile { id: id.to_string(), @@ -10245,12 +10560,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-mcp-default".to_string(), name: "mcp-default".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "mcp-default".to_string(), @@ -10872,12 +11187,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-ambiguous".to_string(), name: "ambiguous".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "ambiguous".to_string(), @@ -10945,12 +11260,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-tls-skip".to_string(), name: "tls-skip".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "tls-skip".to_string(), @@ -11105,12 +11420,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-custom-policy".to_string(), name: "custom-policy".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "custom-policy".to_string(), @@ -11665,12 +11980,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-custom-dynamic".to_string(), name: "custom-dynamic".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "custom-dynamic".to_string(), @@ -11753,12 +12068,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-token-exchange-subject".to_string(), name: "token-exchange-subject".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "token-exchange-subject".to_string(), @@ -11805,8 +12120,10 @@ mod tests { "subject_token".to_string(), "raw-gateway-oidc-token".to_string(), )]); - provider.credential_expires_at_ms = - HashMap::from([("subject_token".to_string(), current_time_ms() + 60_000)]); + provider.credential_expiration_times = HashMap::from([( + "subject_token".to_string(), + openshell_core::time::timestamp_from_millis(current_time_ms() + 60_000).unwrap(), + )]); state.store.put_message(&provider).await.unwrap(); state .store @@ -11842,7 +12159,7 @@ mod tests { ); assert!( !response - .credential_expires_at_ms + .credential_expiration_times .contains_key("subject_token"), "withheld subject credentials must not emit sandbox expiry metadata" ); @@ -11962,12 +12279,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("profile-{id}"), name: id.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: id.to_string(), @@ -12147,12 +12464,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-custom-token".to_string(), name: "custom-token".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "custom-token".to_string(), @@ -12268,12 +12585,12 @@ mod tests { } ), name: "scoped-revision".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "scoped-revision".to_string(), @@ -12667,12 +12984,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-global-profile".to_string(), name: "global-profile-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(sandbox_policy), @@ -12752,12 +13069,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-backfill".to_string(), name: "backfill-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -13723,12 +14040,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-draft-flow".to_string(), name: "draft-flow".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -13764,8 +14081,8 @@ mod tests { rationale: "observed denied request".to_string(), confidence: 0.85, hit_count: 3, - first_seen_ms: 100, - last_seen_ms: 200, + first_seen_time: openshell_core::time::timestamp_from_millis(100).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(200).ok(), binary: "/usr/bin/curl".to_string(), ..Default::default() }], @@ -13978,12 +14295,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-feedback".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -14082,12 +14399,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-agent-l7-verdict".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -14202,12 +14519,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-supersede-flow".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -14413,12 +14730,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-mechanistic-clean".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -14524,12 +14841,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-mechanistic-existing-rest".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(base_policy), @@ -14641,7 +14958,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-invalid-graphql-preflight".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), workspace: "default".to_string(), ..Default::default() }), @@ -14724,7 +15041,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), workspace: "default".to_string(), ..Default::default() }), @@ -14949,12 +15266,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-l7-full-with-cred".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15059,12 +15376,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-default-manual-mode".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15157,12 +15474,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-unknown-mode".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15247,12 +15564,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-explicit-manual-mode".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15339,12 +15656,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-gateway-auto-mode".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15431,12 +15748,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-gateway-pinned-manual".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15528,12 +15845,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-reject-provider-prefix".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15795,12 +16112,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-agent-l4-with-cred".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15897,12 +16214,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-agent-l4-no-cred".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -15987,12 +16304,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-agent-link-local".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -16084,12 +16401,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "profile-custom-api".to_string(), name: "custom-api".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: "custom-api".to_string(), @@ -16129,12 +16446,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -16313,12 +16630,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-full-loop-v2".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -16503,12 +16820,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-redraft".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -16625,12 +16942,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-mech-dedup".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -16734,12 +17051,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: Some(SandboxPolicy { @@ -17011,12 +17328,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-undo-clears".to_string(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -17147,12 +17464,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-draft-owner".to_string(), name: "draft-owner".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -17166,12 +17483,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-draft-other".to_string(), name: "draft-other".to_string(), - created_at_ms: 1_000_001, + created_time: openshell_core::time::timestamp_from_millis(1_000_001).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -17206,8 +17523,8 @@ mod tests { rationale: "observed denied request".to_string(), confidence: 0.85, hit_count: 3, - first_seen_ms: 100, - last_seen_ms: 200, + first_seen_time: openshell_core::time::timestamp_from_millis(100).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(200).ok(), binary: "/usr/bin/curl".to_string(), ..Default::default() }], @@ -18284,12 +18601,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("profile-{suffix}"), name: profile_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(ProviderProfile { id: profile_name.clone(), @@ -19208,12 +19525,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sb_id.to_string(), name: sb_name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: ws.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -19350,12 +19667,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-1".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, // No policy yet - will be backfilled @@ -19445,7 +19762,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-annotated-backfill".to_string(), name: "annotated-backfill".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::from([( @@ -19453,7 +19770,7 @@ mod tests { "keep".to_string(), )]), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -19886,7 +20203,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-preserve-backfill".to_string(), name: "preserve-backfill".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::from([( @@ -19894,7 +20211,7 @@ mod tests { "keep".to_string(), )]), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -20040,12 +20357,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.to_string(), name: sandbox_name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -20127,12 +20444,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.clone(), name: sandbox_name.clone(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -20209,12 +20526,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: sandbox_id.to_string(), name: sandbox_name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -20379,12 +20696,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-sync-strip".to_string(), name: "sync-strip".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -20486,12 +20803,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-1".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, @@ -20583,12 +20900,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sb-1".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { policy: None, diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status.rs b/crates/openshell-server/src/grpc/policy/endpoint_status.rs index 5a0e454434..69c8d474d4 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status.rs @@ -14,7 +14,6 @@ use crate::persistence::{ObjectId, ObjectName, ObjectWorkspace}; use crate::policy_store::PolicyStoreExt; use crate::provider_profile_sources::EffectiveProviderProfileCatalog; use crate::supervisor_session::EndpointReportCursor; -use chrono::{SecondsFormat, Utc}; use openshell_core::GetResourceVersion; use openshell_core::endpoint_status::initial_endpoint_status; use openshell_core::mcp::is_mcp_protocol; @@ -139,7 +138,8 @@ pub(in crate::grpc) async fn handle_report_endpoint_status( &context.endpoints, )?; validate_endpoint_observation_markers(&sandbox, &reports, &observed_endpoint_ids)?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let now = openshell_core::time::timestamp_from_system_time(std::time::SystemTime::now()) + .map_err(|error| Status::internal(format!("create endpoint report timestamp: {error}")))?; let expected_resource_version = sandbox.get_resource_version(); let updated = state .store @@ -345,7 +345,8 @@ pub async fn reset_endpoint_status_for_supervisor_session( .ok_or_else(|| Status::not_found("sandbox not found"))?; let context = active_endpoint_context(state.as_ref(), &sandbox).await?; let reports = unknown_endpoint_reports(&context.endpoints); - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let now = openshell_core::time::timestamp_from_system_time(std::time::SystemTime::now()) + .map_err(|error| Status::internal(format!("create endpoint reset timestamp: {error}")))?; let expected_resource_version = sandbox.get_resource_version(); let updated = state .store @@ -388,7 +389,8 @@ pub async fn reset_endpoint_status_after_supervisor_disconnect( .ok_or_else(|| Status::not_found("sandbox not found"))?; let context = active_endpoint_context(state.as_ref(), &sandbox).await?; let reports = unknown_endpoint_reports(&context.endpoints); - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let now = openshell_core::time::timestamp_from_system_time(std::time::SystemTime::now()) + .map_err(|error| Status::internal(format!("create endpoint reset timestamp: {error}")))?; let expected_resource_version = sandbox.get_resource_version(); let updated = state .store @@ -504,7 +506,7 @@ fn invalidate_endpoint_status_without_session(sandbox: &mut Sandbox) { // remains available so callers can still identify the configured endpoint. for endpoint in &mut status.endpoint_statuses { endpoint.last_result = EndpointResult::NoObservedExchange as i32; - endpoint.last_reported_at.clear(); + endpoint.last_reported_time = None; } } @@ -703,7 +705,7 @@ pub(super) fn reconcile_endpoint_statuses( sandbox: &mut Sandbox, reports: &BTreeMap, observed_endpoint_ids: &HashSet, - now: &str, + now: &prost_types::Timestamp, ) { let phase = sandbox.phase(); let current_policy_version = sandbox.current_policy_version(); @@ -722,12 +724,7 @@ pub(super) fn reconcile_endpoint_statuses( let previous = status .endpoint_statuses .iter() - .map(|endpoint| { - ( - endpoint.endpoint_id.as_str(), - endpoint.last_reported_at.as_str(), - ) - }) + .map(|endpoint| (endpoint.endpoint_id.as_str(), endpoint.last_reported_time)) .collect::>(); // Replace the complete inventory to remove retired endpoints atomically. // Only newly accepted evidence advances the gateway acceptance timestamp; @@ -736,17 +733,16 @@ pub(super) fn reconcile_endpoint_statuses( .iter() .map(|(endpoint_id, report)| { let mut endpoint = report.clone(); - endpoint.last_reported_at = + endpoint.last_reported_time = if endpoint.last_result == EndpointResult::NoObservedExchange as i32 { - String::new() + None } else if observed_endpoint_ids.contains(endpoint_id) { - now.to_string() + Some(*now) } else { previous .get(endpoint_id.as_str()) .copied() .unwrap_or_default() - .to_string() }; endpoint }) diff --git a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs index 36c1b303bf..09f76a60c4 100644 --- a/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs +++ b/crates/openshell-server/src/grpc/policy/endpoint_status_tests.rs @@ -13,6 +13,10 @@ use openshell_core::proto::{ }; use tonic::Code; +fn timestamp(value: &str) -> prost_types::Timestamp { + value.parse().expect("valid test timestamp") +} + fn test_initial_endpoint_status(endpoint_id: &str, host: &str, path: &str) -> EndpointStatus { EndpointStatus { endpoint_id: endpoint_id.to_string(), @@ -20,7 +24,7 @@ fn test_initial_endpoint_status(endpoint_id: &str, host: &str, path: &str) -> En ports: vec![443], path: path.to_string(), last_result: EndpointResult::NoObservedExchange as i32, - last_reported_at: String::new(), + last_reported_time: None, } } @@ -179,7 +183,7 @@ async fn assert_loaded_ack_preserves_endpoint_evidence( before.endpoint_statuses[0].last_result, EndpointResult::HttpResponseReceived as i32 ); - assert!(!before.endpoint_statuses[0].last_reported_at.is_empty()); + assert!(before.endpoint_statuses[0].last_reported_time.is_some()); let cursor = state .supervisor_sessions .endpoint_report_cursor(&report.sandbox_id, &report.supervisor_session_id); @@ -366,7 +370,7 @@ async fn loaded_policy_hash_cycle_resets_endpoint_evidence() { status.endpoint_statuses[0], EndpointStatus { last_result: EndpointResult::NoObservedExchange as i32, - last_reported_at: String::new(), + last_reported_time: None, ..original_status.endpoint_statuses[0].clone() } ); @@ -659,7 +663,7 @@ fn endpoint_results_preserve_address_and_lifecycle_through_failure_recovery_and_ } else { HashSet::from(["current".to_string()]) }; - reconcile_endpoint_statuses(&mut sandbox, &reports, &observed, time); + reconcile_endpoint_statuses(&mut sandbox, &reports, &observed, ×tamp(time)); let status = sandbox.status.as_ref().expect("status remains present"); assert_eq!(status.phase, SandboxPhase::Ready as i32); @@ -668,10 +672,10 @@ fn endpoint_results_preserve_address_and_lifecycle_through_failure_recovery_and_ status.endpoint_statuses, vec![EndpointStatus { last_result: result as i32, - last_reported_at: if result == EndpointResult::NoObservedExchange { - String::new() + last_reported_time: if result == EndpointResult::NoObservedExchange { + None } else { - time.to_string() + Some(timestamp(time)) }, ..initial.clone() }] @@ -689,12 +693,12 @@ fn endpoint_reconciliation_advances_only_observed_endpoint_timestamp() { ); let endpoint_a = EndpointStatus { last_result: EndpointResult::HttpResponseReceived as i32, - last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + last_reported_time: Some(timestamp("2026-09-05T01:01:00.000Z")), ..test_initial_endpoint_status("a", "a.example.com", "/**") }; let endpoint_b = EndpointStatus { last_result: EndpointResult::TransportFailed as i32, - last_reported_at: "2026-09-05T01:11:00.000Z".to_string(), + last_reported_time: Some(timestamp("2026-09-05T01:11:00.000Z")), ..test_initial_endpoint_status("b", "b.example.com", "/**") }; sandbox.status = Some(SandboxStatus { @@ -709,14 +713,14 @@ fn endpoint_reconciliation_advances_only_observed_endpoint_timestamp() { &mut sandbox, &reports, &HashSet::from(["a".to_string()]), - "2026-09-05T02:00:00.000Z", + ×tamp("2026-09-05T02:00:00.000Z"), ); let status = sandbox.status.expect("status remains present"); assert_eq!( status.endpoint_statuses, vec![ EndpointStatus { - last_reported_at: "2026-09-05T02:00:00.000Z".to_string(), + last_reported_time: Some(timestamp("2026-09-05T02:00:00.000Z")), ..endpoint_a }, endpoint_b @@ -751,7 +755,7 @@ fn endpoint_snapshot_requires_marker_for_new_observed_result() { &mut sandbox, &reports, &observed, - "2026-09-05T01:00:00.000Z", + ×tamp("2026-09-05T01:00:00.000Z"), ); validate_endpoint_observation_markers(&sandbox, &reports, &HashSet::new()) .expect("retained evidence"); @@ -761,11 +765,11 @@ fn endpoint_snapshot_requires_marker_for_new_observed_result() { &mut sandbox, &reports, &observed, - "2026-09-05T02:00:00.000Z", + ×tamp("2026-09-05T02:00:00.000Z"), ); assert_eq!( - sandbox.status.expect("status").endpoint_statuses[0].last_reported_at, - "2026-09-05T02:00:00.000Z" + sandbox.status.expect("status").endpoint_statuses[0].last_reported_time, + Some(timestamp("2026-09-05T02:00:00.000Z")) ); } @@ -786,7 +790,7 @@ fn endpoint_reconciliation_initializes_status_name_and_unknown_result() { &mut sandbox, &reports, &HashSet::new(), - "2026-09-05T04:00:00.000Z", + ×tamp("2026-09-05T04:00:00.000Z"), ); let status = sandbox.status.expect("status initialized"); assert_eq!(status.endpoint_statuses, vec![endpoint]); @@ -811,7 +815,7 @@ async fn startup_reconciliation_invalidates_status_from_previous_sessions() { sandbox_name: sandbox_id.to_string(), endpoint_statuses: vec![EndpointStatus { last_result: EndpointResult::HttpResponseReceived as i32, - last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + last_reported_time: Some(timestamp("2026-09-05T01:01:00.000Z")), ..initial.clone() }], conditions: vec![ready_condition()], @@ -928,15 +932,15 @@ async fn report_endpoint_status_is_session_bound_and_retry_idempotent() { .find(|endpoint| endpoint.endpoint_id == observed_id) .expect("observed endpoint"); observed.last_result = EndpointResult::TransportFailed as i32; - observed.last_reported_at.clone_from( + observed.last_reported_time.clone_from( &public .endpoint_statuses .iter() .find(|endpoint| endpoint.endpoint_id == observed_id) .expect("public endpoint") - .last_reported_at, + .last_reported_time, ); - assert!(!observed.last_reported_at.is_empty()); + assert!(observed.last_reported_time.is_some()); assert_eq!(public.endpoint_statuses, expected); handle_report_endpoint_status( @@ -1040,7 +1044,7 @@ async fn loaded_policy_and_unknown_endpoint_inventory_commit_atomically() { current_policy_version: 0, endpoint_statuses: vec![EndpointStatus { last_result: EndpointResult::HttpResponseReceived as i32, - last_reported_at: "2026-09-05T01:00:00.000Z".to_string(), + last_reported_time: Some(timestamp("2026-09-05T01:00:00.000Z")), ..initial_old }], ..Default::default() @@ -1159,12 +1163,12 @@ async fn loaded_policy_and_unknown_endpoint_inventory_commit_atomically() { .expect("reported status"); assert_eq!(reported.endpoint_statuses.len(), 1); let endpoint = &reported.endpoint_statuses[0]; - assert!(!endpoint.last_reported_at.is_empty()); + assert!(endpoint.last_reported_time.is_some()); assert_eq!( endpoint, &EndpointStatus { last_result: EndpointResult::HttpResponseReceived as i32, - last_reported_at: endpoint.last_reported_at.clone(), + last_reported_time: endpoint.last_reported_time, ..initial_active } ); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e3cb3f6b20..9ab7aca417 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -16,7 +16,10 @@ use crate::provider_profile_sources::{ EffectiveProviderProfileCatalog, ProfileScope, ProviderProfileSources, profile_response_payload, profile_storage_payload, stored_profile_resource_version, }; -use crate::storage_proto::{StoredProviderCredentialRefreshState, StoredProviderProfile}; +use crate::storage_proto::{ + StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState, + StoredProviderProfile, +}; use openshell_core::metadata::ObjectWorkspace; use openshell_core::proto::{ CredentialHandle, Provider, ProviderCredentialRefreshStrategy, @@ -64,7 +67,7 @@ fn redact_provider_credentials(mut provider: Provider) -> Provider { #[derive(Debug, Clone, Default, PartialEq)] pub(super) struct ProviderEnvironment { pub environment: HashMap, - pub credential_expires_at_ms: HashMap, + pub credential_expiration_times: HashMap, pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, @@ -138,12 +141,12 @@ async fn create_provider_record_validating( provider.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: generate_name(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }); } @@ -313,7 +316,7 @@ pub(super) async fn update_provider_record_with_catalog( workspace: &str, provider: Provider, ) -> Result { - update_provider_record_validating(store, workspace, catalog, provider, None).await + update_provider_record_validating(store, workspace, catalog, provider, &[], None).await } async fn reject_refresh_owned_credential_updates( @@ -361,6 +364,7 @@ async fn update_provider_record_validating( workspace: &str, catalog: &EffectiveProviderProfileCatalog, provider: Provider, + clear_credential_expiration_keys: &[String], credentials: Option<&crate::credentials::CredentialRuntime>, ) -> Result { use crate::persistence::{ObjectId, ObjectName}; @@ -426,9 +430,17 @@ async fn update_provider_record_validating( .collect::>(); candidate.credentials = merge_map(candidate.credentials, provider.credentials); candidate.config = merge_map(candidate.config, provider.config); - candidate.credential_expires_at_ms = merge_i64_map( - candidate.credential_expires_at_ms, - provider.credential_expires_at_ms, + for key in clear_credential_expiration_keys { + if provider.credential_expiration_times.contains_key(key) { + return Err(Status::invalid_argument(format!( + "credential expiration for '{key}' cannot be both set and cleared" + ))); + } + candidate.credential_expiration_times.remove(key); + } + candidate.credential_expiration_times = merge_timestamp_map( + candidate.credential_expiration_times, + provider.credential_expiration_times, ); // Validate BEFORE writing to prevent persisting invalid state. @@ -735,19 +747,15 @@ fn merge_map( existing } -fn merge_i64_map( - mut existing: HashMap, - incoming: HashMap, -) -> HashMap { +fn merge_timestamp_map( + mut existing: HashMap, + incoming: HashMap, +) -> HashMap { if incoming.is_empty() { return existing; } for (key, value) in incoming { - if value <= 0 { - existing.remove(&key); - } else { - existing.insert(key, value); - } + existing.insert(key, value); } existing } @@ -1224,20 +1232,21 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin continue; } let expires_at_ms = provider - .credential_expires_at_ms + .credential_expiration_times .get(key) - .copied() - .unwrap_or_default(); - if expires_at_ms > 0 && expires_at_ms <= now_ms { - warn!( - provider_name = %name, - key = %key, - expires_at_ms, - "skipping expired provider credential" - ); - continue; - } - if expires_at_ms > 0 { + .map(openshell_core::time::timestamp_to_millis) + .transpose() + .map_err(|error| Status::failed_precondition(error.to_string()))?; + if let Some(expires_at_ms) = expires_at_ms { + if expires_at_ms <= now_ms { + warn!( + provider_name = %name, + key = %key, + expires_at_ms, + "skipping expired provider credential" + ); + continue; + } expires.entry(key.clone()).or_insert(expires_at_ms); } provider_env.insert(key.clone(), value.clone()); @@ -1302,12 +1311,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin ); continue; } - if let Some(expires_at_ms) = resolved_refs - .expires_at_ms - .get(&key) - .copied() - .filter(|expires_at_ms| *expires_at_ms > 0) - { + if let Some(expires_at_ms) = resolved_refs.expires_at_ms.get(&key).copied() { expires.entry(key.clone()).or_insert(expires_at_ms); } provider_env.insert(key.clone(), value); @@ -1350,7 +1354,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin Ok(ProviderEnvironment { environment: env, - credential_expires_at_ms: expires, + credential_expiration_times: expires, dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), static_credential_bindings, static_credential_keys, @@ -1365,7 +1369,7 @@ fn refresh_authorization_epochs_by_key( if state .metadata .as_ref() - .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + .is_some_and(|metadata| metadata.deletion_time.is_some()) { continue; } @@ -1795,7 +1799,7 @@ pub async fn validate_provider_credential_key_available_for_attached_sandboxes_w .credentials .entry(credential_key.to_string()) .or_insert_with(|| "pending".to_string()); - candidate.credential_expires_at_ms.remove(credential_key); + candidate.credential_expiration_times.remove(credential_key); validate_provider_update_against_attached_sandboxes_with_catalog( store, catalog, workspace, &candidate, ) @@ -2257,9 +2261,10 @@ fn broker_only_provider_credential_keys(profile: &ProviderProfile) -> HashSet bool { provider - .credential_expires_at_ms + .credential_expiration_times .get(key) - .is_none_or(|expires_at_ms| *expires_at_ms <= 0 || *expires_at_ms > now_ms) + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .is_none_or(|expiration_ms| expiration_ms > now_ms) } fn is_non_injectable_provider_credential(provider: &Provider, key: &str) -> bool { @@ -3671,12 +3676,12 @@ fn stored_provider_profile_for_workspace( metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: profile.id.clone(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(profile), } @@ -3734,8 +3739,8 @@ pub(super) async fn handle_update_provider( }; let provider_type = provider.r#type.clone(); provider - .credential_expires_at_ms - .extend(req.credential_expires_at_ms); + .credential_expiration_times + .extend(req.credential_expiration_times); if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { state.compute.ensure_workspace(&workspace).await?; } @@ -3748,6 +3753,7 @@ pub(super) async fn handle_update_provider( &workspace, &catalog, provider, + &req.clear_credential_expiration_keys, Some(&state.credentials), ) .await; @@ -3897,7 +3903,10 @@ pub(super) async fn handle_exchange_provider_subject_token( if let Some(cached) = INTERMEDIATE_TOKEN_CACHE.get(&intermediate_cache_key) { return Ok(Response::new(ExchangeProviderSubjectTokenResponse { access_token: cached.access_token, - expires_in: cached.expires_in, + expires_after: openshell_core::time::duration_from_std(std::time::Duration::from_secs( + u64::try_from(cached.expires_in).unwrap_or_default(), + )) + .ok(), token_type: cached.token_type, })); } @@ -3932,8 +3941,14 @@ pub(super) async fn handle_exchange_provider_subject_token( })?; let cache_expires_at_ms = intermediate_token_cache_expires_at_ms( &token_response, - token_grant.cache_ttl_seconds, - provider_credential_expires_at_ms(&provider, &subject_token.credential), + token_grant + .cache_ttl + .as_ref() + .map(openshell_core::time::duration_to_std) + .transpose() + .map_err(|error| Status::invalid_argument(error.to_string()))?, + provider_credential_expiration_ms(&provider, &subject_token.credential)? + .unwrap_or_default(), supervisor_claims.exp, ); if cache_expires_at_ms > crate::persistence::current_time_ms() { @@ -3942,7 +3957,10 @@ pub(super) async fn handle_exchange_provider_subject_token( Ok(Response::new(ExchangeProviderSubjectTokenResponse { access_token: token_response.access_token, - expires_in: token_response.expires_in, + expires_after: openshell_core::time::duration_from_std(std::time::Duration::from_secs( + u64::try_from(token_response.expires_in).unwrap_or_default(), + )) + .ok(), token_type: token_response.token_type, })) } @@ -3983,8 +4001,8 @@ fn ensure_subject_token_credential_not_expired( provider: &Provider, credential_key: &str, ) -> Result<(), Status> { - let expires_at_ms = provider_credential_expires_at_ms(provider, credential_key); - if expires_at_ms > 0 && expires_at_ms <= crate::persistence::current_time_ms() { + let expires_at_ms = provider_credential_expiration_ms(provider, credential_key)?; + if expires_at_ms.is_some_and(|value| value <= crate::persistence::current_time_ms()) { return Err(Status::failed_precondition( "subject token credential has expired", )); @@ -3992,12 +4010,16 @@ fn ensure_subject_token_credential_not_expired( Ok(()) } -fn provider_credential_expires_at_ms(provider: &Provider, credential_key: &str) -> i64 { +fn provider_credential_expiration_ms( + provider: &Provider, + credential_key: &str, +) -> Result, Status> { provider - .credential_expires_at_ms + .credential_expiration_times .get(credential_key) - .copied() - .unwrap_or_default() + .map(openshell_core::time::timestamp_to_millis) + .transpose() + .map_err(|error| Status::failed_precondition(error.to_string())) } struct IntermediateTokenCacheKeyInput<'a> { @@ -4044,25 +4066,31 @@ fn intermediate_token_cache_key(input: IntermediateTokenCacheKeyInput<'_>) -> St fn intermediate_token_cache_expires_at_ms( token: &oauth::OAuthTokenResponse, - cache_ttl_seconds: i64, + cache_ttl: Option, subject_token_expires_at_ms: i64, supervisor_svid_exp_seconds: i64, ) -> i64 { let now_ms = crate::persistence::current_time_ms(); - let mut ttl_seconds = if token.expires_in > 0 { + let default_ttl_seconds = if token.expires_in > 0 { token .expires_in .min(MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS) } else { DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS }; - if cache_ttl_seconds > 0 { - ttl_seconds = ttl_seconds.min(cache_ttl_seconds); - } - ttl_seconds = ttl_seconds - .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS) - .max(1); - let mut expires_at_ms = now_ms.saturating_add(ttl_seconds.saturating_mul(1000)); + let default_ttl = + std::time::Duration::from_secs(u64::try_from(default_ttl_seconds).unwrap_or(u64::MAX)); + let ttl = cache_ttl.map_or(default_ttl, |override_ttl| override_ttl.min(default_ttl)); + if ttl.is_zero() { + return now_ms; + } + let ttl = ttl + .saturating_sub(std::time::Duration::from_secs( + u64::try_from(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS).unwrap_or(u64::MAX), + )) + .max(std::time::Duration::from_millis(1)); + let ttl_ms = i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX); + let mut expires_at_ms = now_ms.saturating_add(ttl_ms); expires_at_ms = cap_cache_expiry_ms(expires_at_ms, jwt_exp_ms(&token.access_token)); expires_at_ms = cap_cache_expiry_ms(expires_at_ms, Some(subject_token_expires_at_ms)); expires_at_ms = cap_cache_expiry_ms( @@ -4415,14 +4443,12 @@ pub(super) async fn handle_configure_provider_refresh( "aws_session_token requires aws_access_key_id and aws_secret_access_key", )); } - if request - .expires_at_ms - .is_some_and(|expires_at_ms| expires_at_ms < 0) - { - return Err(Status::invalid_argument( - "expires_at_ms must be greater than or equal to 0", - )); - } + let requested_expiration_ms = request + .expiration_time + .as_ref() + .map(openshell_core::time::timestamp_to_millis) + .transpose() + .map_err(|error| Status::invalid_argument(error.to_string()))?; // Serialize the reserve-then-persist sequence against other configurations // and sandbox mutations. The collision validation below and the refresh-state @@ -4514,31 +4540,37 @@ pub(super) async fn handle_configure_provider_refresh( material_scopes }; let refresh_before_seconds = - crate::provider_refresh::parse_material_i64(&request.material, "refresh_before_seconds")? - .or_else(|| { - refresh_defaults - .as_ref() - .map(|refresh| refresh.refresh_before_seconds) - }) - .unwrap_or_default(); + crate::provider_refresh::parse_material_i64(&request.material, "refresh_before_seconds")?; let max_lifetime_seconds = - crate::provider_refresh::parse_material_i64(&request.material, "max_lifetime_seconds")? - .or_else(|| { - refresh_defaults - .as_ref() - .map(|refresh| refresh.max_lifetime_seconds) - }) - .unwrap_or_default(); - if refresh_before_seconds < 0 { + crate::provider_refresh::parse_material_i64(&request.material, "max_lifetime_seconds")?; + if refresh_before_seconds.is_some_and(|value| value < 0) { return Err(Status::invalid_argument( "refresh_before_seconds material must be greater than or equal to 0", )); } - if max_lifetime_seconds < 0 { + if max_lifetime_seconds.is_some_and(|value| value < 0) { return Err(Status::invalid_argument( "max_lifetime_seconds material must be greater than or equal to 0", )); } + let refresh_before = match refresh_before_seconds { + Some(0) => None, + Some(seconds) => Some(prost_types::Duration { seconds, nanos: 0 }), + None => refresh_defaults.as_ref().and_then(|refresh| { + refresh + .refresh_before_wkt + .to_proto(refresh.refresh_before_seconds) + }), + }; + let max_lifetime = match max_lifetime_seconds { + Some(0) => None, + Some(seconds) => Some(prost_types::Duration { seconds, nanos: 0 }), + None => refresh_defaults.as_ref().and_then(|refresh| { + refresh + .max_lifetime_wkt + .to_proto(refresh.max_lifetime_seconds) + }), + }; let existing_refresh_state = crate::provider_refresh::get_refresh_state( state.store.as_ref(), &workspace, @@ -4550,7 +4582,7 @@ pub(super) async fn handle_configure_provider_refresh( state .metadata .as_ref() - .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + .is_some_and(|metadata| metadata.deletion_time.is_some()) }) { return Err(Status::failed_precondition( "provider refresh is being deleted; retry deletion before configuring it again", @@ -4562,7 +4594,11 @@ pub(super) async fn handle_configure_provider_refresh( .as_ref() .map(|metadata| metadata.resource_version) }); - let expires_at_ms = request.expires_at_ms.unwrap_or_else(|| { + let has_expiration = requested_expiration_ms.is_some() + || existing_refresh_state + .as_ref() + .is_some_and(crate::provider_refresh::refresh_has_expiration); + let expires_at_ms = requested_expiration_ms.unwrap_or_else(|| { existing_refresh_state .as_ref() .map(|state| state.expires_at_ms) @@ -4588,8 +4624,8 @@ pub(super) async fn handle_configure_provider_refresh( expires_at_ms, token_url, scopes, - refresh_before_seconds, - max_lifetime_seconds, + refresh_before, + max_lifetime, additional_output_keys, }, )?; @@ -4607,6 +4643,7 @@ pub(super) async fn handle_configure_provider_refresh( ); } } + crate::provider_refresh::set_refresh_expiration_presence(&mut state_record, has_expiration); let material_staging_id = format!( "{}-refresh-config-{}", provider.object_id(), @@ -4658,22 +4695,26 @@ pub(super) async fn handle_configure_provider_refresh( return Err(err); } - if let Some(expires_at_ms) = request.expires_at_ms { + if let Some(expires_at_ms) = requested_expiration_ms { let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: provider_name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::from([(credential_key.to_string(), expires_at_ms)]), + credential_expiration_times: HashMap::from([( + credential_key.to_string(), + openshell_core::time::timestamp_from_millis(expires_at_ms) + .map_err(|error| Status::invalid_argument(error.to_string()))?, + )]), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -4744,16 +4785,14 @@ fn clear_refresh_owned_expiries( refresh_expires_at_ms: i64, owned_keys: &[String], ) { - if refresh_expires_at_ms <= 0 { - return; - } for key in owned_keys { if provider - .credential_expires_at_ms + .credential_expiration_times .get(key) - .is_some_and(|expires_at_ms| *expires_at_ms == refresh_expires_at_ms) + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .is_some_and(|expires_at_ms| expires_at_ms == refresh_expires_at_ms) { - provider.credential_expires_at_ms.remove(key); + provider.credential_expiration_times.remove(key); } } } @@ -4812,7 +4851,7 @@ pub(super) async fn handle_delete_provider_refresh( // from the snapshot read above would let a concurrent rotation or provider // update land between the read and the write and then be clobbered (CWE-362). if let Some(refresh_state) = existing_refresh_state - && refresh_state.expires_at_ms > 0 + && crate::provider_refresh::refresh_has_expiration(&refresh_state) { let refresh_expires_at_ms = refresh_state.expires_at_ms; let owned_keys: Vec = std::iter::once(credential_key.to_string()) @@ -4958,6 +4997,10 @@ mod tests { use openshell_core::{ObjectId, ObjectName}; use tonic::{Code, Request}; + fn ts(milliseconds: i64) -> prost_types::Timestamp { + openshell_core::time::timestamp_from_millis(milliseconds).unwrap() + } + #[test] fn env_key_validation_accepts_valid_keys() { assert!(is_valid_env_key("PATH")); @@ -4975,6 +5018,20 @@ mod tests { assert!(!is_valid_env_key("X;rm -rf /")); } + #[test] + fn subject_token_epoch_expiration_is_expired() { + let provider = Provider { + credential_expiration_times: HashMap::from([("SUBJECT_TOKEN".to_string(), ts(0))]), + ..Default::default() + }; + + let error = ensure_subject_token_credential_not_expired(&provider, "SUBJECT_TOKEN") + .expect_err("the Unix epoch is a present, expired timestamp"); + assert_eq!(error.code(), Code::FailedPrecondition); + ensure_subject_token_credential_not_expired(&provider, "UNSET_TOKEN") + .expect("an absent expiration remains non-expiring"); + } + #[test] fn create_validation_accepts_broker_only_credential_by_logical_name() { let profile = ProviderTypeProfile::from_proto(&ProviderProfile { @@ -5078,7 +5135,7 @@ mod tests { subject_token: None, scopes: vec!["openid".to_string()], requested_token_type: String::new(), - cache_ttl_seconds: 300, + cache_ttl: Some(prost_types::Duration { seconds: 300, nanos: 0 }), audience_overrides: service_audiences .iter() .map( @@ -5172,17 +5229,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -5257,12 +5314,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-import-ambiguity-id".to_string(), name: "sandbox-import-ambiguity".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec![ @@ -5399,7 +5456,7 @@ mod tests { let after_meta = after.metadata.unwrap(); assert_eq!(after_meta.id, before_meta.id); assert_eq!(after_meta.name, before_meta.name); - assert_eq!(after_meta.created_at_ms, before_meta.created_at_ms); + assert_eq!(after_meta.created_time, before_meta.created_time); assert_eq!(after_meta.labels, before_meta.labels); assert!(after_meta.resource_version > before_meta.resource_version); assert_eq!( @@ -5584,12 +5641,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-update-ambiguity-id".to_string(), name: "sandbox-update-ambiguity".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec![ @@ -5651,12 +5708,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: [ @@ -5671,7 +5728,7 @@ mod tests { ] .into_iter() .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } @@ -5686,7 +5743,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, ..Default::default() @@ -5694,7 +5751,7 @@ mod tests { r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: std::iter::once(( credential_key.to_string(), @@ -5718,7 +5775,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, ..Default::default() @@ -5726,7 +5783,7 @@ mod tests { r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), value.to_string())).collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } @@ -5774,8 +5831,14 @@ mod tests { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, token_url: "https://auth.example.com/token".to_string(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(prost_types::Duration { + seconds: 300, + nanos: 0, + }), + max_lifetime: Some(prost_types::Duration { + seconds: 3600, + nanos: 0, + }), additional_outputs: Vec::new(), material: vec![ ProviderCredentialRefreshMaterial { @@ -5856,7 +5919,10 @@ mod tests { subject_token: None, scopes: vec!["read".to_string()], requested_token_type: String::new(), - cache_ttl_seconds: 300, + cache_ttl: Some(prost_types::Duration { + seconds: 300, + nanos: 0, + }), audience_overrides: Vec::new(), }), } @@ -6075,6 +6141,98 @@ mod tests { assert_eq!(fetched.id, "custom-api"); } + #[tokio::test] + async fn import_provider_profile_preserves_exact_duration_values() { + let state = test_server_state().await; + let mut profile = custom_profile("duration-api"); + let mut credential = refreshable_credential("access_token", "ACCESS_TOKEN"); + let refresh = credential.refresh.as_mut().unwrap(); + refresh.refresh_before = Some(prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }); + refresh.max_lifetime = Some(prost_types::Duration { + seconds: 1, + nanos: 500_000_000, + }); + profile.credentials.push(credential); + + let response = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "duration-api.proto".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(response.imported, "diagnostics: {:?}", response.diagnostics); + + let fetched = handle_get_provider_profile( + &state, + authed_request(GetProviderProfileRequest { + id: "duration-api".to_string(), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .profile + .unwrap(); + let refresh = fetched.credentials[0].refresh.as_ref().unwrap(); + assert_eq!( + refresh.refresh_before, + Some(prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }) + ); + assert_eq!( + refresh.max_lifetime, + Some(prost_types::Duration { + seconds: 1, + nanos: 500_000_000, + }) + ); + } + + #[tokio::test] + async fn import_provider_profile_rejects_malformed_duration_values() { + let state = test_server_state().await; + let mut profile = custom_profile("invalid-duration-api"); + let mut credential = refreshable_credential("access_token", "ACCESS_TOKEN"); + credential.refresh.as_mut().unwrap().refresh_before = Some(prost_types::Duration { + seconds: 1, + nanos: -1, + }); + profile.credentials.push(credential); + + let response = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "invalid-duration-api.proto".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + + assert!(!response.imported); + assert!(response.diagnostics.iter().any(|diagnostic| { + diagnostic.field == "credentials.refresh.refresh_before" + && diagnostic.message.contains("valid non-negative duration") + })); + } + #[tokio::test] async fn profile_update_rejects_fanout_endpoint_ambiguity_without_persisting() { let state = test_server_state().await; @@ -6114,12 +6272,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "fanout-sandbox-id".to_string(), name: "fanout-sandbox".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["fanout-provider".to_string()], @@ -6623,12 +6781,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-id".to_string(), name: "sandbox-custom".to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: Vec::new(), @@ -6715,12 +6873,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "msgraph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -6729,7 +6887,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -6753,7 +6911,7 @@ mod tests { // profile; direct callers cannot opt a client secret out of // credential storage by omitting this advisory list. secret_material_keys: Vec::new(), - expires_at_ms: Some(expires_at_ms), + expiration_time: openshell_core::time::timestamp_from_millis(expires_at_ms).ok(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -6780,7 +6938,13 @@ mod tests { .unwrap() .into_inner(); assert_eq!(status.credentials.len(), 1); - assert_eq!(status.credentials[0].expires_at_ms, expires_at_ms); + assert_eq!( + status.credentials[0] + .expiration_time + .as_ref() + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()), + Some(expires_at_ms) + ); let provider = state .store @@ -6790,9 +6954,9 @@ mod tests { .expect("provider"); assert_eq!( provider - .credential_expires_at_ms + .credential_expiration_times .get("MS_GRAPH_ACCESS_TOKEN"), - Some(&expires_at_ms) + Some(&ts(expires_at_ms)) ); let first_refresh = crate::provider_refresh::get_refresh_state( @@ -6839,7 +7003,7 @@ mod tests { ("client_secret".to_string(), "client-secret".to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: Some(expires_at_ms), + expiration_time: openshell_core::time::timestamp_from_millis(0).ok(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -6861,6 +7025,43 @@ mod tests { first_refresh.authorization_epoch, second_refresh.authorization_epoch, "every explicit configuration starts a new authorization epoch" ); + assert!(crate::provider_refresh::refresh_has_expiration( + &second_refresh + )); + assert_eq!(second_refresh.expires_at_ms, 0); + + let epoch_status = handle_get_provider_refresh_status( + &state, + authed_request(GetProviderRefreshStatusRequest { + provider: "msgraph".to_string(), + credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), + }), + ) + .await + .unwrap() + .into_inner(); + assert_eq!( + epoch_status.credentials[0] + .expiration_time + .as_ref() + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()), + Some(0) + ); + let provider_with_epoch = state + .store + .get_message_by_name::("default", "msgraph") + .await + .unwrap() + .expect("provider"); + assert_eq!( + provider_with_epoch + .credential_expiration_times + .get("MS_GRAPH_ACCESS_TOKEN"), + Some(&ts(0)) + ); let deleted = handle_delete_provider_refresh( &state, @@ -6901,7 +7102,7 @@ mod tests { .expect("provider"); assert!( !provider_after_delete - .credential_expires_at_ms + .credential_expiration_times .contains_key("MS_GRAPH_ACCESS_TOKEN") ); } @@ -6936,7 +7137,7 @@ mod tests { ("client_secret".to_string(), client_secret.to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7058,7 +7259,7 @@ mod tests { ("client_secret".to_string(), client_secret.to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7148,12 +7349,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-authoritative-profiles-id".to_string(), name: "sandbox-authoritative-profiles".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -7185,7 +7386,7 @@ mod tests { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, material: HashMap::new(), secret_material_keys: Vec::new(), - expires_at_ms: Some(expires_at_ms), + expiration_time: openshell_core::time::timestamp_from_millis(expires_at_ms).ok(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7201,8 +7402,8 @@ mod tests { .unwrap() .expect("provider-a"); assert_eq!( - provider.credential_expires_at_ms.get("REFRESH_TOKEN"), - Some(&expires_at_ms) + provider.credential_expiration_times.get("REFRESH_TOKEN"), + Some(&ts(expires_at_ms)) ); } @@ -7214,17 +7415,20 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-a".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::from([("REFRESH_TOKEN".to_string(), expires_at_ms)]), + credential_expiration_times: HashMap::from([( + "REFRESH_TOKEN".to_string(), + ts(expires_at_ms), + )]), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -7248,8 +7452,8 @@ mod tests { expires_at_ms, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 0, - max_lifetime_seconds: 0, + refresh_before: None, + max_lifetime: None, additional_output_keys: HashMap::new(), }, ) @@ -7279,7 +7483,7 @@ mod tests { .expect("provider-a"); assert!( !provider - .credential_expires_at_ms + .credential_expiration_times .contains_key("REFRESH_TOKEN") ); } @@ -7294,12 +7498,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "vertex-sa".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -7308,7 +7512,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7333,7 +7537,7 @@ mod tests { ), ]), secret_material_keys: vec!["private_key".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7366,12 +7570,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "msgraph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -7380,7 +7584,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7401,7 +7605,8 @@ mod tests { ("client_secret".to_string(), "client-secret".to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: Some(refresh_expires_at_ms), + expiration_time: openshell_core::time::timestamp_from_millis(refresh_expires_at_ms) + .ok(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7418,19 +7623,19 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "msgraph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::from([( + credential_expiration_times: HashMap::from([( "MS_GRAPH_ACCESS_TOKEN".to_string(), - manual_expires_at_ms, + ts(manual_expires_at_ms), )]), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), @@ -7462,9 +7667,9 @@ mod tests { .expect("provider"); assert_eq!( provider_after_delete - .credential_expires_at_ms + .credential_expiration_times .get("MS_GRAPH_ACCESS_TOKEN"), - Some(&manual_expires_at_ms) + Some(&ts(manual_expires_at_ms)) ); } @@ -7478,17 +7683,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-delete".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7508,7 +7713,8 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: Some(refresh_expires_at_ms), + expiration_time: openshell_core::time::timestamp_from_millis(refresh_expires_at_ms) + .ok(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7528,19 +7734,25 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-delete".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::from([ - ("AWS_SECRET_ACCESS_KEY".to_string(), refresh_expires_at_ms), - ("AWS_SESSION_TOKEN".to_string(), independent_expires_at_ms), + credential_expiration_times: HashMap::from([ + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + ts(refresh_expires_at_ms), + ), + ( + "AWS_SESSION_TOKEN".to_string(), + ts(independent_expires_at_ms), + ), ]), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), @@ -7571,18 +7783,20 @@ mod tests { // Refresh-owned expiries for the primary and secret are cleared. assert!( !provider - .credential_expires_at_ms + .credential_expiration_times .contains_key("AWS_ACCESS_KEY_ID") ); assert!( !provider - .credential_expires_at_ms + .credential_expiration_times .contains_key("AWS_SECRET_ACCESS_KEY") ); // The independently updated session-token expiry is preserved. assert_eq!( - provider.credential_expires_at_ms.get("AWS_SESSION_TOKEN"), - Some(&independent_expires_at_ms) + provider + .credential_expiration_times + .get("AWS_SESSION_TOKEN"), + Some(&ts(independent_expires_at_ms)) ); } @@ -7600,20 +7814,23 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "p".to_string(), name: "p".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::from([ - ("AWS_ACCESS_KEY_ID".to_string(), refresh_expires_at_ms), - ("AWS_SECRET_ACCESS_KEY".to_string(), refresh_expires_at_ms), - ("AWS_SESSION_TOKEN".to_string(), concurrently_changed), + credential_expiration_times: HashMap::from([ + ("AWS_ACCESS_KEY_ID".to_string(), ts(refresh_expires_at_ms)), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + ts(refresh_expires_at_ms), + ), + ("AWS_SESSION_TOKEN".to_string(), ts(concurrently_changed)), ]), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), @@ -7628,17 +7845,30 @@ mod tests { assert!( !provider - .credential_expires_at_ms + .credential_expiration_times .contains_key("AWS_ACCESS_KEY_ID") ); assert!( !provider - .credential_expires_at_ms + .credential_expiration_times .contains_key("AWS_SECRET_ACCESS_KEY") ); assert_eq!( - provider.credential_expires_at_ms.get("AWS_SESSION_TOKEN"), - Some(&concurrently_changed) + provider + .credential_expiration_times + .get("AWS_SESSION_TOKEN"), + Some(&ts(concurrently_changed)) + ); + + provider + .credential_expiration_times + .insert("AWS_ACCESS_KEY_ID".to_string(), ts(0)); + clear_refresh_owned_expiries(&mut provider, 0, &owned_keys); + assert!( + !provider + .credential_expiration_times + .contains_key("AWS_ACCESS_KEY_ID"), + "an epoch expiry owned by the refresh must be cleared" ); } @@ -7653,12 +7883,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "existing-graph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -7667,7 +7897,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7681,18 +7911,18 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "refreshing-graph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(("OTHER_TOKEN".to_string(), "other".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7705,12 +7935,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-collision".to_string(), name: "collision".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -7733,7 +7963,7 @@ mod tests { ("client_secret".to_string(), "client-secret".to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7763,17 +7993,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7787,12 +8017,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-refresh-collision".to_string(), name: "refresh-collision".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["first-graph".to_string(), "second-graph".to_string()], @@ -7815,7 +8045,7 @@ mod tests { ("client_secret".to_string(), "client-secret".to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7836,7 +8066,7 @@ mod tests { ("client_secret".to_string(), "client-secret".to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7863,12 +8093,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "msgraph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: TEST_GRAPH_PROVIDER_TYPE.to_string(), credentials: std::iter::once(( @@ -7877,7 +8107,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7901,7 +8131,7 @@ mod tests { ), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7920,7 +8150,7 @@ mod tests { strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, material: HashMap::from([("tenant_id".to_string(), "tenant".to_string())]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -7942,12 +8172,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "msgraph".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -7956,7 +8186,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -7976,7 +8206,7 @@ mod tests { strategy: strategy as i32, material: HashMap::new(), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -8165,12 +8395,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "gitlab-local".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -8180,7 +8410,7 @@ mod tests { .collect(), config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string())) .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -8341,6 +8571,7 @@ mod tests { "default", &catalog, provider_with_credential_value("openai-local", "openai", "OPENAI_API_KEY", "sk-second"), + &[], Some(&credentials), ) .await @@ -8380,6 +8611,46 @@ mod tests { assert_eq!(result.get("OPENAI_API_KEY"), Some(&"sk-second".to_string())); } + #[tokio::test] + async fn update_provider_record_clears_credential_expiration_by_key() { + let store = test_store().await; + let mut provider = provider_with_values("legacy-provider", "legacy-custom"); + provider.credential_expiration_times.insert( + "API_TOKEN".to_string(), + openshell_core::time::timestamp_from_millis(1_700_000_000_000).unwrap(), + ); + create_provider_record(&store, "default", provider) + .await + .unwrap(); + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + + let updated = update_provider_record_validating( + &store, + "default", + &catalog, + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "legacy-provider".to_string(), + ..Default::default() + }), + ..Default::default() + }, + &["API_TOKEN".to_string()], + None, + ) + .await + .unwrap(); + + assert!( + !updated + .credential_expiration_times + .contains_key("API_TOKEN") + ); + } + #[tokio::test] async fn update_provider_record_with_runtime_preserves_legacy_inline_credentials_on_noop() { let store = test_store().await; @@ -8406,7 +8677,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "legacy-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, ..Default::default() @@ -8418,10 +8689,11 @@ mod tests { "https://updated.example.com".to_string(), )) .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }, + &[], Some(&credentials), ) .await @@ -8471,7 +8743,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "legacy-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, ..Default::default() @@ -8483,10 +8755,11 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }, + &[], Some(&credentials), ) .await @@ -8892,7 +9165,8 @@ mod tests { "openai", "OPENAI_API_KEY", )), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -8913,7 +9187,10 @@ mod tests { &store, "default", Provider { - credential_expires_at_ms: HashMap::from([("API_TOKEN".to_string(), 123_456)]), + credential_expiration_times: HashMap::from([( + "API_TOKEN".to_string(), + ts(123_456), + )]), ..provider_with_values("gitlab-local", "gitlab") }, ) @@ -8934,8 +9211,14 @@ mod tests { expires_at_ms: 123_456, token_url: "https://refresh.example.com/token".to_string(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(prost_types::Duration { + seconds: 300, + nanos: 0, + }), + max_lifetime: Some(prost_types::Duration { + seconds: 3600, + nanos: 0, + }), }, ) .unwrap(); @@ -8971,12 +9254,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-id".to_string(), name: "attached-sandbox".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["gitlab-local".to_string()], @@ -9021,12 +9304,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "test-provider".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "legacy-custom".to_string(), credentials: std::iter::once(( @@ -9035,7 +9318,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9056,12 +9339,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "test-provider".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "legacy-custom".to_string(), credentials: std::iter::once(( @@ -9070,7 +9353,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9096,17 +9379,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "bad-provider".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9122,17 +9405,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "gitlab-no-creds".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "gitlab".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9166,8 +9449,14 @@ mod tests { as i32, token_url: "https://login.example/token".to_string(), scopes: vec!["https://example.test/.default".to_string()], - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(prost_types::Duration { + seconds: 300, + nanos: 0, + }), + max_lifetime: Some(prost_types::Duration { + seconds: 3600, + nanos: 0, + }), additional_outputs: Vec::new(), material: vec![ ProviderCredentialRefreshMaterial { @@ -9207,17 +9496,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "delegated-refresh-no-token-yet".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "delegated-refresh-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9250,17 +9539,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "mixed-required-no-token-yet".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "mixed-required-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9293,17 +9582,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "optional-static-no-token-yet".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "optional-static-api".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9319,17 +9608,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "vertex-no-token-yet".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-vertex-ai".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9353,17 +9642,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "missing".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9389,17 +9678,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "noop-test".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9444,17 +9733,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "delete-key-test".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), config: std::iter::once(("region".to_string(), String::new())).collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9503,17 +9792,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "type-preserve-test".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9540,17 +9829,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "type-change-test".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9579,17 +9868,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "validate-merge-test".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9612,17 +9901,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: "legacy-oversized-type".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: oversized_type.clone(), credentials: std::iter::once(("API_TOKEN".to_string(), "old".to_string())).collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -9635,18 +9924,18 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "legacy-oversized-type".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: std::iter::once(("API_TOKEN".to_string(), "new".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -9673,12 +9962,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "claude-local".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: [ @@ -9692,7 +9981,7 @@ mod tests { "https://api.anthropic.com".to_string(), )) .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -9856,7 +10145,7 @@ mod tests { ("client_secret".to_string(), "client-secret".to_string()), ]), secret_material_keys: vec!["client_secret".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -9900,7 +10189,7 @@ mod tests { openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( revision_1, first.environment.clone(), - first.credential_expires_at_ms.clone(), + first.credential_expiration_times.clone(), first.dynamic_credentials.clone(), first.static_credential_bindings.clone(), Vec::new(), @@ -9923,7 +10212,8 @@ mod tests { )]), ..Default::default() }), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -9962,7 +10252,7 @@ mod tests { .install_bound_environment( revision_1, unchanged_environment.environment, - unchanged_environment.credential_expires_at_ms, + unchanged_environment.credential_expiration_times, unchanged_environment.dynamic_credentials, unchanged_environment.static_credential_bindings, Vec::new(), @@ -10018,7 +10308,7 @@ mod tests { .install_bound_environment( revision_2, second.environment.clone(), - second.credential_expires_at_ms.clone(), + second.credential_expiration_times.clone(), second.dynamic_credentials.clone(), second.static_credential_bindings.clone(), Vec::new(), @@ -10035,7 +10325,7 @@ mod tests { openshell_core::provider_credentials::ProviderCredentialState::from_bound_environment( revision_2, second.environment.clone(), - second.credential_expires_at_ms.clone(), + second.credential_expiration_times.clone(), second.dynamic_credentials.clone(), second.static_credential_bindings.clone(), Vec::new(), @@ -10082,7 +10372,7 @@ mod tests { .install_bound_environment( revision_3, third.environment, - third.credential_expires_at_ms, + third.credential_expiration_times, third.dynamic_credentials, third.static_credential_bindings, Vec::new(), @@ -10110,8 +10400,8 @@ mod tests { "GCP_ADC_ACCESS_TOKEN".to_string(), "google-token".to_string(), )]); - google_cloud.credential_expires_at_ms = - HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), expires_at_ms)]); + google_cloud.credential_expiration_times = + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), ts(expires_at_ms))]); create_provider_record(&store, "default", google_cloud) .await .unwrap(); @@ -10138,7 +10428,7 @@ mod tests { ); assert!( !result - .credential_expires_at_ms + .credential_expiration_times .contains_key("GCP_ADC_ACCESS_TOKEN"), "withheld static credentials must not retain expiry metadata" ); @@ -10580,24 +10870,26 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "expiring-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "test".to_string(), credentials: [ ("FRESH_TOKEN".to_string(), "fresh".to_string()), ("STALE_TOKEN".to_string(), "stale".to_string()), + ("EPOCH_TOKEN".to_string(), "epoch".to_string()), ] .into_iter() .collect(), config: HashMap::new(), - credential_expires_at_ms: [ - ("FRESH_TOKEN".to_string(), now_ms + 60_000), - ("STALE_TOKEN".to_string(), now_ms - 60_000), + credential_expiration_times: [ + ("FRESH_TOKEN".to_string(), ts(now_ms + 60_000)), + ("STALE_TOKEN".to_string(), ts(now_ms - 60_000)), + ("EPOCH_TOKEN".to_string(), ts(0)), ] .into_iter() .collect(), @@ -10614,8 +10906,9 @@ mod tests { .unwrap(); assert_eq!(result.get("FRESH_TOKEN"), Some(&"fresh".to_string())); assert!(!result.contains_key("STALE_TOKEN")); + assert!(!result.contains_key("EPOCH_TOKEN")); assert_eq!( - result.credential_expires_at_ms.get("FRESH_TOKEN"), + result.credential_expiration_times.get("FRESH_TOKEN"), Some(&(now_ms + 60_000)) ); } @@ -10637,12 +10930,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "test-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "test".to_string(), credentials: [ @@ -10653,7 +10946,7 @@ mod tests { .into_iter() .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -10680,12 +10973,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "claude-local".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -10694,7 +10987,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -10708,18 +11001,18 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "gitlab-local".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "gitlab".to_string(), credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -10748,18 +11041,18 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-a".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -10773,12 +11066,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-b".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -10787,7 +11080,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -10824,7 +11117,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-a".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, ..Default::default() @@ -10833,7 +11126,7 @@ mod tests { credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }, @@ -10874,12 +11167,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "google-config".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-cloud".to_string(), credentials: std::iter::once(( @@ -10889,7 +11182,7 @@ mod tests { .collect(), config: std::iter::once(("project_id".to_string(), "config-project".to_string())) .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -10903,12 +11196,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "static-credential".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -10917,7 +11210,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -10963,12 +11256,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "vertex-local".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -10985,7 +11278,7 @@ mod tests { ] .into_iter() .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11043,12 +11336,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "vertex-bootstrap".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -11064,7 +11357,7 @@ mod tests { .into_iter() .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11094,12 +11387,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "vertex-no-config".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-vertex-ai".to_string(), credentials: std::iter::once(( @@ -11108,7 +11401,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11149,12 +11442,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "vertex-collision".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-vertex-ai".to_string(), credentials: [ @@ -11173,7 +11466,7 @@ mod tests { ] .into_iter() .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11204,18 +11497,18 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "openai-local".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "openai".to_string(), credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-test".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11248,12 +11541,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-a".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "outlook".to_string(), credentials: std::iter::once(( @@ -11262,7 +11555,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11276,12 +11569,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-b".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-drive".to_string(), credentials: std::iter::once(( @@ -11290,7 +11583,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11301,12 +11594,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-collision".to_string(), name: "collision".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["provider-a".to_string(), "provider-b".to_string()], @@ -11323,12 +11616,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-b".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: std::iter::once(( @@ -11337,7 +11630,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11360,12 +11653,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "google-config".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-cloud".to_string(), credentials: std::iter::once(( @@ -11375,7 +11668,7 @@ mod tests { .collect(), config: std::iter::once(("project_id".to_string(), "config-project".to_string())) .collect(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11389,12 +11682,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "credential-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "gitlab".to_string(), credentials: std::iter::once(( @@ -11403,7 +11696,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11415,12 +11708,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-plugin-config-collision".to_string(), name: "plugin-config-collision".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec![ @@ -11441,12 +11734,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "credential-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: std::iter::once(( @@ -11455,7 +11748,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11482,12 +11775,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "my-claude".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: std::iter::once(( @@ -11496,7 +11789,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -11508,12 +11801,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-001".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["my-claude".to_string()], @@ -11548,12 +11841,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-002".to_string(), name: "empty-sandbox".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec::default()), status: None, @@ -11599,17 +11892,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "test-validate-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), // Empty type is ignored in update credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -11765,7 +12058,8 @@ mod tests { &state, authed_request(UpdateProviderRequest { provider: Some(update), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -11814,7 +12108,8 @@ mod tests { &state, authed_request(UpdateProviderRequest { provider: Some(update), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -11869,7 +12164,8 @@ mod tests { &state, authed_request(UpdateProviderRequest { provider: Some(updated_provider.clone()), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -11939,7 +12235,8 @@ mod tests { &state, authed_request(UpdateProviderRequest { provider: Some(stale_provider), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12016,7 +12313,8 @@ mod tests { &state, authed_request(UpdateProviderRequest { provider: Some(stale_provider), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12092,7 +12390,8 @@ mod tests { &state_clone, authed_request(UpdateProviderRequest { provider: Some(updated), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12155,12 +12454,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "my-aws-v2".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: std::iter::once(( @@ -12169,7 +12468,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12188,7 +12487,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12217,17 +12516,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-endpoint-override".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12252,7 +12551,7 @@ mod tests { ), ]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12294,17 +12593,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-partial-source".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12328,7 +12627,7 @@ mod tests { ("aws_access_key_id".to_string(), "AKIATESTKEY".to_string()), ]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12351,17 +12650,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-lone-session".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12386,7 +12685,7 @@ mod tests { ), ]), secret_material_keys: vec!["aws_session_token".to_string()], - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12409,17 +12708,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-outputs".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12438,7 +12737,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12518,7 +12817,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12544,7 +12843,8 @@ mod tests { credentials: HashMap::from([(key.to_string(), value.to_string())]), ..Default::default() }), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), + clear_credential_expiration_keys: Vec::new(), workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12579,12 +12879,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "generic-no-profile".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "generic".to_string(), credentials: std::iter::once(( @@ -12593,7 +12893,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12612,7 +12912,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12635,17 +12935,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-wrong-key".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12666,7 +12966,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12689,17 +12989,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "aws-env".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12733,8 +13033,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 0, - max_lifetime_seconds: 0, + refresh_before: None, + max_lifetime: None, }, ) .unwrap(); @@ -12762,17 +13062,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "existing-aws-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -12788,12 +13088,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "new-aws-provider".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: std::iter::once(( @@ -12802,7 +13102,7 @@ mod tests { )) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -12816,12 +13116,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-aws-configure-collision".to_string(), name: "aws-configure-collision".to_string(), - created_at_ms: 1, + created_time: openshell_core::time::timestamp_from_millis(1).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec![ @@ -12846,7 +13146,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12870,17 +13170,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "aws".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, @@ -12897,12 +13197,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "sandbox-concurrent-configure".to_string(), name: "concurrent-configure".to_string(), - created_at_ms: 1, + created_time: openshell_core::time::timestamp_from_millis(1).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["aws-a".to_string(), "aws-b".to_string()], @@ -12923,7 +13223,7 @@ mod tests { "arn:aws:iam::123456789012:role/Test".to_string(), )]), secret_material_keys: Vec::new(), - expires_at_ms: None, + expiration_time: None, workspace_scope: Some(openshell_core::proto::workspace_selector( "default".to_string(), )), @@ -12957,17 +13257,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "my-google-cloud".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "google-cloud".to_string(), credentials: HashMap::new(), config, - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } @@ -13064,17 +13364,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "github".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "github".to_string(), credentials: HashMap::new(), config: HashMap::from([("project_id".to_string(), "should-be-ignored".to_string())]), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -13112,7 +13412,7 @@ mod tests { r#type: "pypi".to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -13125,12 +13425,12 @@ mod tests { p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "shared-name".to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }); p }), @@ -13157,12 +13457,12 @@ mod tests { p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "shared-name".to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }); p }), @@ -13299,12 +13599,12 @@ mod tests { p.metadata = Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "provider-d".to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }); p }), @@ -13436,17 +13736,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "cross-ws".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "other-workspace".to_string(), credential_handles: HashMap::new(), }; @@ -13464,17 +13764,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "global-profile".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: String::new(), credential_handles: HashMap::new(), }; @@ -13491,17 +13791,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "same-ws-profile".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -13518,17 +13818,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "immutable-pw".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "claude".to_string(), credentials: HashMap::from([("ANTHROPIC_API_KEY".to_string(), "sk-123".to_string())]), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }; @@ -13540,17 +13840,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "immutable-pw".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "other".to_string(), credential_handles: HashMap::new(), }; @@ -13623,17 +13923,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: "uses-ws".to_string(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: "ws-custom".to_string(), credentials: HashMap::from([("TOKEN".to_string(), "val".to_string())]), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), }, diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 75b07f8c9b..e806529fbe 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -172,7 +172,7 @@ pub(super) async fn handle_create_sandbox( request: Request, ) -> Result, Status> { let create_request = request.get_ref().clone(); - let result = handle_create_sandbox_inner(state, request).await; + let result = Box::pin(handle_create_sandbox_inner(state, request)).await; let created_sandbox = result .as_ref() .ok() @@ -221,7 +221,9 @@ pub(super) async fn handle_begin_rootfs_tar_staging( staging_token: slot.token, upload_path: slot.upload_path.to_string_lossy().into_owned(), max_bytes: slot.max_bytes, - expires_at_ms: slot.expires_at_ms, + expiration_time: openshell_core::time::timestamp_from_millis(slot.expires_at_ms) + .map(Some) + .map_err(|error| Status::internal(error.to_string()))?, })) } @@ -463,12 +465,12 @@ async fn handle_create_sandbox_inner( metadata: Some(ObjectMeta { id: id.clone(), name: name.clone(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: request.labels.clone(), resource_version: 0, annotations: request.annotations.clone(), workspace, - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(spec), status: None, @@ -804,12 +806,12 @@ pub(super) async fn handle_create_sandbox_template( resolved.metadata = Some(ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: metadata.name, - created_at_ms: current_time_ms(), + created_time: openshell_core::time::timestamp_from_millis(current_time_ms()).ok(), labels: metadata.labels, resource_version: 0, annotations: metadata.annotations, workspace: workspace.clone(), - deletion_timestamp_ms: 0, + deletion_time: None, }); validate_sandbox_workload_template(&resolved)?; @@ -1610,7 +1612,11 @@ pub(super) async fn handle_watch_sandbox( req.log_tail_lines }; let stop_on_terminal = req.stop_on_terminal; - let log_since_ms = req.log_since_ms; + if let Some(since_time) = req.since_time.as_ref() { + openshell_core::time::validate_timestamp(since_time) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + } + let log_since_time = req.since_time; let log_sources = req.log_sources; let log_min_level = req.log_min_level; let event_tail = req.event_tail; @@ -1694,15 +1700,25 @@ pub(super) async fn handle_watch_sandbox( } } - // Replay tail logs (best-effort), filtered by log_since_ms and log_sources. + // Replay tail logs (best-effort), filtered by log_since_time and log_sources. if follow_logs { for evt in state.tracing_log_bus.tail(&sandbox_id, log_tail as usize) { if let Some(openshell_core::proto::sandbox_stream_event::Payload::Log( ref log, )) = evt.payload { - if log_since_ms > 0 && log.timestamp_ms < log_since_ms { - continue; + if let Some(since_time) = log_since_time.as_ref() { + let Some(event_time) = log.event_time.as_ref() else { + continue; + }; + let Ok(ordering) = + openshell_core::time::compare_timestamps(event_time, since_time) + else { + continue; + }; + if ordering == std::cmp::Ordering::Less { + continue; + } } if !log_sources.is_empty() && !source_matches(&log.source, &log_sources) { continue; @@ -1884,7 +1900,12 @@ pub(super) async fn handle_exec_sandbox( let command_str = build_remote_exec_command(&req) .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; let stdin_payload = req.stdin; - let timeout_seconds = req.timeout_seconds; + let execution_timeout = req + .execution_timeout + .as_ref() + .map(openshell_core::time::duration_to_std) + .transpose() + .map_err(|error| Status::invalid_argument(error.to_string()))?; let request_tty = req.tty; let (cols, rows) = pty_dimensions(req.cols, req.rows); @@ -1908,7 +1929,7 @@ pub(super) async fn handle_exec_sandbox( relay_stream, &command_str, stdin_payload, - timeout_seconds, + execution_timeout, request_tty, no_login_shell, cols, @@ -2084,9 +2105,11 @@ async fn validate_ssh_forward_token( return Err(Status::unauthenticated("SSH session token is not valid")); } - if session.expires_at_ms > 0 { + if let Some(expiration_time) = session.expiration_time.as_ref() { let now_ms = current_time_ms(); - if now_ms > session.expires_at_ms { + let expires_at_ms = openshell_core::time::timestamp_to_millis(expiration_time) + .map_err(|error| Status::internal(error.to_string()))?; + if now_ms > expires_at_ms { return Err(Status::unauthenticated("SSH session token expired")); } } @@ -2323,7 +2346,12 @@ pub(super) async fn handle_exec_sandbox_interactive( .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; let request_tty = req.tty; let no_login_shell = req.no_login_shell; - let timeout_seconds = req.timeout_seconds; + let execution_timeout = req + .execution_timeout + .as_ref() + .map(openshell_core::time::duration_to_std) + .transpose() + .map_err(|error| Status::invalid_argument(error.to_string()))?; let (cols, rows) = pty_dimensions(req.cols, req.rows); let sandbox_id = sandbox.object_id().to_string(); @@ -2351,7 +2379,7 @@ pub(super) async fn handle_exec_sandbox_interactive( input_stream, request_tty, no_login_shell, - timeout_seconds, + execution_timeout, cols, rows, ) @@ -2403,17 +2431,18 @@ pub(super) async fn handle_create_ssh_session( metadata: Some(ObjectMeta { id: token.clone(), name: generate_name(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: sandbox.object_workspace().to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: req.sandbox_id.clone(), token: token.clone(), revoked: false, - expires_at_ms, + expiration_time: openshell_core::time::optional_timestamp_from_legacy_millis(expires_at_ms) + .map_err(|error| Status::internal(error.to_string()))?, }; // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) @@ -2457,7 +2486,8 @@ pub(super) async fn handle_create_ssh_session( gateway_port: gateway_port.into(), gateway_scheme: scheme.to_string(), host_key_fingerprint: String::new(), - expires_at_ms, + expiration_time: openshell_core::time::optional_timestamp_from_legacy_millis(expires_at_ms) + .map_err(|error| Status::internal(error.to_string()))?, })) } @@ -2643,7 +2673,7 @@ async fn stream_exec_over_relay( relay_stream: tokio::io::DuplexStream, command: &str, stdin_payload: Vec, - timeout_seconds: u32, + execution_timeout: Option, request_tty: bool, no_login_shell: bool, cols: u32, @@ -2677,25 +2707,22 @@ async fn stream_exec_over_relay( tx.clone(), ); - let exec_result = if timeout_seconds == 0 { - exec.await - } else if let Ok(r) = tokio::time::timeout( - std::time::Duration::from_secs(u64::from(timeout_seconds)), - exec, - ) - .await - { - r + let exec_result = if let Some(execution_timeout) = execution_timeout { + if let Ok(result) = tokio::time::timeout(execution_timeout, exec).await { + result + } else { + let _ = tx + .send(Ok(ExecSandboxEvent { + payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Exit( + ExecSandboxExit { exit_code: 124 }, + )), + })) + .await; + let _ = proxy_task.await; + return Ok(()); + } } else { - let _ = tx - .send(Ok(ExecSandboxEvent { - payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Exit( - ExecSandboxExit { exit_code: 124 }, - )), - })) - .await; - let _ = proxy_task.await; - return Ok(()); + exec.await }; let exit_code = match exec_result { @@ -2729,7 +2756,7 @@ async fn stream_interactive_exec_over_relay( input_stream: tonic::Streaming, request_tty: bool, no_login_shell: bool, - timeout_seconds: u32, + execution_timeout: Option, cols: u32, rows: u32, ) -> Result<(), Status> { @@ -2761,25 +2788,22 @@ async fn stream_interactive_exec_over_relay( tx.clone(), ); - let exec_result = if timeout_seconds == 0 { - exec.await - } else if let Ok(r) = tokio::time::timeout( - std::time::Duration::from_secs(u64::from(timeout_seconds)), - exec, - ) - .await - { - r + let exec_result = if let Some(execution_timeout) = execution_timeout { + if let Ok(result) = tokio::time::timeout(execution_timeout, exec).await { + result + } else { + let _ = tx + .send(Ok(ExecSandboxEvent { + payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Exit( + ExecSandboxExit { exit_code: 124 }, + )), + })) + .await; + let _ = proxy_task.await; + return Ok(()); + } } else { - let _ = tx - .send(Ok(ExecSandboxEvent { - payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Exit( - ExecSandboxExit { exit_code: 124 }, - )), - })) - .await; - let _ = proxy_task.await; - return Ok(()); + exec.await }; let exit_code = match exec_result { @@ -3580,18 +3604,18 @@ mod tests { metadata: Some(ObjectMeta { id: format!("provider-{name}"), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: std::iter::once((credential_key.to_string(), "secret".to_string())) .collect(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } @@ -3602,12 +3626,12 @@ mod tests { metadata: Some(ObjectMeta { id: format!("sandbox-{name}"), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: std::iter::once(("team".to_string(), "agents".to_string())).collect(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { log_level: "debug".to_string(), @@ -3627,12 +3651,12 @@ mod tests { metadata: Some(ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 0, + created_time: openshell_core::time::timestamp_from_millis(0).ok(), labels: HashMap::from([("team".to_string(), "runtime".to_string())]), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(openshell_core::proto::SandboxWorkloadTemplateSpec { workload: Some(openshell_core::proto::SandboxWorkloadConfig { diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index a691ec0ecf..f46c5b426f 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -77,7 +77,9 @@ pub(super) async fn handle_expose_service( existing .metadata .as_ref() - .map_or(now, |metadata| metadata.created_at_ms), + .and_then(|metadata| metadata.created_time.as_ref()) + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .unwrap_or(now), WriteCondition::MatchResourceVersion(resource_version), false, ) @@ -101,12 +103,12 @@ pub(super) async fn handle_expose_service( metadata: Some(ObjectMeta { id: id.clone(), name: key.clone(), - created_at_ms, + created_time: openshell_core::time::timestamp_from_millis(created_at_ms).ok(), labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), resource_version: 0, annotations: HashMap::new(), workspace: workspace.clone(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: sandbox.object_id().to_string(), sandbox_name: req.sandbox.clone(), @@ -376,12 +378,12 @@ mod tests { metadata: Some(ObjectMeta { id: format!("sandbox-{name}"), name: name.to_string(), - created_at_ms: 1_000, + created_time: openshell_core::time::timestamp_from_millis(1_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() @@ -703,12 +705,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sandbox-my-sandbox-beta".to_string(), name: "my-sandbox".to_string(), - created_at_ms: 1_000, + created_time: openshell_core::time::timestamp_from_millis(1_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "beta".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(openshell_core::proto::SandboxSpec::default()), ..Default::default() diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 85bf1371a8..8a81e2648c 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -571,31 +571,28 @@ pub(super) fn validate_provider_mutable_fields(provider: &Provider) -> Result<() MAX_MAP_VALUE_LEN, "provider.config", )?; - if provider.credential_expires_at_ms.len() > MAX_PROVIDER_CREDENTIALS_ENTRIES { + if provider.credential_expiration_times.len() > MAX_PROVIDER_CREDENTIALS_ENTRIES { return Err(invalid_argument( - "provider.credential_expires_at_ms", + "provider.credential_expiration_times", format!( - "provider.credential_expires_at_ms exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})", - provider.credential_expires_at_ms.len() + "provider.credential_expiration_times exceeds maximum entries ({} > {MAX_PROVIDER_CREDENTIALS_ENTRIES})", + provider.credential_expiration_times.len() ), )); } - for (key, value) in &provider.credential_expires_at_ms { + for (key, value) in &provider.credential_expiration_times { if key.len() > MAX_MAP_KEY_LEN { return Err(invalid_argument( - "provider.credential_expires_at_ms", + "provider.credential_expiration_times", format!( - "provider.credential_expires_at_ms key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})", + "provider.credential_expiration_times key exceeds maximum length ({} > {MAX_MAP_KEY_LEN})", key.len() ), )); } - if *value < 0 { - return Err(invalid_argument( - "provider.credential_expires_at_ms", - "provider.credential_expires_at_ms value must be greater than or equal to 0", - )); - } + openshell_core::time::validate_timestamp(value).map_err(|error| { + invalid_argument("provider.credential_expiration_times", error.to_string()) + })?; } Ok(()) } @@ -1591,17 +1588,17 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials, config, - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index c18819be52..82440c1b59 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -28,7 +28,10 @@ use crate::persistence::{ DRAFT_CHUNK_OBJECT_TYPE, ObjectLabels, ObjectListQuery, ObjectType, POLICY_OBJECT_TYPE, WriteCondition, current_time_ms, }; -use crate::storage_proto::{StoredProviderCredentialRefreshState, StoredProviderProfile}; +use crate::storage_proto::{ + StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState, + StoredProviderProfile, +}; use std::collections::HashMap; pub const WORKSPACE_OBJECT_TYPE: &str = "workspace"; @@ -131,7 +134,7 @@ pub async fn resolve_workspace( let terminating = ws .metadata .as_ref() - .is_some_and(|m| m.deletion_timestamp_ms != 0); + .is_some_and(|m| m.deletion_time.is_some()); Ok(ResolvedWorkspace { name, terminating }) } None => Err(Status::not_found(format!("workspace '{name}' not found"))), @@ -153,12 +156,12 @@ pub(super) async fn handle_create_workspace( metadata: Some(ObjectMeta { id: workspace_id.clone(), name: req.name, - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: req.labels, annotations: HashMap::new(), resource_version: 0, workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), status: Some(WorkspaceStatus { phase: WorkspacePhase::Active.into(), @@ -315,7 +318,7 @@ pub(super) async fn handle_delete_workspace( let already_terminating = ws .metadata .as_ref() - .is_some_and(|m| m.deletion_timestamp_ms != 0); + .is_some_and(|m| m.deletion_time.is_some()); // Track the resource_version so the final delete targets exactly this // workspace instance (prevents ABA if a same-name workspace is recreated @@ -328,7 +331,7 @@ pub(super) async fn handle_delete_workspace( .update_message_cas::(&ws_id, 0, |w| { let now_ms = current_time_ms(); if let Some(meta) = w.metadata.as_mut() { - meta.deletion_timestamp_ms = now_ms; + meta.deletion_time = openshell_core::time::timestamp_from_millis(now_ms).ok(); } w.status = Some(WorkspaceStatus { phase: WorkspacePhase::Terminating.into(), @@ -352,7 +355,7 @@ pub(super) async fn handle_delete_workspace( let now_terminating = refreshed .metadata .as_ref() - .is_some_and(|m| m.deletion_timestamp_ms != 0); + .is_some_and(|m| m.deletion_time.is_some()); if !now_terminating { return Err(Status::aborted( "workspace was concurrently modified, please retry", @@ -496,12 +499,12 @@ pub(super) async fn handle_add_workspace_member( metadata: Some(ObjectMeta { id: member_id.clone(), name: req.principal_subject.clone(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: workspace.clone(), - deletion_timestamp_ms: 0, + deletion_time: None, }), principal_subject: req.principal_subject, role: req.role, @@ -657,10 +660,10 @@ mod tests { let meta = ws.metadata.as_ref().unwrap(); assert_eq!(meta.name, "new-ws"); assert!(!meta.id.is_empty(), "id should be a generated UUID"); - assert!(meta.created_at_ms > 0, "created_at_ms should be set"); + assert!(meta.created_time.is_some(), "created_time should be set"); assert_eq!(meta.labels.get("env").map(String::as_str), Some("test")); assert!(meta.resource_version > 0, "resource_version should be set"); - assert_eq!(meta.deletion_timestamp_ms, 0); + assert!(meta.deletion_time.is_none()); let status = ws.status.as_ref().unwrap(); assert_eq!(status.phase, i32::from(WorkspacePhase::Active)); @@ -784,12 +787,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sbx-eph-1".to_string(), name: "blocker".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "ephemeral".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }; @@ -846,12 +849,12 @@ mod tests { metadata: Some(ObjectMeta { id: "template-1".to_string(), name: "gpu-kata".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "templated".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, }; @@ -912,17 +915,17 @@ mod tests { metadata: Some(ObjectMeta { id: "ssh-1".to_string(), name: "session-ssh-1".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "sessioned".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: "sbx-1".to_string(), token: "ssh-1".to_string(), revoked: false, - expires_at_ms: 0, + expiration_time: None, }; state.store.put_message(&session).await.unwrap(); @@ -960,12 +963,12 @@ mod tests { metadata: Some(ObjectMeta { id: "prof-1".to_string(), name: "my-profile".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "profiles-ws".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }; @@ -1267,12 +1270,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sbx-term-1".to_string(), name: "blocker".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "term-test".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }; @@ -1294,11 +1297,7 @@ mod tests { .await .unwrap() .unwrap(); - assert_ne!( - ws.metadata.as_ref().unwrap().deletion_timestamp_ms, - 0, - "workspace should have deletion_timestamp set" - ); + assert!(ws.metadata.as_ref().unwrap().deletion_time.is_some()); assert_eq!( ws.status.as_ref().unwrap().phase, i32::from(WorkspacePhase::Terminating), @@ -1323,12 +1322,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sbx-dying-1".to_string(), name: "hold".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "dying-ws".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }; @@ -1368,12 +1367,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sbx-idem-1".to_string(), name: "temp".to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: "idempotent-ws".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() }; @@ -1438,11 +1437,7 @@ mod tests { .await .unwrap() .expect("workspace must remain durable after cleanup failure"); - assert_ne!( - retained.metadata.unwrap().deletion_timestamp_ms, - 0, - "retained workspace must remain terminating" - ); + assert!(retained.metadata.unwrap().deletion_time.is_some()); let retry = handle_delete_workspace( &state, diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index b086177530..d68c93c6b6 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1716,12 +1716,15 @@ pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { metadata: Some(ObjectMeta { id: id.clone(), name: DEFAULT_WORKSPACE_NAME.to_string(), - created_at_ms: persistence::current_time_ms(), + created_time: openshell_core::time::timestamp_from_millis( + persistence::current_time_ms(), + ) + .ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), status: Some(openshell_core::proto::datamodel::v1::WorkspaceStatus { phase: openshell_core::proto::datamodel::v1::WorkspacePhase::Active.into(), diff --git a/crates/openshell-server/src/persistence/legacy_time_wire.rs b/crates/openshell-server/src/persistence/legacy_time_wire.rs new file mode 100644 index 0000000000..d5f12bab1b --- /dev/null +++ b/crates/openshell-server/src/persistence/legacy_time_wire.rs @@ -0,0 +1,645 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Compatibility rewriting for protobuf records written before time fields used WKTs. + +use prost::Message; +use prost_reflect::{DescriptorPool, Kind, MessageDescriptor}; +use std::sync::LazyLock; + +use super::{PersistenceError, PersistenceResult}; + +static DESCRIPTORS: LazyLock = LazyLock::new(|| { + let mut pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET) + .expect("the embedded public protobuf descriptor set must be valid"); + pool.decode_file_descriptor_set(crate::storage_proto::STORAGE_FILE_DESCRIPTOR_SET) + .expect("the embedded storage protobuf descriptor set must be valid"); + pool +}); + +#[derive(Clone, Copy)] +enum Conversion { + Timestamp { new_tag: u32 }, + TimestampString { new_tag: u32 }, + DurationSeconds { new_tag: u32 }, + DurationString { new_tag: u32 }, + TimestampMap { new_tag: u32 }, +} + +pub(super) fn migrate(object_type: &str, payload: &[u8]) -> PersistenceResult> { + let Some(message_name) = root_message_name(object_type) else { + return Ok(payload.to_vec()); + }; + let descriptor = DESCRIPTORS + .get_message_by_name(message_name) + .ok_or_else(|| { + PersistenceError::Decode(format!("missing descriptor for {message_name}")) + })?; + rewrite_message(&descriptor, payload) +} + +fn root_message_name(object_type: &str) -> Option<&'static str> { + match object_type { + "sandbox" => Some("openshell.v1.Sandbox"), + "provider" => Some("openshell.datamodel.v1.Provider"), + "workspace" => Some("openshell.datamodel.v1.Workspace"), + "workspace_member" => Some("openshell.v1.WorkspaceMember"), + "provider_profile" => Some("openshell.storage.v1.StoredProviderProfile"), + "provider_credential_refresh_state" => { + Some("openshell.storage.v1.StoredProviderCredentialRefreshStateV2") + } + "service_endpoint" => Some("openshell.v1.ServiceEndpoint"), + "ssh_session" => Some("openshell.v1.SshSession"), + "sandbox_workload_template" => Some("openshell.v1.SandboxWorkloadTemplate"), + "sandbox_policy" => Some("openshell.storage.v1.PolicyRevisionPayload"), + "draft_policy_chunk" => Some("openshell.storage.v1.DraftChunkPayload"), + _ => None, + } +} + +fn rewrite_message(descriptor: &MessageDescriptor, input: &[u8]) -> PersistenceResult> { + let mut output = Vec::with_capacity(input.len()); + let mut offset = 0; + while offset < input.len() { + let field_start = offset; + let (key, key_len) = read_varint(&input[offset..])?; + offset += key_len; + let field_number = u32::try_from(key >> 3) + .map_err(|_| PersistenceError::Decode("protobuf field number overflow".into()))?; + let wire_type = (key & 7) as u8; + let (payload_start, payload_end, field_end) = field_bounds(input, offset, wire_type)?; + + if let Some(conversion) = conversion(descriptor.full_name(), field_number) { + rewrite_legacy_field( + &mut output, + conversion, + wire_type, + &input[payload_start..payload_end], + )?; + } else if wire_type == 2 + && let Some(field) = descriptor.get_field(field_number) + && !field.is_map() + && let Kind::Message(child) = field.kind() + { + let rewritten = rewrite_message(&child, &input[payload_start..payload_end])?; + write_key(&mut output, field_number, 2); + write_varint(&mut output, rewritten.len() as u64); + output.extend_from_slice(&rewritten); + } else { + output.extend_from_slice(&input[field_start..field_end]); + } + offset = field_end; + } + Ok(output) +} + +fn rewrite_legacy_field( + output: &mut Vec, + conversion: Conversion, + wire_type: u8, + payload: &[u8], +) -> PersistenceResult<()> { + match conversion { + Conversion::Timestamp { new_tag } => { + require_wire_type(wire_type, 0)?; + let (raw, consumed) = read_varint(payload)?; + if consumed != payload.len() { + return Err(PersistenceError::Decode("invalid legacy timestamp".into())); + } + let millis = raw.cast_signed(); + if millis != 0 { + let timestamp = openshell_core::time::timestamp_from_millis(millis) + .map_err(|error| PersistenceError::Decode(error.to_string()))?; + write_embedded(output, new_tag, ×tamp.encode_to_vec()); + } + } + Conversion::TimestampString { new_tag } => { + require_wire_type(wire_type, 2)?; + if !payload.is_empty() { + let value = std::str::from_utf8(payload).map_err(|error| { + PersistenceError::Decode(format!("legacy timestamp is not UTF-8: {error}")) + })?; + // Legacy sandbox conditions accepted arbitrary driver-provided + // strings. Preserve upgrade availability by dropping values + // that cannot be represented as protobuf timestamps. + if let Ok(timestamp) = value.parse::() + && openshell_core::time::validate_timestamp(×tamp).is_ok() + { + write_embedded(output, new_tag, ×tamp.encode_to_vec()); + } + } + } + Conversion::DurationSeconds { new_tag } => { + require_wire_type(wire_type, 0)?; + let (seconds, consumed) = read_varint(payload)?; + if consumed != payload.len() { + return Err(PersistenceError::Decode("invalid legacy duration".into())); + } + if seconds != 0 { + let seconds = i64::try_from(seconds).map_err(|_| { + PersistenceError::Decode("legacy duration exceeds protobuf range".into()) + })?; + let duration = prost_types::Duration { seconds, nanos: 0 }; + openshell_core::time::validate_duration(&duration) + .map_err(|error| PersistenceError::Decode(error.to_string()))?; + write_embedded(output, new_tag, &duration.encode_to_vec()); + } + } + Conversion::DurationString { new_tag } => { + require_wire_type(wire_type, 2)?; + if !payload.is_empty() { + let value = std::str::from_utf8(payload).map_err(|error| { + PersistenceError::Decode(format!("legacy duration is not UTF-8: {error}")) + })?; + let duration = parse_legacy_duration(value)?; + let duration = openshell_core::time::duration_from_std(duration) + .map_err(|error| PersistenceError::Decode(error.to_string()))?; + write_embedded(output, new_tag, &duration.encode_to_vec()); + } + } + Conversion::TimestampMap { new_tag } => { + require_wire_type(wire_type, 2)?; + if let Some(rewritten) = rewrite_timestamp_map_entry(payload)? { + write_embedded(output, new_tag, &rewritten); + } + } + } + Ok(()) +} + +fn parse_legacy_duration(value: &str) -> PersistenceResult { + let (number, millis_multiplier) = value + .strip_suffix("ms") + .map(|number| (number, 1u64)) + .or_else(|| value.strip_suffix('s').map(|number| (number, 1_000u64))) + .ok_or_else(|| PersistenceError::Decode("legacy duration must end in ms or s".into()))?; + let amount = number + .parse::() + .map_err(|error| PersistenceError::Decode(format!("invalid legacy duration: {error}")))?; + let millis = amount + .checked_mul(millis_multiplier) + .ok_or_else(|| PersistenceError::Decode("legacy duration overflow".into()))?; + Ok(std::time::Duration::from_millis(millis)) +} + +fn rewrite_timestamp_map_entry(input: &[u8]) -> PersistenceResult>> { + let mut output = Vec::with_capacity(input.len() + 8); + let mut has_expiration = false; + let mut offset = 0; + while offset < input.len() { + let start = offset; + let (key, key_len) = read_varint(&input[offset..])?; + offset += key_len; + let number = u32::try_from(key >> 3) + .map_err(|_| PersistenceError::Decode("protobuf field number overflow".into()))?; + let wire_type = (key & 7) as u8; + let (payload_start, payload_end, field_end) = field_bounds(input, offset, wire_type)?; + if number == 2 { + require_wire_type(wire_type, 0)?; + let (raw, consumed) = read_varint(&input[payload_start..payload_end])?; + if consumed != payload_end - payload_start { + return Err(PersistenceError::Decode( + "invalid legacy expiration map".into(), + )); + } + let millis = raw.cast_signed(); + if millis != 0 { + let timestamp = openshell_core::time::timestamp_from_millis(millis) + .map_err(|error| PersistenceError::Decode(error.to_string()))?; + write_embedded(&mut output, 2, ×tamp.encode_to_vec()); + has_expiration = true; + } + } else { + output.extend_from_slice(&input[start..field_end]); + } + offset = field_end; + } + Ok(has_expiration.then_some(output)) +} + +fn conversion(message: &str, field: u32) -> Option { + use Conversion::{ + DurationSeconds as D, DurationString as DS, Timestamp as T, TimestampMap as M, + TimestampString as TS, + }; + match (message, field) { + ("openshell.datamodel.v1.ObjectMeta", 3) => Some(T { new_tag: 103 }), + ("openshell.datamodel.v1.ObjectMeta", 8) => Some(T { new_tag: 108 }), + ("openshell.v1.SshSession", 4) => Some(T { new_tag: 104 }), + ("openshell.datamodel.v1.Provider", 5) => Some(M { new_tag: 105 }), + ("openshell.v1.SandboxCondition", 5) => Some(TS { new_tag: 105 }), + ("openshell.v1.EndpointStatus", 6) => Some(TS { new_tag: 106 }), + ("openshell.v1.PlatformEvent", 1) => Some(T { new_tag: 101 }), + ( + "openshell.v1.ProviderCredentialTokenGrant" | "openshell.v1.ProviderCredentialRefresh", + 4, + ) => Some(D { new_tag: 104 }), + ("openshell.v1.ProviderCredentialRefresh", 5) => Some(D { new_tag: 105 }), + ("openshell.storage.v1.StoredProviderCredentialRefreshStateV2", 15) => { + Some(D { new_tag: 115 }) + } + ("openshell.storage.v1.StoredProviderCredentialRefreshStateV2", 16) => { + Some(D { new_tag: 116 }) + } + ("openshell.sandbox.v1.MiddlewareBinding", 4) => Some(DS { new_tag: 104 }), + _ => None, + } +} + +fn field_bounds( + input: &[u8], + value_offset: usize, + wire_type: u8, +) -> PersistenceResult<(usize, usize, usize)> { + match wire_type { + 0 => { + let (_, len) = read_varint(&input[value_offset..])?; + Ok((value_offset, value_offset + len, value_offset + len)) + } + 1 => checked_fixed_bounds(input, value_offset, 8), + 2 => { + let (len, prefix_len) = read_varint(&input[value_offset..])?; + let start = value_offset + prefix_len; + let len = usize::try_from(len) + .map_err(|_| PersistenceError::Decode("protobuf length overflow".into()))?; + let end = start + .checked_add(len) + .filter(|end| *end <= input.len()) + .ok_or_else(|| PersistenceError::Decode("truncated protobuf field".into()))?; + Ok((start, end, end)) + } + 5 => checked_fixed_bounds(input, value_offset, 4), + _ => Err(PersistenceError::Decode(format!( + "unsupported protobuf wire type {wire_type}" + ))), + } +} + +fn checked_fixed_bounds( + input: &[u8], + value_offset: usize, + len: usize, +) -> PersistenceResult<(usize, usize, usize)> { + let end = value_offset + .checked_add(len) + .filter(|end| *end <= input.len()) + .ok_or_else(|| PersistenceError::Decode("truncated protobuf field".into()))?; + Ok((value_offset, end, end)) +} + +fn read_varint(input: &[u8]) -> PersistenceResult<(u64, usize)> { + let mut value = 0u64; + for (index, byte) in input.iter().copied().take(10).enumerate() { + value |= u64::from(byte & 0x7f) << (index * 7); + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + } + Err(PersistenceError::Decode("invalid protobuf varint".into())) +} + +fn write_key(output: &mut Vec, field_number: u32, wire_type: u8) { + write_varint( + output, + (u64::from(field_number) << 3) | u64::from(wire_type), + ); +} + +fn write_varint(output: &mut Vec, mut value: u64) { + while value >= 0x80 { + output.push(value.to_le_bytes()[0] | 0x80); + value >>= 7; + } + output.push(value.to_le_bytes()[0]); +} + +fn write_embedded(output: &mut Vec, field_number: u32, payload: &[u8]) { + write_key(output, field_number, 2); + write_varint(output, payload.len() as u64); + output.extend_from_slice(payload); +} + +fn require_wire_type(actual: u8, expected: u8) -> PersistenceResult<()> { + if actual == expected { + Ok(()) + } else { + Err(PersistenceError::Decode(format!( + "legacy time field has wire type {actual}, expected {expected}" + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage_proto::{ + StoredProviderCredentialRefreshState, StoredProviderCredentialRefreshStateV2, + }; + use openshell_core::proto::{ + EndpointStatus, Provider, SandboxCondition, SandboxWorkloadTemplate, SshSession, + }; + use std::collections::HashMap; + + #[derive(Clone, PartialEq, Message)] + struct LegacyObjectMeta { + #[prost(string, tag = "1")] + id: String, + #[prost(string, tag = "2")] + name: String, + #[prost(int64, tag = "3")] + created_at_ms: i64, + #[prost(int64, tag = "8")] + deletion_timestamp_ms: i64, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacyProvider { + #[prost(message, optional, tag = "1")] + metadata: Option, + #[prost(string, tag = "2")] + r#type: String, + #[prost(map = "string, int64", tag = "5")] + credential_expires_at_ms: HashMap, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacySandboxCondition { + #[prost(string, tag = "1")] + r#type: String, + #[prost(string, tag = "2")] + status: String, + #[prost(string, tag = "3")] + reason: String, + #[prost(string, tag = "4")] + message: String, + #[prost(string, tag = "5")] + last_transition_time: String, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacyEndpointStatus { + #[prost(string, tag = "1")] + endpoint_id: String, + #[prost(string, tag = "2")] + host: String, + #[prost(uint32, repeated, tag = "3")] + ports: Vec, + #[prost(string, tag = "4")] + path: String, + #[prost(enumeration = "openshell_core::proto::EndpointResult", tag = "5")] + last_result: i32, + #[prost(string, tag = "6")] + last_reported_at: String, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacySshSession { + #[prost(message, optional, tag = "1")] + metadata: Option, + #[prost(string, tag = "2")] + sandbox_id: String, + #[prost(string, tag = "3")] + token: String, + #[prost(int64, tag = "4")] + expires_at_ms: i64, + #[prost(bool, tag = "5")] + revoked: bool, + } + + #[derive(Clone, PartialEq, Message)] + struct LegacySandboxWorkloadTemplate { + #[prost(message, optional, tag = "1")] + metadata: Option, + } + + #[test] + fn migrates_nested_metadata_and_timestamp_maps() { + let legacy = LegacyProvider { + metadata: Some(LegacyObjectMeta { + id: "provider-id".into(), + name: "provider-name".into(), + created_at_ms: 1_700_000_000_123, + deletion_timestamp_ms: 1_700_000_001_456, + }), + r#type: "test".into(), + credential_expires_at_ms: HashMap::from([ + ("TOKEN".into(), 1_700_000_002_789), + ("NO_EXPIRY".into(), 0), + ]), + }; + + let migrated = migrate("provider", &legacy.encode_to_vec()).unwrap(); + let provider = Provider::decode(migrated.as_slice()).unwrap(); + let metadata = provider.metadata.unwrap(); + assert_eq!( + openshell_core::time::timestamp_to_millis(&metadata.created_time.unwrap()).unwrap(), + 1_700_000_000_123 + ); + assert_eq!( + openshell_core::time::timestamp_to_millis(&metadata.deletion_time.unwrap()).unwrap(), + 1_700_000_001_456 + ); + assert_eq!( + openshell_core::time::timestamp_to_millis( + provider.credential_expiration_times.get("TOKEN").unwrap() + ) + .unwrap(), + 1_700_000_002_789 + ); + assert!( + !provider + .credential_expiration_times + .contains_key("NO_EXPIRY") + ); + } + + #[test] + fn migrates_ssh_session_expiration() { + let legacy = LegacySshSession { + metadata: Some(LegacyObjectMeta { + id: "session-id".into(), + name: "session-name".into(), + created_at_ms: 1_700_000_000_123, + deletion_timestamp_ms: 0, + }), + sandbox_id: "sandbox-id".into(), + token: "token".into(), + expires_at_ms: 1_700_000_001_456, + revoked: false, + }; + + let migrated = migrate("ssh_session", &legacy.encode_to_vec()).unwrap(); + let session = SshSession::decode(migrated.as_slice()).unwrap(); + + assert_eq!( + openshell_core::time::timestamp_to_millis(&session.expiration_time.unwrap()).unwrap(), + 1_700_000_001_456 + ); + } + + #[test] + fn migrates_sandbox_workload_template_metadata() { + let legacy = LegacySandboxWorkloadTemplate { + metadata: Some(LegacyObjectMeta { + id: "template-id".into(), + name: "template-name".into(), + created_at_ms: 1_700_000_000_123, + deletion_timestamp_ms: 1_700_000_001_456, + }), + }; + + let migrated = migrate("sandbox_workload_template", &legacy.encode_to_vec()).unwrap(); + let template = SandboxWorkloadTemplate::decode(migrated.as_slice()).unwrap(); + let metadata = template.metadata.unwrap(); + + assert_eq!( + openshell_core::time::timestamp_to_millis(&metadata.created_time.unwrap()).unwrap(), + 1_700_000_000_123 + ); + assert_eq!( + openshell_core::time::timestamp_to_millis(&metadata.deletion_time.unwrap()).unwrap(), + 1_700_000_001_456 + ); + } + + #[test] + fn rejects_malformed_legacy_time_wire_type() { + let descriptor = DESCRIPTORS + .get_message_by_name("openshell.datamodel.v1.ObjectMeta") + .unwrap(); + // Legacy field 3 encoded as length-delimited instead of int64 varint. + let error = rewrite_message(&descriptor, &[0x1a, 0x01, 0x00]).unwrap_err(); + assert!(error.to_string().contains("wire type")); + } + + #[test] + fn rejects_out_of_range_legacy_timestamps() { + let legacy = LegacyObjectMeta { + id: "invalid".into(), + name: "invalid".into(), + created_at_ms: i64::MAX, + deletion_timestamp_ms: 0, + }; + let descriptor = DESCRIPTORS + .get_message_by_name("openshell.datamodel.v1.ObjectMeta") + .unwrap(); + let error = rewrite_message(&descriptor, &legacy.encode_to_vec()).unwrap_err(); + assert!(error.to_string().contains("timestamp")); + } + + #[test] + fn drops_non_rfc3339_legacy_condition_transition_time() { + let legacy = LegacySandboxCondition { + r#type: "Ready".into(), + status: "Unknown".into(), + reason: "DriverPending".into(), + message: String::new(), + last_transition_time: "driver-clock-pending".into(), + }; + let descriptor = DESCRIPTORS + .get_message_by_name("openshell.v1.SandboxCondition") + .unwrap(); + + let migrated = rewrite_message(&descriptor, &legacy.encode_to_vec()).unwrap(); + let condition = SandboxCondition::decode(migrated.as_slice()).unwrap(); + + assert_eq!(condition.r#type, "Ready"); + assert!(condition.transition_time.is_none()); + } + + #[test] + fn migrates_endpoint_last_reported_time() { + let legacy = LegacyEndpointStatus { + endpoint_id: "endpoint:v1:test".into(), + host: "api.example.com".into(), + ports: vec![443], + path: "/mcp".into(), + last_result: openshell_core::proto::EndpointResult::HttpResponseReceived as i32, + last_reported_at: "2026-09-05T01:01:00.123456789Z".into(), + }; + let descriptor = DESCRIPTORS + .get_message_by_name("openshell.v1.EndpointStatus") + .unwrap(); + + let migrated = rewrite_message(&descriptor, &legacy.encode_to_vec()).unwrap(); + let endpoint = EndpointStatus::decode(migrated.as_slice()).unwrap(); + + assert_eq!( + endpoint.last_reported_time.unwrap().to_string(), + "2026-09-05T01:01:00.123456789Z" + ); + } + + #[test] + fn migrates_refresh_state_durations_to_v2() { + let legacy = StoredProviderCredentialRefreshState { + provider_id: "provider-id".into(), + refresh_before_seconds: 30, + max_lifetime_seconds: 3600, + ..Default::default() + }; + + let migrated = + migrate("provider_credential_refresh_state", &legacy.encode_to_vec()).unwrap(); + let state = StoredProviderCredentialRefreshStateV2::decode(migrated.as_slice()).unwrap(); + + assert_eq!( + state.refresh_before, + Some(prost_types::Duration { + seconds: 30, + nanos: 0, + }) + ); + assert_eq!( + state.max_lifetime, + Some(prost_types::Duration { + seconds: 3600, + nanos: 0, + }) + ); + } + + #[test] + fn public_time_fields_use_well_known_types() { + let private_storage_messages = [ + "openshell.storage.v1.StoredProviderCredentialRefreshState", + "openshell.storage.v1.StoredProviderCredentialRefreshStateV2", + "openshell.storage.v1.PolicyRevisionPayload", + "openshell.storage.v1.DraftChunkPayload", + "openshell.storage.v1.StoredPolicyRevision", + "openshell.storage.v1.StoredDraftChunk", + "openshell.internal.pagination.v1.ObjectCursor", + ]; + let mut violations = Vec::new(); + for message in DESCRIPTORS.all_messages() { + if private_storage_messages.contains(&message.full_name()) { + continue; + } + for field in message.fields() { + let name = field.name(); + let looks_temporal = name.ends_with("_ms") + || name.ends_with("_secs") + || name.ends_with("_seconds") + || (name.ends_with("_at") && matches!(field.kind(), Kind::String)) + || name == "timeout" + || name == "expires_in" + || name == "last_transition_time"; + if looks_temporal + && !matches!( + field.kind(), + Kind::Message(ref descriptor) + if descriptor.full_name() == "google.protobuf.Timestamp" + || descriptor.full_name() == "google.protobuf.Duration" + ) + { + violations.push(format!("{}.{}", message.full_name(), name)); + } + } + } + assert!( + violations.is_empty(), + "public scalar time fields remain: {}", + violations.join(", ") + ); + } +} diff --git a/crates/openshell-server/src/persistence/mod.rs b/crates/openshell-server/src/persistence/mod.rs index e22f5f99b9..0b365ccd0c 100644 --- a/crates/openshell-server/src/persistence/mod.rs +++ b/crates/openshell-server/src/persistence/mod.rs @@ -3,6 +3,7 @@ //! Persistence layer for `OpenShell` Server. +mod legacy_time_wire; mod postgres; mod sqlite; @@ -201,6 +202,10 @@ pub trait ObjectType { fn object_type() -> &'static str; } +pub fn migrate_legacy_time_fields(object_type: &str, payload: &[u8]) -> PersistenceResult> { + legacy_time_wire::migrate(object_type, payload) +} + // Import object metadata accessor traits from openshell-core. Implementations // for public resource types live there; private storage types implement them // in crate::storage_proto. @@ -227,10 +232,11 @@ pub fn generate_name() -> String { /// Extracted to avoid repeating the identical decode-and-hydrate block across /// `get_message`, `get_message_by_name`, `list_messages`, and /// `list_messages_with_selector`. -fn decode_record( +fn decode_record( record: ObjectRecord, ) -> PersistenceResult { - let mut message = T::decode(record.payload.as_slice()) + let payload = legacy_time_wire::migrate(T::object_type(), &record.payload)?; + let mut message = T::decode(payload.as_slice()) .map_err(|e| PersistenceError::Decode(format!("protobuf decode error: {e}")))?; message.set_resource_version(record.resource_version); Ok(message) diff --git a/crates/openshell-server/src/persistence/postgres.rs b/crates/openshell-server/src/persistence/postgres.rs index 3fa54151b5..c0be94e0c9 100644 --- a/crates/openshell-server/src/persistence/postgres.rs +++ b/crates/openshell-server/src/persistence/postgres.rs @@ -49,7 +49,44 @@ impl PostgresStore { POSTGRES_MIGRATOR .run(&self.pool) .await - .map_err(|e| map_migrate_error(&e)) + .map_err(|e| map_migrate_error(&e))?; + self.migrate_legacy_time_payloads().await + } + + async fn migrate_legacy_time_payloads(&self) -> PersistenceResult<()> { + let mut transaction = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + // Serialize this application-level data migration across gateway replicas. + sqlx::query("SELECT pg_advisory_xact_lock(3052)") + .execute(&mut *transaction) + .await + .map_err(|e| map_db_error(&e))?; + let rows = + sqlx::query("SELECT id, object_type, payload FROM objects ORDER BY id FOR UPDATE") + .fetch_all(&mut *transaction) + .await + .map_err(|e| map_db_error(&e))?; + + for row in rows { + let id: String = row.try_get("id").map_err(|e| map_db_error(&e))?; + let object_type: String = row.try_get("object_type").map_err(|e| map_db_error(&e))?; + let payload: Vec = row.try_get("payload").map_err(|e| map_db_error(&e))?; + let migrated = + super::legacy_time_wire::migrate(&object_type, &payload).map_err(|error| { + PersistenceError::Migration(format!( + "failed to migrate {object_type} record {id}: {error}" + )) + })?; + if migrated != payload { + sqlx::query("UPDATE objects SET payload = $1 WHERE id = $2") + .bind(migrated) + .bind(id) + .execute(&mut *transaction) + .await + .map_err(|e| map_db_error(&e))?; + } + } + + transaction.commit().await.map_err(|e| map_db_error(&e)) } /// Verify the database is reachable by acquiring a pooled connection diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index 4c274386ca..d9f22bcbbe 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -136,7 +136,38 @@ impl SqliteStore { SQLITE_MIGRATOR .run(&self.pool) .await - .map_err(|e| map_migrate_error(&e)) + .map_err(|e| map_migrate_error(&e))?; + self.migrate_legacy_time_payloads().await + } + + async fn migrate_legacy_time_payloads(&self) -> PersistenceResult<()> { + let mut transaction = self.pool.begin().await.map_err(|e| map_db_error(&e))?; + let rows = sqlx::query("SELECT id, object_type, payload FROM objects ORDER BY id") + .fetch_all(&mut *transaction) + .await + .map_err(|e| map_db_error(&e))?; + + for row in rows { + let id: String = row.try_get("id").map_err(|e| map_db_error(&e))?; + let object_type: String = row.try_get("object_type").map_err(|e| map_db_error(&e))?; + let payload: Vec = row.try_get("payload").map_err(|e| map_db_error(&e))?; + let migrated = + super::legacy_time_wire::migrate(&object_type, &payload).map_err(|error| { + PersistenceError::Migration(format!( + "failed to migrate {object_type} record {id}: {error}" + )) + })?; + if migrated != payload { + sqlx::query("UPDATE objects SET payload = ?1 WHERE id = ?2") + .bind(migrated) + .bind(id) + .execute(&mut *transaction) + .await + .map_err(|e| map_db_error(&e))?; + } + } + + transaction.commit().await.map_err(|e| map_db_error(&e)) } /// Verify the database is reachable by acquiring a pooled connection diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index a292926270..49fa192094 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -1082,7 +1082,7 @@ fn policy_test_sandbox(id: &str, name: &str) -> Sandbox { metadata: Some(ProtoObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1, + created_time: openshell_core::time::timestamp_from_millis(1).ok(), workspace: "default".to_string(), ..Default::default() }), @@ -1954,12 +1954,12 @@ async fn cas_update_message_cas_succeeds() { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "test-id".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1000, + created_time: openshell_core::time::timestamp_from_millis(1000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, status: None, @@ -1997,12 +1997,12 @@ async fn cas_update_message_cas_conflicts_on_concurrent_updates() { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "test-id".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1000, + created_time: openshell_core::time::timestamp_from_millis(1000).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, status: None, @@ -2068,12 +2068,12 @@ async fn cas_update_message_cas_rejects_workspace_change() { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "ws-immutable".to_string(), name: "test-sandbox".to_string(), - created_at_ms: 1000, + created_time: openshell_core::time::timestamp_from_millis(1000).ok(), labels: std::collections::HashMap::new(), annotations: std::collections::HashMap::new(), resource_version: 0, workspace: "alpha".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, status: None, @@ -2111,12 +2111,12 @@ async fn cas_update_message_cas_rejects_name_change() { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "name-immutable".to_string(), name: "original".to_string(), - created_at_ms: 1000, + created_time: openshell_core::time::timestamp_from_millis(1000).ok(), labels: std::collections::HashMap::new(), annotations: std::collections::HashMap::new(), resource_version: 0, workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: None, status: None, diff --git a/crates/openshell-server/src/policy_store.rs b/crates/openshell-server/src/policy_store.rs index 8b986db763..464957b44f 100644 --- a/crates/openshell-server/src/policy_store.rs +++ b/crates/openshell-server/src/policy_store.rs @@ -68,7 +68,8 @@ pub fn project_policy_revision_onto_sandbox( }); } - let mut sandbox = Sandbox::decode(payload) + let payload = crate::persistence::migrate_legacy_time_fields("sandbox", payload)?; + let mut sandbox = Sandbox::decode(payload.as_slice()) .map_err(|e| PersistenceError::Decode(format!("decode sandbox payload failed: {e}")))?; sandbox.set_resource_version(current_resource_version); diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 83081c5d5e..819d642b1e 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -780,12 +780,12 @@ pub fn stored_provider_profile(profile: ProviderProfile) -> StoredProviderProfil metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: profile.id.clone(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: String::new(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(profile), } @@ -1638,12 +1638,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: proto.id.clone(), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: std::collections::HashMap::new(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), profile: Some(proto), } diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 1fcf84eebb..43a70ee89d 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -22,9 +22,13 @@ use std::time::Duration; use tonic::{Code, Status}; use tracing::{info, warn}; -use crate::storage_proto::{StoredProviderCredentialRefreshState, StoredRefreshMaterialDeletion}; +use crate::storage_proto::{ + StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState, + StoredRefreshMaterialDeletion, +}; const DEFAULT_REFRESH_BEFORE_SECONDS: i64 = 300; +const EXPIRATION_PRESENT_ANNOTATION: &str = "openshell.nvidia.com/refresh-expiration-present"; const DEFAULT_MAX_LIFETIME_SECONDS: i64 = 3600; const REFRESH_ERROR_RETRY_SECONDS: i64 = 60; const REFRESH_CONFIGURATION_RETRY_SECONDS: i64 = 60 * 60; @@ -218,10 +222,11 @@ pub async fn delete_refresh_state_with_credentials( if state .metadata .as_ref() - .is_some_and(|metadata| metadata.deletion_timestamp_ms == 0) + .is_some_and(|metadata| metadata.deletion_time.is_none()) { if let Some(metadata) = state.metadata.as_mut() { - metadata.deletion_timestamp_ms = current_time_ms(); + metadata.deletion_time = + openshell_core::time::timestamp_from_millis(current_time_ms()).ok(); } state.authorization_epoch = uuid::Uuid::new_v4().to_string(); state.status = "deleting".to_string(); @@ -290,14 +295,64 @@ pub fn refresh_status_from_state( credential_key: state.credential_key.clone(), strategy: state.strategy, status: state.status.clone(), - expires_at_ms: state.expires_at_ms, - next_refresh_at_ms: state.next_refresh_at_ms, - last_refresh_at_ms: state.last_refresh_at_ms, + expiration_time: if refresh_has_expiration(state) { + openshell_core::time::timestamp_from_millis(state.expires_at_ms).ok() + } else { + None + }, + next_refresh_time: if state.next_refresh_at_ms == i64::MAX { + None + } else { + openshell_core::time::optional_timestamp_from_legacy_millis(state.next_refresh_at_ms) + .ok() + .flatten() + }, + last_refresh_time: openshell_core::time::optional_timestamp_from_legacy_millis( + state.last_refresh_at_ms, + ) + .ok() + .flatten(), last_error: state.last_error.clone(), recovery_action: state.recovery_action, failure_code: state.failure_code.clone(), provider_error_subtype: state.provider_error_subtype.clone(), - last_error_at_ms: state.last_error_at_ms, + last_error_time: openshell_core::time::optional_timestamp_from_legacy_millis( + state.last_error_at_ms, + ) + .ok() + .flatten(), + } +} + +/// Whether a refresh expiration was explicitly provided. +/// +/// Legacy records infer presence from a nonzero millisecond value. New records +/// use a private metadata annotation only for the ambiguous Unix epoch value so +/// the frozen storage protobuf schema does not need to change. +pub fn refresh_has_expiration(state: &StoredProviderCredentialRefreshState) -> bool { + state.expires_at_ms != 0 + || state.metadata.as_ref().is_some_and(|metadata| { + metadata + .annotations + .get(EXPIRATION_PRESENT_ANNOTATION) + .is_some_and(|value| value == "true") + }) +} + +pub fn set_refresh_expiration_presence( + state: &mut StoredProviderCredentialRefreshState, + present: bool, +) { + let Some(metadata) = state.metadata.as_mut() else { + return; + }; + if present && state.expires_at_ms == 0 { + metadata.annotations.insert( + EXPIRATION_PRESENT_ANNOTATION.to_string(), + "true".to_string(), + ); + } else { + metadata.annotations.remove(EXPIRATION_PRESENT_ANNOTATION); } } @@ -308,8 +363,8 @@ pub struct NewRefreshStateConfig { pub expires_at_ms: i64, pub token_url: String, pub scopes: Vec, - pub refresh_before_seconds: i64, - pub max_lifetime_seconds: i64, + pub refresh_before: Option, + pub max_lifetime: Option, /// Resolved semantic output id -> concrete env key for credentials this /// refresh co-mints beyond its primary. Pinned from the profile's /// `additional_outputs` at configure time. @@ -323,25 +378,34 @@ pub fn new_refresh_state( credential_key: &str, config: NewRefreshStateConfig, ) -> Result { + if let Some(value) = config.refresh_before.as_ref() { + openshell_core::time::duration_to_std(value) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + } + if let Some(value) = config.max_lifetime.as_ref() { + let duration = openshell_core::time::duration_to_std(value) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + if duration.is_zero() { + return Err(Status::invalid_argument( + "max_lifetime must be greater than zero when present", + )); + } + } let provider_id = provider.object_id().to_string(); let provider_name = provider.object_name().to_string(); let now_ms = current_time_ms(); - let next_refresh_at_ms = next_refresh_at_ms( - config.expires_at_ms, - config.refresh_before_seconds, - config.max_lifetime_seconds, - now_ms, - ); + let next_refresh_at_ms = + next_refresh_at_ms(config.expires_at_ms, config.refresh_before.as_ref()); Ok(StoredProviderCredentialRefreshState { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: uuid::Uuid::new_v4().to_string(), name: refresh_state_name(&provider_id, credential_key), - created_at_ms: now_ms, + created_time: openshell_core::time::timestamp_from_millis(now_ms).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), provider_id, provider_name, @@ -356,8 +420,8 @@ pub fn new_refresh_state( last_error: String::new(), token_url: config.token_url, scopes: config.scopes, - refresh_before_seconds: config.refresh_before_seconds, - max_lifetime_seconds: config.max_lifetime_seconds, + refresh_before: config.refresh_before, + max_lifetime: config.max_lifetime, additional_output_keys: config.additional_output_keys, authorization_epoch: uuid::Uuid::new_v4().to_string(), secret_material_handles: HashMap::new(), @@ -521,21 +585,39 @@ struct GoogleServiceAccountClaims<'a> { pub fn next_refresh_at_ms( expires_at_ms: i64, - refresh_before_seconds: i64, - _max_lifetime_seconds: i64, - _now_ms: i64, + refresh_before: Option<&prost_types::Duration>, ) -> i64 { - let refresh_before_seconds = if refresh_before_seconds > 0 { - refresh_before_seconds - } else { - DEFAULT_REFRESH_BEFORE_SECONDS - }; + let refresh_before = refresh_before + .and_then(|value| openshell_core::time::duration_to_std(value).ok()) + .unwrap_or_else(|| { + Duration::from_secs(u64::try_from(DEFAULT_REFRESH_BEFORE_SECONDS).unwrap_or(u64::MAX)) + }); + let refresh_before_ms = refresh_before.as_millis().saturating_add(u128::from( + !refresh_before.subsec_nanos().is_multiple_of(1_000_000), + )); + let refresh_before_ms = i64::try_from(refresh_before_ms).unwrap_or(i64::MAX); if expires_at_ms > 0 { - return expires_at_ms.saturating_sub(refresh_before_seconds.saturating_mul(1000)); + return expires_at_ms.saturating_sub(refresh_before_ms); } 0 } +// OAuth/JWT expiry fields use whole seconds. Round an exact positive profile +// duration up only at that protocol boundary so a fractional lifetime never +// collapses into the absent/default sentinel. +fn max_lifetime_seconds(state: &StoredProviderCredentialRefreshState) -> i64 { + let Some(value) = state.max_lifetime.as_ref() else { + return DEFAULT_MAX_LIFETIME_SECONDS; + }; + let Ok(duration) = openshell_core::time::duration_to_std(value) else { + return DEFAULT_MAX_LIFETIME_SECONDS; + }; + let seconds = duration + .as_secs() + .saturating_add(u64::from(duration.subsec_nanos() != 0)); + i64::try_from(seconds).unwrap_or(i64::MAX).max(1) +} + fn seconds_until_ms(now_ms: i64, target_ms: i64) -> i64 { if target_ms <= 0 { return 0; @@ -760,7 +842,7 @@ pub async fn refresh_provider_credential( if state .metadata .as_ref() - .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + .is_some_and(|metadata| metadata.deletion_time.is_some()) { return Err(Status::failed_precondition( "provider refresh is being deleted", @@ -885,12 +967,9 @@ pub async fn refresh_provider_credential( state.material.remove("refresh_token"); } state.expires_at_ms = minted.expires_at_ms; - state.next_refresh_at_ms = next_refresh_at_ms( - minted.expires_at_ms, - state.refresh_before_seconds, - state.max_lifetime_seconds, - now_ms, - ); + set_refresh_expiration_presence(&mut state, true); + state.next_refresh_at_ms = + next_refresh_at_ms(minted.expires_at_ms, state.refresh_before.as_ref()); state.last_refresh_at_ms = now_ms; state.status = "refreshed".to_string(); state.last_error.clear(); @@ -1102,19 +1181,22 @@ async fn apply_minted_credential( } None }; - if minted.expires_at_ms > 0 { + let credential_expiration_time = + openshell_core::time::optional_timestamp_from_legacy_millis(minted.expires_at_ms) + .map_err(|error| Status::internal(error.to_string()))?; + if let Some(expiration_time) = credential_expiration_time.as_ref() { updated - .credential_expires_at_ms - .insert(credential_key.to_string(), minted.expires_at_ms); + .credential_expiration_times + .insert(credential_key.to_string(), *expiration_time); for key in minted.additional_credentials.keys() { updated - .credential_expires_at_ms - .insert(key.clone(), minted.expires_at_ms); + .credential_expiration_times + .insert(key.clone(), *expiration_time); } } else { - updated.credential_expires_at_ms.remove(credential_key); + updated.credential_expiration_times.remove(credential_key); for key in minted.additional_credentials.keys() { - updated.credential_expires_at_ms.remove(key); + updated.credential_expiration_times.remove(key); } } // Acquire the shared sandbox mutation boundary only around validation and @@ -1164,19 +1246,19 @@ async fn apply_minted_credential( current.credentials.insert(key.clone(), value.clone()); } } - if minted.expires_at_ms > 0 { + if let Some(expiration_time) = credential_expiration_time.as_ref() { current - .credential_expires_at_ms - .insert(credential_key.to_string(), minted.expires_at_ms); + .credential_expiration_times + .insert(credential_key.to_string(), *expiration_time); for key in minted.additional_credentials.keys() { current - .credential_expires_at_ms - .insert(key.clone(), minted.expires_at_ms); + .credential_expiration_times + .insert(key.clone(), *expiration_time); } } else { - current.credential_expires_at_ms.remove(credential_key); + current.credential_expiration_times.remove(credential_key); for key in minted.additional_credentials.keys() { - current.credential_expires_at_ms.remove(key); + current.credential_expiration_times.remove(key); } } }) @@ -1287,7 +1369,7 @@ async fn mint_oauth2_refresh_token( request_token( &token_url, &form, - state.max_lifetime_seconds, + max_lifetime_seconds(state), OAuthGrantKind::UserRefreshToken, ) .await @@ -1312,7 +1394,7 @@ async fn mint_oauth2_client_credentials( request_token( &token_url, &form, - state.max_lifetime_seconds, + max_lifetime_seconds(state), OAuthGrantKind::NonInteractive, ) .await @@ -1334,11 +1416,7 @@ async fn mint_google_service_account_jwt( } let now_ms = current_time_ms(); let now_secs = now_ms / 1000; - let lifetime_secs = if state.max_lifetime_seconds > 0 { - state.max_lifetime_seconds.min(DEFAULT_MAX_LIFETIME_SECONDS) - } else { - DEFAULT_MAX_LIFETIME_SECONDS - }; + let lifetime_secs = max_lifetime_seconds(state).min(DEFAULT_MAX_LIFETIME_SECONDS); let subject = material_value(&state.material, &["subject", "sub"]); let claims = GoogleServiceAccountClaims { iss: &client_email, @@ -1435,11 +1513,7 @@ async fn mint_aws_sts_assume_role( }; let client = aws_sdk_sts::Client::from_conf(sts_config); - let max_lifetime_i64 = if state.max_lifetime_seconds > 0 { - state.max_lifetime_seconds - } else { - DEFAULT_MAX_LIFETIME_SECONDS - }; + let max_lifetime_i64 = max_lifetime_seconds(state); let max_lifetime = i32::try_from(max_lifetime_i64.min(i64::from(i32::MAX))).unwrap_or(i32::MAX); let max_lifetime_ms = i64::from(max_lifetime).saturating_mul(1000); @@ -1818,11 +1892,11 @@ pub fn spawn_refresh_worker(state: std::sync::Arc, interval: ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; - if let Err(err) = run_refresh_worker_tick( + if let Err(err) = Box::pin(run_refresh_worker_tick( state.store.as_ref(), Some(&state.credentials), Some(&state.compute), - ) + )) .await { warn!(error = %err, "provider credential refresh worker tick failed"); @@ -1869,7 +1943,7 @@ async fn run_refresh_worker_tick( if state .metadata .as_ref() - .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + .is_some_and(|metadata| metadata.deletion_time.is_some()) { let Some(credentials) = credentials else { warn!( @@ -1989,14 +2063,40 @@ mod tests { RefreshRetrySchedule, Status, classify_oauth_token_error, delete_refresh_state_with_credentials, effective_authorization_epoch, enqueue_pending_secret_deletion, get_refresh_state, list_all_refresh_states, - list_refresh_states_for_provider, new_refresh_state, put_refresh_state, - read_bounded_oauth_error_body, refresh_material_scope, refresh_provider_credential, - refresh_state_name, refresh_strategy_name, run_refresh_worker_tick, seconds_until_ms, + list_refresh_states_for_provider, max_lifetime_seconds, new_refresh_state, + next_refresh_at_ms, put_refresh_state, read_bounded_oauth_error_body, + refresh_has_expiration, refresh_material_scope, refresh_provider_credential, + refresh_state_name, refresh_status_from_state, refresh_strategy_name, + run_refresh_worker_tick, seconds_until_ms, set_refresh_expiration_presence, validate_secret_material_references, }; use crate::credentials::CredentialRuntime; use crate::persistence::{current_time_ms, test_store}; - use crate::storage_proto::StoredProviderCredentialRefreshState; + use crate::storage_proto::StoredProviderCredentialRefreshStateV2 as StoredProviderCredentialRefreshState; + + fn proto_duration(seconds: i64) -> prost_types::Duration { + prost_types::Duration { seconds, nanos: 0 } + } + + #[test] + fn exact_refresh_durations_preserve_fractional_values_until_protocol_boundaries() { + let refresh_before = prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }; + assert_eq!(next_refresh_at_ms(10_000, Some(&refresh_before)), 9_500); + let submillisecond = prost_types::Duration { + seconds: 0, + nanos: 1, + }; + assert_eq!(next_refresh_at_ms(10_000, Some(&submillisecond)), 9_999); + + let state = StoredProviderCredentialRefreshState { + max_lifetime: Some(refresh_before), + ..Default::default() + }; + assert_eq!(max_lifetime_seconds(&state), 1); + } use openshell_core::Config; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{ @@ -2008,6 +2108,10 @@ mod tests { use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; + fn ts(milliseconds: i64) -> prost_types::Timestamp { + openshell_core::time::timestamp_from_millis(milliseconds).unwrap() + } + fn test_credentials() -> CredentialRuntime { CredentialRuntime::from_config(&Config::new(None).with_credential_drivers(["test-static"])) .expect("test credential runtime") @@ -2074,8 +2178,8 @@ mod tests { expires_at_ms: 0, token_url: "https://issuer.example/token".to_string(), scopes: vec!["scope".to_string()], - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), additional_output_keys: HashMap::new(), }; let first = new_refresh_state(&provider, "default", "ACCESS_TOKEN", config()) @@ -2117,6 +2221,33 @@ mod tests { assert_eq!(refresh_strategy_name(i32::MAX), "unspecified"); } + #[test] + fn refresh_expiration_presence_distinguishes_absent_epoch_and_legacy_values() { + let mut state = StoredProviderCredentialRefreshState { + metadata: Some(ObjectMeta::default()), + ..Default::default() + }; + assert!(!refresh_has_expiration(&state)); + assert!(refresh_status_from_state(&state).expiration_time.is_none()); + + set_refresh_expiration_presence(&mut state, true); + assert!(refresh_has_expiration(&state)); + assert_eq!( + refresh_status_from_state(&state).expiration_time, + Some(ts(0)) + ); + + set_refresh_expiration_presence(&mut state, false); + assert!(!refresh_has_expiration(&state)); + + state.expires_at_ms = 1; + assert!(refresh_has_expiration(&state)); + assert_eq!( + refresh_status_from_state(&state).expiration_time, + Some(ts(1)) + ); + } + #[test] fn local_refresh_statuses_default_to_safe_recovery_actions() { for status in [ @@ -2416,8 +2547,8 @@ mod tests { expires_at_ms: 0, token_url: "not-an-absolute-url".to_string(), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -2490,8 +2621,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -2568,8 +2699,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: vec!["https://graph.microsoft.com/.default".to_string()], - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), }, ) .unwrap(); @@ -2621,8 +2752,10 @@ mod tests { Some(&"minted-graph-token".to_string()) ); assert_eq!( - stored.credential_expires_at_ms.get("MS_GRAPH_ACCESS_TOKEN"), - Some(&refreshed.expires_at_ms) + stored + .credential_expiration_times + .get("MS_GRAPH_ACCESS_TOKEN"), + Some(&ts(refreshed.expires_at_ms)) ); } @@ -2656,8 +2789,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -2716,8 +2849,10 @@ mod tests { .unwrap(); assert_eq!(handle.driver, "test-static"); assert_eq!( - stored.credential_expires_at_ms.get("MS_GRAPH_ACCESS_TOKEN"), - Some(&refreshed.expires_at_ms) + stored + .credential_expiration_times + .get("MS_GRAPH_ACCESS_TOKEN"), + Some(&ts(refreshed.expires_at_ms)) ); let resolved = credentials @@ -2757,12 +2892,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sandbox-collision".to_string(), name: "collision".to_string(), - created_at_ms: 1, + created_time: openshell_core::time::timestamp_from_millis(1).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["existing-graph".to_string(), "refreshing-graph".to_string()], @@ -2787,8 +2922,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), }, ) .unwrap(); @@ -2878,8 +3013,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: vec!["https://graph.microsoft.com/.default".to_string()], - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), }, ) .unwrap(); @@ -2919,9 +3054,9 @@ mod tests { ); assert_eq!( stored_provider - .credential_expires_at_ms + .credential_expiration_times .get("MS_GRAPH_ACCESS_TOKEN"), - Some(&refreshed.expires_at_ms) + Some(&ts(refreshed.expires_at_ms)) ); let stored_state = get_refresh_state( @@ -2995,8 +3130,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -3088,8 +3223,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -3198,8 +3333,8 @@ mod tests { expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: vec!["https://www.googleapis.com/auth/drive.readonly".to_string()], - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -3251,14 +3386,16 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 0, - max_lifetime_seconds: 0, + refresh_before: None, + max_lifetime: None, }, ) .unwrap(); put_refresh_state(&store, &state).await.unwrap(); - run_refresh_worker_tick(&store, None, None).await.unwrap(); + Box::pin(run_refresh_worker_tick(&store, None, None)) + .await + .unwrap(); let stored_state = get_refresh_state( &store, @@ -3300,8 +3437,8 @@ mod tests { expires_at_ms: 0, token_url: "https://issuer.example/token".to_string(), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -3314,9 +3451,13 @@ mod tests { state.next_refresh_at_ms = i64::MAX; put_refresh_state(&store, &state).await.unwrap(); - run_refresh_worker_tick(&store, Some(&test_credentials()), None) - .await - .unwrap(); + Box::pin(run_refresh_worker_tick( + &store, + Some(&test_credentials()), + None, + )) + .await + .unwrap(); let stored = get_refresh_state( &store, @@ -3353,8 +3494,8 @@ mod tests { expires_at_ms: 0, token_url: "https://issuer.example/token".to_string(), scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, + refresh_before: Some(proto_duration(30)), + max_lifetime: Some(proto_duration(60)), additional_output_keys: HashMap::new(), }, ) @@ -3369,12 +3510,13 @@ mod tests { .await .unwrap(); state.material.clear(); - state.metadata.as_mut().unwrap().deletion_timestamp_ms = current_time_ms(); + state.metadata.as_mut().unwrap().deletion_time = + openshell_core::time::timestamp_from_millis(current_time_ms()).ok(); state.status = "deleting".to_string(); put_refresh_state(&store, &state).await.unwrap(); assert_eq!(credentials.stored_credential_count(), Some(1)); - run_refresh_worker_tick(&store, Some(&credentials), None) + Box::pin(run_refresh_worker_tick(&store, Some(&credentials), None)) .await .unwrap(); @@ -3402,7 +3544,9 @@ mod tests { let store = test_store().await; let traced = test_exporter::install_traced(); - run_refresh_worker_tick(&store, None, None).await.unwrap(); + Box::pin(run_refresh_worker_tick(&store, None, None)) + .await + .unwrap(); let spans = traced.finished_spans(); let root = spans @@ -3506,8 +3650,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -3602,8 +3746,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -3677,8 +3821,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -3762,16 +3906,18 @@ mod tests { Some(&"FwoGZXIvYXdzEBYaDH...EXAMPLETOKEN".to_string()) ); assert_eq!( - stored.credential_expires_at_ms.get("AWS_ACCESS_KEY_ID"), - Some(&4_000_000_000_000) + stored.credential_expiration_times.get("AWS_ACCESS_KEY_ID"), + Some(&ts(4_000_000_000_000)) ); assert_eq!( - stored.credential_expires_at_ms.get("AWS_SECRET_ACCESS_KEY"), - Some(&4_000_000_000_000) + stored + .credential_expiration_times + .get("AWS_SECRET_ACCESS_KEY"), + Some(&ts(4_000_000_000_000)) ); assert_eq!( - stored.credential_expires_at_ms.get("AWS_SESSION_TOKEN"), - Some(&4_000_000_000_000) + stored.credential_expiration_times.get("AWS_SESSION_TOKEN"), + Some(&ts(4_000_000_000_000)) ); } @@ -3882,12 +4028,12 @@ mod tests { metadata: Some(ObjectMeta { id: "sandbox-aws-collision".to_string(), name: "aws-collision".to_string(), - created_at_ms: 1, + created_time: openshell_core::time::timestamp_from_millis(1).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), spec: Some(SandboxSpec { providers: vec!["existing-aws".to_string(), "refreshing-aws".to_string()], @@ -4012,8 +4158,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -4079,8 +4225,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -4160,8 +4306,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -4281,8 +4427,8 @@ mod tests { expires_at_ms: 0, token_url: String::new(), scopes: Vec::new(), - refresh_before_seconds: 300, - max_lifetime_seconds: 3600, + refresh_before: Some(proto_duration(300)), + max_lifetime: Some(proto_duration(3600)), }, ) .unwrap(); @@ -4346,17 +4492,17 @@ mod tests { metadata: Some(ObjectMeta { id: format!("{name}-id"), name: name.to_string(), - created_at_ms: 1, + created_time: openshell_core::time::timestamp_from_millis(1).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), + credential_expiration_times: HashMap::new(), profile_workspace: "default".to_string(), credential_handles: HashMap::new(), } diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 926f78094c..b7a528ddbc 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -825,12 +825,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "endpoint-id".to_string(), name: "my-sandbox--web".to_string(), - created_at_ms: 1_700_000_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_700_000_000_000).ok(), labels: std::collections::HashMap::default(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: "sandbox-id".to_string(), sandbox_name: "my-sandbox".to_string(), @@ -1210,12 +1210,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: "ep-1".to_string(), name: "my-sandbox--web".to_string(), - created_at_ms: 1_700_000_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_700_000_000_000).ok(), labels: std::collections::HashMap::default(), resource_version: 0, annotations: std::collections::HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: "sandbox-1".to_string(), sandbox_name: "my-sandbox".to_string(), diff --git a/crates/openshell-server/src/ssh_sessions.rs b/crates/openshell-server/src/ssh_sessions.rs index 12081c217d..809e0454e4 100644 --- a/crates/openshell-server/src/ssh_sessions.rs +++ b/crates/openshell-server/src/ssh_sessions.rs @@ -74,7 +74,12 @@ where decode_failures += 1; continue; }; - if (session.expires_at_ms > 0 && now_ms > session.expires_at_ms) || session.revoked { + let expired = session + .expiration_time + .as_ref() + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .is_some_and(|expires_at_ms| now_ms > expires_at_ms); + if expired || session.revoked { session_ids.push(session.object_id().to_string()); } } @@ -118,16 +123,19 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: format!("session-{id}"), - created_at_ms: 1000, + created_time: openshell_core::time::timestamp_from_millis(1000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), sandbox_id: sandbox_id.to_string(), token: id.to_string(), - expires_at_ms, + expiration_time: (expires_at_ms != 0) + .then(|| openshell_core::time::timestamp_from_millis(expires_at_ms)) + .transpose() + .unwrap(), revoked, } } diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index f0ec02107a..afe4b3d95d 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -15,8 +15,7 @@ include!(concat!(env!("OUT_DIR"), "/openshell.storage.v1.rs")); -#[cfg(test)] -const STORAGE_FILE_DESCRIPTOR_SET: &[u8] = +pub(crate) const STORAGE_FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/storage_descriptor.bin")); use openshell_core::{ @@ -66,25 +65,25 @@ impl ObjectWorkspace for StoredProviderProfile { } } -impl ObjectId for StoredProviderCredentialRefreshState { +impl ObjectId for StoredProviderCredentialRefreshStateV2 { fn object_id(&self) -> &str { self.metadata.as_ref().map_or("", |m| m.id.as_str()) } } -impl ObjectName for StoredProviderCredentialRefreshState { +impl ObjectName for StoredProviderCredentialRefreshStateV2 { fn object_name(&self) -> &str { self.metadata.as_ref().map_or("", |m| m.name.as_str()) } } -impl ObjectLabels for StoredProviderCredentialRefreshState { +impl ObjectLabels for StoredProviderCredentialRefreshStateV2 { fn object_labels(&self) -> Option> { self.metadata.as_ref().map(|m| m.labels.clone()) } } -impl SetResourceVersion for StoredProviderCredentialRefreshState { +impl SetResourceVersion for StoredProviderCredentialRefreshStateV2 { fn set_resource_version(&mut self, version: u64) { if let Some(meta) = self.metadata.as_mut() { meta.resource_version = version; @@ -92,13 +91,13 @@ impl SetResourceVersion for StoredProviderCredentialRefreshState { } } -impl GetResourceVersion for StoredProviderCredentialRefreshState { +impl GetResourceVersion for StoredProviderCredentialRefreshStateV2 { fn get_resource_version(&self) -> u64 { self.metadata.as_ref().map_or(0, |m| m.resource_version) } } -impl ObjectWorkspace for StoredProviderCredentialRefreshState { +impl ObjectWorkspace for StoredProviderCredentialRefreshStateV2 { fn object_workspace(&self) -> &str { self.metadata.as_ref().map_or("", |m| m.workspace.as_str()) } @@ -117,13 +116,13 @@ mod tests { use std::collections::{BTreeMap, BTreeSet, VecDeque}; const STORAGE_V1_SCHEMA_SHA256: &str = - "79c72615d957fc0653c672f61998bf7d8d21b757bc05d07b3fff92bd70fc8f52"; + "574bf5fcff731bd6e3fd84ed3f124161035bd236ef0fb7e32b4d8a8c55ceba5e"; const PUBLIC_RPC_SCHEMA_SHA256: &str = - "91025c34fadd69f2d96d5ad571f0e6e031490ff1f0ff3060b16021ae6633a81a"; + "3c2ad1ef3f38b9bfe029252974e261fb9adf440cebc60acf4b2ff5088f7ba6aa"; const DURABLE_SCHEMA_SHA256: &str = - "568ec5637c504726b40a616d286457f41b5be2f4761872c749313e1ee16b5c85"; + "65066c0b0eef57a4c708f20fcbbb8e8f47376da9f4bf73dfc3bca0b3df174ba8"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = - "f96d841e67da5c3443fa0aca15936dd14ac30d2e150ddf0439b0d195c4a0cfd9"; + "39e8aaf0d1fbc86906c49a9e7f60641a3ce203d3130799c8065e09acf9d53ddf"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -139,12 +138,13 @@ mod tests { "0a0472756c651a07666978747572652d0000403f3a0b6578616d706c652e636f6d40bb035002"; const V0_0_116_POLICY_RECORD: &str = "0a09706f6c6963792d6964120a73616e64626f782d6964180222030102032a0673686132353632066c6f616465643a046e6f6e6540fa0148ac0252110a06736f75726365120766697874757265"; const V0_0_116_DRAFT_RECORD: &str = "0a086368756e6b2d6964120a73616e64626f782d69641802220770656e64696e672a0472756c65320204053a076669787475726549000000000000e83f50de02589003620b6578616d706c652e636f6d68bb037801"; - const STORAGE_MESSAGE_NAMES: [&str; 7] = [ + const STORAGE_MESSAGE_NAMES: [&str; 8] = [ "DraftChunkPayload", "PolicyRevisionPayload", "StoredDraftChunk", "StoredPolicyRevision", "StoredProviderCredentialRefreshState", + "StoredProviderCredentialRefreshStateV2", "StoredProviderProfile", "StoredRefreshMaterialDeletion", ]; @@ -154,7 +154,7 @@ mod tests { ".openshell.sandbox.v1.SandboxPolicy", ".openshell.storage.v1.DraftChunkPayload", ".openshell.storage.v1.PolicyRevisionPayload", - ".openshell.storage.v1.StoredProviderCredentialRefreshState", + ".openshell.storage.v1.StoredProviderCredentialRefreshStateV2", ".openshell.storage.v1.StoredProviderProfile", ".openshell.v1.Sandbox", ".openshell.v1.SandboxWorkloadTemplate", @@ -490,13 +490,13 @@ mod tests { assert_eq!( (public_closure.messages.len(), public_closure.enums.len()), - (282, 13) + (283, 13) ); assert_eq!( (durable_closure.messages.len(), durable_closure.enums.len()), - (82, 9) + (83, 9) ); - assert_eq!((overlap_messages.len(), overlap_enums.len()), (72, 9)); + assert_eq!((overlap_messages.len(), overlap_enums.len()), (73, 9)); assert_eq!( public_inventory_hash, PUBLIC_RPC_SCHEMA_SHA256, @@ -693,7 +693,7 @@ mod tests { } let refresh = current_store - .get_message::("legacy-id") + .get_message::("legacy-id") .await .expect("decode refresh fixture") .expect("refresh fixture must remain present"); diff --git a/crates/openshell-server/src/supervisor_session.rs b/crates/openshell-server/src/supervisor_session.rs index 91a2151037..12ebecaee8 100644 --- a/crates/openshell-server/src/supervisor_session.rs +++ b/crates/openshell-server/src/supervisor_session.rs @@ -317,7 +317,7 @@ impl SupervisorSessionRegistry { // configured address so a caller can still identify each endpoint. for endpoint in &mut status.endpoint_statuses { endpoint.last_result = openshell_core::proto::EndpointResult::NoObservedExchange as i32; - endpoint.last_reported_at.clear(); + endpoint.last_reported_time = None; } } @@ -779,7 +779,7 @@ fn sandbox_proto_is_terminating(sandbox: &Sandbox) -> bool { || sandbox .metadata .as_ref() - .is_some_and(|metadata| metadata.deletion_timestamp_ms != 0) + .is_some_and(|metadata| metadata.deletion_time.is_some()) } async fn sandbox_is_terminating_or_gone(state: &Arc, sandbox_id: &str) -> bool { @@ -924,7 +924,10 @@ pub async fn handle_connect_supervisor( let accepted = GatewayMessage { payload: Some(gateway_message::Payload::SessionAccepted(SessionAccepted { session_id: session_id.clone(), - heartbeat_interval_secs: HEARTBEAT_INTERVAL_SECS, + heartbeat_interval: openshell_core::time::duration_from_std(Duration::from_secs( + u64::from(HEARTBEAT_INTERVAL_SECS), + )) + .ok(), })), }; if tx.send(accepted).await.is_err() { @@ -1221,12 +1224,12 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: id.to_string(), name: name.to_string(), - created_at_ms: 1_000_000, + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: "default".to_string(), - deletion_timestamp_ms: 0, + deletion_time: None, }), ..Default::default() } @@ -1263,7 +1266,7 @@ mod tests { ports: vec![443], path: "/mcp".to_string(), last_result: EndpointResult::HttpResponseReceived as i32, - last_reported_at: "2026-09-05T01:01:00.000Z".to_string(), + last_reported_time: Some("2026-09-05T01:01:00.000Z".parse().unwrap()), }; let ready = SandboxCondition { r#type: "Ready".to_string(), @@ -1277,7 +1280,7 @@ mod tests { }); let unknown = EndpointStatus { last_result: EndpointResult::NoObservedExchange as i32, - last_reported_at: String::new(), + last_reported_time: None, ..endpoint.clone() }; @@ -1775,7 +1778,8 @@ mod tests { #[test] fn sandbox_proto_terminating_detects_deletion_timestamp() { let mut sandbox = sandbox_record("sbx-1", "sandbox-one"); - sandbox.metadata.as_mut().unwrap().deletion_timestamp_ms = 1; + sandbox.metadata.as_mut().unwrap().deletion_time = + openshell_core::time::timestamp_from_millis(1).ok(); assert!(sandbox_proto_is_terminating(&sandbox)); } diff --git a/crates/openshell-server/src/tracing_bus.rs b/crates/openshell-server/src/tracing_bus.rs index a91a5fd877..91db86c275 100644 --- a/crates/openshell-server/src/tracing_bus.rs +++ b/crates/openshell-server/src/tracing_bus.rs @@ -144,7 +144,7 @@ where let ts = openshell_core::time::now_ms(); let log = SandboxLogLine { sandbox_id: sandbox_id.clone(), - timestamp_ms: ts, + event_time: openshell_core::time::timestamp_from_millis(ts).ok(), level, target: meta.target().to_string(), message: msg, @@ -199,7 +199,7 @@ mod tests { fn make_log_event(sandbox_id: &str, message: &str) -> SandboxLogLine { SandboxLogLine { sandbox_id: sandbox_id.to_string(), - timestamp_ms: 1000, + event_time: openshell_core::time::timestamp_from_millis(1000).ok(), level: "INFO".to_string(), target: "test".to_string(), message: message.to_string(), @@ -331,7 +331,7 @@ mod tests { for i in 0..5 { let evt = SandboxStreamEvent { payload: Some(sandbox_stream_event::Payload::Event(PlatformEvent { - timestamp_ms: i, + event_time: openshell_core::time::timestamp_from_millis(i).ok(), source: "test".to_string(), r#type: "Normal".to_string(), reason: format!("Event{i}"), diff --git a/crates/openshell-supervisor-middleware-builtins/src/regex.rs b/crates/openshell-supervisor-middleware-builtins/src/regex.rs index a5c2df882a..3b16524567 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/regex.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/regex.rs @@ -55,13 +55,13 @@ pub fn describe() -> Vec { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), + request_timeout: None, }, MiddlewareBinding { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), + request_timeout: None, }, ] } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 8755ddae61..e69b808ee6 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -642,10 +642,10 @@ struct MiddlewareServiceState { impl MiddlewareServiceState { fn timeout_for_binding(&self, binding: &MiddlewareBinding) -> Result { - if binding.timeout.trim().is_empty() { + if binding.request_timeout.is_none() { Ok(self.operator_timeout) } else { - parse_middleware_timeout(&binding.timeout) + middleware_proto_timeout_or_default(binding.request_timeout.as_ref()) .map(|binding_timeout| binding_timeout.min(self.operator_timeout)) .map_err(|reason| miette!("middleware binding has invalid timeout: {reason}")) } @@ -778,7 +778,7 @@ fn validate_registration(registration: &SupervisorMiddlewareService) -> Result advertised) { @@ -907,6 +907,24 @@ fn validate_manifest_bindings( Ok(()) } +fn middleware_proto_timeout_or_default( + value: Option<&prost_types::Duration>, +) -> std::result::Result { + let Some(value) = value else { + return Ok(DEFAULT_MIDDLEWARE_TIMEOUT); + }; + let timeout = + openshell_core::time::duration_to_std(value).map_err(|error| error.to_string())?; + if !(MIN_MIDDLEWARE_TIMEOUT..=MAX_MIDDLEWARE_TIMEOUT).contains(&timeout) { + return Err(format!( + "must be between {}ms and {}s", + MIN_MIDDLEWARE_TIMEOUT.as_millis(), + MAX_MIDDLEWARE_TIMEOUT.as_secs() + )); + } + Ok(timeout) +} + fn validate_external_manifest( registration: &SupervisorMiddlewareService, manifest: &MiddlewareManifest, @@ -1493,7 +1511,7 @@ impl ChainRunner { }); continue; }; - let Some(binding) = Self::binding(manifest, operation, phase).cloned() else { + let Some(binding) = Self::binding(manifest, operation, phase).copied() else { // The config remains globally ordered, but it does not // participate in this exact operation/phase chain. unbound.push(entry); @@ -2031,6 +2049,18 @@ mod tests { use tokio_stream::wrappers::TcpListenerStream; + fn proto_duration(value: &str) -> prost_types::Duration { + let duration = match (value.strip_suffix("ms"), value.strip_suffix('s')) { + (Some(milliseconds), _) => { + Duration::from_millis(milliseconds.parse().expect("integer milliseconds")) + } + (_, Some(seconds)) => Duration::from_secs(seconds.parse().expect("integer seconds")), + (None, None) => panic!("test duration must use ms or s"), + }; + openshell_core::time::duration_from_std(duration) + .expect("test duration is in protobuf range") + } + #[test] fn advertised_audience_mismatch_fails_registration() { let configured = "urn:openshell:extension:middleware:content-guard"; @@ -2146,7 +2176,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } @@ -2282,7 +2312,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } @@ -2373,7 +2403,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: "10ms".into(), + request_timeout: Some(proto_duration("10ms")), }], expected_audience: String::new(), } @@ -2633,7 +2663,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: self.max_body_bytes, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), })) @@ -2662,7 +2692,7 @@ mod tests { struct SlowService { delay: Duration, - binding_timeout: String, + binding_timeout: Option, } #[tonic::async_trait] @@ -2688,7 +2718,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: self.binding_timeout.clone(), + request_timeout: self.binding_timeout, }], expected_audience: String::new(), })) @@ -2747,7 +2777,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 256 * 1024, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), })) @@ -3017,7 +3047,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), })) @@ -3073,7 +3103,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } @@ -3132,7 +3162,7 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), })) @@ -3629,7 +3659,7 @@ mod tests { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), }; @@ -3656,7 +3686,7 @@ mod tests { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, max_payload_bytes: u64::MAX, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), }; @@ -3672,7 +3702,7 @@ mod tests { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }; let manifest = MiddlewareManifest { name: "example/service".into(), @@ -3700,7 +3730,10 @@ mod tests { operation: SupervisorMiddlewareOperation::HttpResponse as i32, phase: SupervisorMiddlewarePhase::PreReturn as i32, max_payload_bytes: 4096, - timeout: "500ms".into(), + request_timeout: Some(prost_types::Duration { + seconds: 0, + nanos: 500_000_000, + }), }], expected_audience: String::new(), }; @@ -3720,7 +3753,7 @@ mod tests { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: phase as i32, max_payload_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "500ms".into(), + request_timeout: Some(proto_duration("500ms")), }; let mut manifest = MiddlewareManifest { name: "example/websocket".into(), @@ -3747,7 +3780,7 @@ mod tests { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), }; @@ -3771,7 +3804,7 @@ mod tests { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 4096, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), }; @@ -3820,7 +3853,7 @@ mod tests { assert_eq!(timeout, DEFAULT_MIDDLEWARE_TIMEOUT); let mut registration = external_registration(4096); - registration.timeout = "2s".into(); + registration.request_timeout = Some(proto_duration("2s")); let timeout = validate_registration(®istration).expect("operator timeout"); assert_eq!(timeout, Duration::from_secs(2)); } @@ -3829,7 +3862,7 @@ mod tests { fn registration_timeout_enforces_bounds() { for timeout in ["9ms", "31s"] { let mut registration = external_registration(4096); - registration.timeout = timeout.into(); + registration.request_timeout = Some(proto_duration(timeout)); assert!(validate_registration(®istration).is_err()); } } @@ -3845,7 +3878,7 @@ mod tests { operation: HTTP_REQUEST_OPERATION as i32, phase: PRE_CREDENTIALS_PHASE as i32, max_payload_bytes: 4096, - timeout: timeout.into(), + request_timeout: Some(proto_duration(timeout)), }], expected_audience: String::new(), }; @@ -3858,11 +3891,11 @@ mod tests { #[tokio::test] async fn binding_timeout_override_controls_evaluation_and_on_error() { let mut registration = external_registration(4096); - registration.timeout = "2s".into(); + registration.request_timeout = Some(proto_duration("2s")); let registry = registry_with_external( Arc::new(SlowService { delay: Duration::from_millis(50), - binding_timeout: "10ms".into(), + binding_timeout: Some(proto_duration("10ms")), }), registration, ) @@ -3900,11 +3933,11 @@ mod tests { #[tokio::test] async fn operator_timeout_controls_binding_without_manifest_override() { let mut registration = external_registration(4096); - registration.timeout = "10ms".into(); + registration.request_timeout = Some(proto_duration("10ms")); let registry = registry_with_external( Arc::new(SlowService { delay: Duration::from_millis(50), - binding_timeout: String::new(), + binding_timeout: None, }), registration, ) @@ -3935,11 +3968,14 @@ mod tests { #[tokio::test] async fn operator_timeout_caps_longer_binding_timeout_for_validation_and_evaluation() { let mut registration = external_registration(4096); - registration.timeout = "10ms".into(); + registration.request_timeout = Some(proto_duration("10ms")); let registry = registry_with_external( Arc::new(SlowService { delay: Duration::from_millis(50), - binding_timeout: "2s".into(), + binding_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), }), registration, ) @@ -4917,7 +4953,10 @@ mod tests { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "1s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), }], expected_audience: String::new(), })) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 31acd3a7fd..9b97b4605e 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -4064,7 +4064,10 @@ network_policies: as i32, max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "2s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), }], expected_audience: String::new(), }, @@ -4176,7 +4179,10 @@ network_policies: grpc_endpoint: format!("http://{address}"), max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "2s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), tls_ca_cert_pem: Vec::new(), audience: String::new(), allow_insecure_transport: false, @@ -5874,7 +5880,7 @@ network_policies: as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } @@ -6021,7 +6027,7 @@ network_policies: as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } @@ -6492,7 +6498,7 @@ network_policies: operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: self.max_body_bytes, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index fc440c6547..1813ddcf4b 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -25,7 +25,7 @@ pub struct TokenGrantRequest<'a> { pub client_assertion_type: &'a str, pub audience: &'a str, pub scopes: &'a [String], - pub cache_ttl_seconds: i64, + pub cache_ttl: Option, pub grant_type: i32, pub requested_token_type: &'a str, } @@ -54,7 +54,7 @@ impl TokenGrantResolver for SpiffeTokenGrantResolver { client_assertion_type: request.client_assertion_type, audience: request.audience, scopes: request.scopes, - cache_ttl_override: request.cache_ttl_seconds, + cache_ttl_override: request.cache_ttl, grant_type: request.grant_type, requested_token_type: request.requested_token_type, }, @@ -95,7 +95,7 @@ pub async fn inject_if_needed(req: L7Request, ctx: &L7EvalContext) -> Result { @@ -172,18 +172,24 @@ fn ocsf_message_field(value: &str) -> String { fn token_grant_request<'a>( provider_key: &'a str, token_grant: &'a ProviderCredentialTokenGrant, -) -> TokenGrantRequest<'a> { - TokenGrantRequest { +) -> Result> { + let cache_ttl = token_grant + .cache_ttl + .as_ref() + .map(openshell_core::time::duration_to_std) + .transpose() + .map_err(|error| miette!("invalid token grant cache_ttl: {error}"))?; + Ok(TokenGrantRequest { provider_key, token_endpoint: &token_grant.token_endpoint, jwt_svid_audience: &token_grant.jwt_svid_audience, client_assertion_type: &token_grant.client_assertion_type, audience: &token_grant.audience, scopes: &token_grant.scopes, - cache_ttl_seconds: token_grant.cache_ttl_seconds, + cache_ttl, grant_type: token_grant.grant_type, requested_token_type: &token_grant.requested_token_type, - } + }) } #[cfg(test)] @@ -376,7 +382,7 @@ pub mod test_support { client_assertion_type: String, audience: String, scopes: Vec, - cache_ttl_seconds: i64, + cache_ttl: Option, grant_type: i32, requested_token_type: String, } @@ -473,7 +479,7 @@ pub mod test_support { ); assert_eq!(request.audience, "api://example"); assert_eq!(request.scopes, ["read"]); - assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!(request.cache_ttl, Some(std::time::Duration::from_mins(5))); assert_eq!( request.grant_type, ProviderCredentialTokenGrantType::ClientCredentials as i32 @@ -498,7 +504,7 @@ pub mod test_support { ); assert_eq!(request.audience, "api://example"); assert_eq!(request.scopes, ["read"]); - assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!(request.cache_ttl, Some(std::time::Duration::from_mins(5))); assert_eq!( request.grant_type, ProviderCredentialTokenGrantType::TokenExchange as i32 @@ -518,7 +524,10 @@ pub mod test_support { client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" .to_string(), scopes: vec!["read".to_string()], - cache_ttl_seconds: 300, + cache_ttl: Some(prost_types::Duration { + seconds: 300, + nanos: 0, + }), audience_overrides: Vec::new(), grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, subject_token: None, @@ -553,7 +562,7 @@ pub mod test_support { client_assertion_type: request.client_assertion_type.to_string(), audience: request.audience.to_string(), scopes: request.scopes.to_vec(), - cache_ttl_seconds: request.cache_ttl_seconds, + cache_ttl: request.cache_ttl, grant_type: request.grant_type, requested_token_type: request.requested_token_type.to_string(), }; diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index dab11a1112..77ed825088 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3565,7 +3565,10 @@ network_policies: phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "1s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), }], expected_audience: String::new(), })) @@ -3759,7 +3762,10 @@ network_policies: grpc_endpoint: format!("http://{address}"), max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "2s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), tls_ca_cert_pem: Vec::new(), audience: String::new(), allow_insecure_transport: false, @@ -3831,7 +3837,10 @@ network_policies: grpc_endpoint: format!("http://{address}"), max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "2s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), tls_ca_cert_pem: Vec::new(), audience: String::new(), allow_insecure_transport: false, @@ -4788,7 +4797,10 @@ network_policies: grpc_endpoint: format!("http://{address}"), max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "2s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), tls_ca_cert_pem: Vec::new(), audience: String::new(), allow_insecure_transport: false, @@ -4938,7 +4950,10 @@ network_policies: grpc_endpoint: format!("http://{address}"), max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - timeout: "2s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 2, + nanos: 0, + }), tls_ca_cert_pem: Vec::new(), audience: String::new(), allow_insecure_transport: false, diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 76d2b87c84..77ed3e14da 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -1077,13 +1077,13 @@ fn policy_chunk_from_add_rule( security_notes: String::new(), confidence: 0.75, denial_summary_ids: vec![], - created_at_ms: 0, - decided_at_ms: 0, + created_time: None, + decided_time: None, stage: "agent".to_string(), supersedes_chunk_id: String::new(), hit_count: 1, - first_seen_ms: 0, - last_seen_ms: 0, + first_seen_time: None, + last_seen_time: None, binary, validation_result: String::new(), rejection_reason: String::new(), diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index dbb8c98fd9..46c8f69cea 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -6655,7 +6655,10 @@ process: phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 1024, - timeout: "1s".into(), + request_timeout: Some(prost_types::Duration { + seconds: 1, + nanos: 0, + }), }], expected_audience: String::new(), }, @@ -6739,7 +6742,7 @@ process: as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, - timeout: String::new(), + request_timeout: None, }], expected_audience: String::new(), } diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 4e4237f686..5d03012f61 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -25,7 +25,7 @@ //! - `client_assertion_type` — `OAuth2` client assertion type (optional) //! - `audience` — Resource audience to request from the token service //! - `scopes` — `OAuth2` scopes to request (optional) -//! - `cache_ttl_seconds` — Cache override (0 = use `expires_in` from response) +//! - `cache_ttl` — Optional exact cache override; zero disables caching //! //! ## Environment //! @@ -124,7 +124,7 @@ impl TokenCache { /// * `client_assertion_type` — Optional `OAuth2` client assertion type /// * `audience` — Resource audience to request in the token request /// * `scopes` — `OAuth2` scopes to request (may be empty) -/// * `cache_ttl_override` — Cache TTL in seconds (0 = use `expires_in` from response) +/// * `cache_ttl_override` — Exact cache TTL; absence uses `expires_in` and zero disables caching /// /// # Errors /// @@ -141,7 +141,7 @@ pub struct ObtainProviderTokenRequest<'a> { pub client_assertion_type: &'a str, pub audience: &'a str, pub scopes: &'a [String], - pub cache_ttl_override: i64, + pub cache_ttl_override: Option, pub grant_type: i32, pub requested_token_type: &'a str, } @@ -239,7 +239,7 @@ struct ObtainProviderTokenInput<'a> { client_assertion_type: &'a str, audience: &'a str, scopes: &'a [String], - cache_ttl_override: i64, + cache_ttl_override: Option, grant_type: ProviderCredentialTokenGrantType, requested_token_type: &'a str, } @@ -264,21 +264,24 @@ where requested_token_type: effective_token_type(input.requested_token_type), }); - if let Some(cached) = input.cache.get(&cache_key) { + if input.cache_ttl_override != Some(Duration::ZERO) + && let Some(cached) = input.cache.get(&cache_key) + { return Ok(cached); } let token_response = grant(jwt_audience).await?; - let cache_ttl_seconds = - token_cache_ttl_seconds(input.cache_ttl_override, token_response.expires_in); - let expires_at_ms = current_time_ms().saturating_add(cache_ttl_seconds.saturating_mul(1000)); - - input.cache.set( - cache_key, - token_response.access_token.clone(), - expires_at_ms, - ); + let cache_ttl = token_cache_ttl(input.cache_ttl_override, token_response.expires_in); + if !cache_ttl.is_zero() { + let ttl_ms = i64::try_from(cache_ttl.as_millis()).unwrap_or(i64::MAX); + let expires_at_ms = current_time_ms().saturating_add(ttl_ms); + input.cache.set( + cache_key, + token_response.access_token.clone(), + expires_at_ms, + ); + } Ok(token_response.access_token) } @@ -352,8 +355,8 @@ async fn perform_token_exchange( pub use oauth::validate_access_token; -fn token_cache_ttl_seconds(cache_ttl_override: i64, expires_in: i64) -> i64 { - if cache_ttl_override > 0 { +fn token_cache_ttl(cache_ttl_override: Option, expires_in: i64) -> Duration { + if let Some(cache_ttl_override) = cache_ttl_override { return cache_ttl_override; } @@ -363,7 +366,10 @@ fn token_cache_ttl_seconds(cache_ttl_override: i64, expires_in: i64) -> i64 { DEFAULT_TOKEN_CACHE_TTL_SECONDS }; - ttl.saturating_sub(TOKEN_CACHE_EXPIRY_SKEW_SECONDS).max(1) + Duration::from_secs( + u64::try_from(ttl.saturating_sub(TOKEN_CACHE_EXPIRY_SKEW_SECONDS).max(1)) + .unwrap_or(u64::MAX), + ) } /// Derive the issuer/realm URL from a token endpoint URL. @@ -659,7 +665,7 @@ mod tests { jwt_svid_audience: &'a str, audience: &'a str, scopes: &'a [String], - cache_ttl_override: i64, + cache_ttl_override: Option, expires_in: i64, grant_calls: Arc, } @@ -700,7 +706,7 @@ mod tests { jwt_svid_audience: &str, audience: &str, scopes: &[String], - cache_ttl_override: i64, + cache_ttl_override: Option, ) -> Result { obtain_provider_token_with_grant( ObtainProviderTokenInput { @@ -851,25 +857,46 @@ mod tests { #[test] fn token_cache_ttl_uses_override_without_endpoint_skew() { - assert_eq!(token_cache_ttl_seconds(120, 10), 120); - assert_eq!(token_cache_ttl_seconds(120, i64::MAX), 120); + assert_eq!( + token_cache_ttl(Some(Duration::from_mins(2)), 10), + Duration::from_mins(2) + ); + assert_eq!( + token_cache_ttl(Some(Duration::from_mins(2)), i64::MAX), + Duration::from_mins(2) + ); + } + + #[test] + fn token_cache_ttl_preserves_fractional_and_zero_overrides() { + assert_eq!( + token_cache_ttl(Some(Duration::from_millis(500)), 60), + Duration::from_millis(500) + ); + assert_eq!(token_cache_ttl(Some(Duration::ZERO), 60), Duration::ZERO); } #[test] fn token_cache_ttl_skews_default_and_response_expires_in() { assert_eq!( - token_cache_ttl_seconds(0, 0), - DEFAULT_TOKEN_CACHE_TTL_SECONDS - TOKEN_CACHE_EXPIRY_SKEW_SECONDS + token_cache_ttl(None, 0), + Duration::from_secs( + u64::try_from(DEFAULT_TOKEN_CACHE_TTL_SECONDS - TOKEN_CACHE_EXPIRY_SKEW_SECONDS) + .unwrap() + ) ); - assert_eq!(token_cache_ttl_seconds(0, 60), 30); - assert_eq!(token_cache_ttl_seconds(0, 10), 1); + assert_eq!(token_cache_ttl(None, 60), Duration::from_secs(30)); + assert_eq!(token_cache_ttl(None, 10), Duration::from_secs(1)); } #[test] fn token_cache_ttl_clamps_large_response_expires_in() { assert_eq!( - token_cache_ttl_seconds(0, i64::MAX), - MAX_TOKEN_EXPIRES_IN_SECONDS - TOKEN_CACHE_EXPIRY_SKEW_SECONDS + token_cache_ttl(None, i64::MAX), + Duration::from_secs( + u64::try_from(MAX_TOKEN_EXPIRES_IN_SECONDS - TOKEN_CACHE_EXPIRY_SKEW_SECONDS) + .unwrap() + ) ); } @@ -886,7 +913,7 @@ mod tests { jwt_svid_audience: "https://auth.example.com", audience: "api://resource", scopes: &scopes, - cache_ttl_override: 0, + cache_ttl_override: None, expires_in: 60, grant_calls: grant_calls.clone(), }) @@ -899,7 +926,7 @@ mod tests { "https://auth.example.com", "api://resource", &scopes, - 0, + None, ) .await .expect("second call should use cache"); @@ -923,7 +950,7 @@ mod tests { jwt_svid_audience: "https://auth.example.com", audience: "api://resource-one", scopes: &read_scope, - cache_ttl_override: 0, + cache_ttl_override: None, expires_in: 60, grant_calls: grant_calls.clone(), }) @@ -936,7 +963,7 @@ mod tests { jwt_svid_audience: "https://auth.example.com", audience: "api://resource-two", scopes: &read_scope, - cache_ttl_override: 0, + cache_ttl_override: None, expires_in: 60, grant_calls: grant_calls.clone(), }) @@ -949,7 +976,7 @@ mod tests { jwt_svid_audience: "https://auth.example.com", audience: "api://resource-one", scopes: &write_scope, - cache_ttl_override: 0, + cache_ttl_override: None, expires_in: 60, grant_calls: grant_calls.clone(), }) @@ -995,7 +1022,7 @@ mod tests { jwt_svid_audience, audience, scopes: &scopes, - cache_ttl_override: 0, + cache_ttl_override: None, expires_in: 60, grant_calls: grant_calls.clone(), }) @@ -1019,7 +1046,7 @@ mod tests { jwt_svid_audience: "https://auth.example.com", audience: "api://resource", scopes: &scopes, - cache_ttl_override: 60, + cache_ttl_override: Some(Duration::from_mins(1)), expires_in: 0, grant_calls: grant_calls.clone(), }) @@ -1032,7 +1059,7 @@ mod tests { "https://auth.example.com", "api://resource", &scopes, - 60, + Some(Duration::from_mins(1)), ) .await .expect("override should keep token cached"); @@ -1042,6 +1069,28 @@ mod tests { assert_eq!(grant_calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn obtain_provider_token_zero_cache_ttl_does_not_cache() { + let cache = TokenCache::new(); + let grant_calls = Arc::new(AtomicUsize::new(0)); + let scopes = vec!["read".to_string()]; + let input = || CountedTokenGrantInput { + cache: &cache, + provider_name: "api.example.test\t443\t/v1/**\tprovider:access_token", + token_endpoint: "https://auth.example.com/token", + jwt_svid_audience: "https://auth.example.com", + audience: "api://resource", + scopes: &scopes, + cache_ttl_override: Some(Duration::ZERO), + expires_in: 60, + grant_calls: grant_calls.clone(), + }; + + assert_eq!(obtain_counted_test_token(input()).await.unwrap(), "token-1"); + assert_eq!(obtain_counted_test_token(input()).await.unwrap(), "token-2"); + assert_eq!(grant_calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn perform_token_grant_posts_jwt_assertion_and_parses_success_response() { let (endpoint, request) = token_endpoint_once( diff --git a/crates/openshell-supervisor-process/src/debug_rpc.rs b/crates/openshell-supervisor-process/src/debug_rpc.rs index 6f885a69db..83e85731fc 100644 --- a/crates/openshell-supervisor-process/src/debug_rpc.rs +++ b/crates/openshell-supervisor-process/src/debug_rpc.rs @@ -112,7 +112,11 @@ async fn run_refresh() -> Result { match resp { Ok(r) => { let inner = r.into_inner(); - print_token_summary(&inner.token, Some(inner.expires_at_ms)); + let expires_at_ms = inner + .expiration_time + .as_ref() + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()); + print_token_summary(&inner.token, expires_at_ms); Ok(0) } Err(status) => { diff --git a/crates/openshell-supervisor-process/src/log_push.rs b/crates/openshell-supervisor-process/src/log_push.rs index b24382787c..470ed8e416 100644 --- a/crates/openshell-supervisor-process/src/log_push.rs +++ b/crates/openshell-supervisor-process/src/log_push.rs @@ -75,7 +75,7 @@ impl Layer for LogPushLayer { let log = SandboxLogLine { sandbox_id: self.sandbox_id.clone(), - timestamp_ms: ts, + event_time: openshell_core::time::timestamp_from_millis(ts).ok(), level: if is_ocsf { "OCSF".to_string() } else { @@ -375,7 +375,7 @@ mod tests { assert_eq!(line.sandbox_id, "sb-test"); assert_eq!(line.message, expected_shorthand); assert!(line.fields.is_empty()); - assert!(line.timestamp_ms > 0); + assert!(line.event_time.is_some()); } #[test] @@ -447,7 +447,7 @@ mod tests { fn test_line(message: &str) -> SandboxLogLine { SandboxLogLine { sandbox_id: "sb-test".to_string(), - timestamp_ms: 1, + event_time: openshell_core::time::timestamp_from_millis(1).ok(), level: "INFO".to_string(), target: "t".to_string(), message: message.to_string(), diff --git a/crates/openshell-supervisor-process/src/supervisor_session.rs b/crates/openshell-supervisor-process/src/supervisor_session.rs index bc5189468d..32cbc2d1ee 100644 --- a/crates/openshell-supervisor-process/src/supervisor_session.rs +++ b/crates/openshell-supervisor-process/src/supervisor_session.rs @@ -420,7 +420,11 @@ async fn run_single_session( _ => return Err("expected SessionAccepted or SessionRejected".into()), }; - let heartbeat_secs = accepted.heartbeat_interval_secs.max(5); + let heartbeat_secs = accepted + .heartbeat_interval + .as_ref() + .and_then(|value| openshell_core::time::duration_to_std(value).ok()) + .map_or(5, |value| value.as_secs().max(5)); if let Some(updates) = &config.session_id_updates { updates.send_replace(Some(accepted.session_id.clone())); } @@ -428,14 +432,13 @@ async fn run_single_session( openshell_ocsf::ctx::ctx(), &config.endpoint, &accepted.session_id, - heartbeat_secs, + u32::try_from(heartbeat_secs).unwrap_or(u32::MAX), ); ocsf_emit!(event); config.ready_tx.send_replace(true); // Main loop: receive gateway messages + send heartbeats. - let mut heartbeat_interval = - tokio::time::interval(Duration::from_secs(u64::from(heartbeat_secs))); + let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(heartbeat_secs)); heartbeat_interval.tick().await; // skip immediate tick loop { diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 3b6f6c436d..92c9b011fa 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -1292,8 +1292,8 @@ async fn flush_proposals_to_gateway( binary: s.binary, ancestors: s.ancestors, deny_reason: s.deny_reason, - first_seen_ms: s.first_seen_ms, - last_seen_ms: s.last_seen_ms, + first_seen_time: openshell_core::time::timestamp_from_millis(s.first_seen_ms).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(s.last_seen_ms).ok(), count: s.count, suppressed_count: 0, total_count: s.count, diff --git a/crates/openshell-supervisor/src/mechanistic_mapper.rs b/crates/openshell-supervisor/src/mechanistic_mapper.rs index 260f4ec180..013d31abc1 100644 --- a/crates/openshell-supervisor/src/mechanistic_mapper.rs +++ b/crates/openshell-supervisor/src/mechanistic_mapper.rs @@ -84,8 +84,14 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { for denial in denials { total_count += denial.count; - first_seen_ms = first_seen_ms.min(denial.first_seen_ms); - last_seen_ms = last_seen_ms.max(denial.last_seen_ms); + if let Some(timestamp) = denial.first_seen_time.as_ref() { + first_seen_ms = first_seen_ms + .min(openshell_core::time::timestamp_to_millis(timestamp).unwrap_or(i64::MAX)); + } + if let Some(timestamp) = denial.last_seen_time.as_ref() { + last_seen_ms = last_seen_ms + .max(openshell_core::time::timestamp_to_millis(timestamp).unwrap_or_default()); + } if denial.denial_stage == "ssrf" { is_ssrf = true; } @@ -211,13 +217,13 @@ pub fn generate_proposals(summaries: &[DenialSummary]) -> Vec { security_notes, confidence, denial_summary_ids: vec![], - created_at_ms: 0, // Set by gateway on persist - decided_at_ms: 0, + created_time: None, // Set by gateway on persist + decided_time: None, stage, supersedes_chunk_id: String::new(), hit_count: total_count.cast_signed(), - first_seen_ms, - last_seen_ms, + first_seen_time: openshell_core::time::timestamp_from_millis(first_seen_ms).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(last_seen_ms).ok(), binary: binary.clone(), validation_result: String::new(), rejection_reason: String::new(), @@ -504,8 +510,8 @@ mod tests { binary: "/usr/bin/curl".to_string(), ancestors: vec![], deny_reason: "no matching policy".to_string(), - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), count: 5, suppressed_count: 0, total_count: 5, @@ -547,8 +553,8 @@ mod tests { binary: "/usr/bin/python3".to_string(), ancestors: vec![], deny_reason: "l7 deny".to_string(), - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), count: 3, suppressed_count: 0, total_count: 3, @@ -651,8 +657,8 @@ mod tests { port: 80, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -671,8 +677,8 @@ mod tests { port: 80, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -691,8 +697,8 @@ mod tests { port: 80, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -711,8 +717,8 @@ mod tests { port: 8080, binary: "/usr/bin/curl".to_string(), count: 3, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "ssrf".to_string(), ..Default::default() }]; @@ -731,8 +737,8 @@ mod tests { port: 443, binary: "/usr/bin/curl".to_string(), count: 5, - first_seen_ms: 1000, - last_seen_ms: 2000, + first_seen_time: openshell_core::time::timestamp_from_millis(1000).ok(), + last_seen_time: openshell_core::time::timestamp_from_millis(2000).ok(), denial_stage: "connect".to_string(), ..Default::default() }]; diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 64d9bd5806..ee86b5f4f6 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -900,9 +900,12 @@ fn provider_to_redacted_yaml(provider: &openshell_core::proto::Provider) -> Stri } } - if !provider.credential_expires_at_ms.is_empty() { - out.push_str("credential_expires_at_ms:\n"); - let mut entries = provider.credential_expires_at_ms.iter().collect::>(); + if !provider.credential_expiration_times.is_empty() { + out.push_str("credential_expiration_times:\n"); + let mut entries = provider + .credential_expiration_times + .iter() + .collect::>(); entries.sort_by_key(|(key, _)| *key); for (key, value) in entries { out.push_str(" "); @@ -3290,10 +3293,9 @@ impl App { .get(key) .map_or_else(|| "-".to_string(), |value| mask_secret(value)); let expiry = provider - .credential_expires_at_ms + .credential_expiration_times .get(key) - .copied() - .filter(|value| *value > 0) + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) .map_or_else(String::new, |value| format!(" expires={value}")); format!("{key}: {masked}{expiry}") }) @@ -3320,9 +3322,8 @@ impl App { credential.env_vars.join(", ") }; let expiry = present_key - .and_then(|key| provider.credential_expires_at_ms.get(key)) - .copied() - .filter(|value| *value > 0) + .and_then(|key| provider.credential_expiration_times.get(key)) + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) .map_or_else(String::new, |value| format!(" expires={value}")); format!( "{} ({required}) env=[{env_vars}] {status}{expiry}", diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 55fbcaff87..17eb0ad18e 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -710,7 +710,7 @@ fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { let req = openshell_core::proto::GetSandboxLogsRequest { sandbox_id: sandbox_id.clone(), lines: 500, - since_ms: 0, + since_time: None, sources: vec![], min_level: String::new(), workspace_scope: Some(named_workspace_scope(workspace)), @@ -787,7 +787,11 @@ fn proto_to_log_line(log: openshell_core::proto::SandboxLogLine) -> LogLine { log.source }; LogLine { - timestamp_ms: log.timestamp_ms, + timestamp_ms: log + .event_time + .as_ref() + .and_then(|value| openshell_core::time::timestamp_to_millis(value).ok()) + .unwrap_or_default(), level: log.level, source, target: log.target, @@ -1742,17 +1746,17 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: provider_name.clone(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.clone(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: ptype.clone(), credentials: credentials.clone(), config: config.clone(), - credential_expires_at_ms: HashMap::default(), + credential_expiration_times: HashMap::default(), profile_workspace: workspace.clone(), credential_handles: HashMap::default(), }), @@ -1859,22 +1863,23 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), name: name.clone(), - created_at_ms: 0, + created_time: None, labels: HashMap::new(), resource_version: 0, annotations: HashMap::new(), workspace: workspace.clone(), - deletion_timestamp_ms: 0, + deletion_time: None, }), r#type: ptype, credentials, config, - credential_expires_at_ms: HashMap::default(), + credential_expiration_times: HashMap::default(), profile_workspace: String::new(), credential_handles: HashMap::default(), }), - credential_expires_at_ms: HashMap::default(), + credential_expiration_times: HashMap::default(), workspace_scope: Some(named_workspace_scope(workspace)), + clear_credential_expiration_keys: Vec::new(), }; match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { @@ -2727,7 +2732,9 @@ fn apply_sandbox_refresh(app: &mut App, sandboxes: Vec -Use `--credential-expires-at` when the current provider credential already has a known expiry timestamp. For refresh-managed keys, the value can be Unix epoch milliseconds or an ISO/RFC3339 timestamp such as `2026-01-01T00:00:00Z` or `2026-01-01T01:00:00+01:00`. OpenShell stores that value as epoch milliseconds in both refresh state and provider credential metadata. Later gateway-managed refreshes replace it with the minted token expiry. +Use `--credential-expires-at` when the current provider credential already has a known expiry timestamp. For refresh-managed keys, the value can be Unix epoch milliseconds or an ISO/RFC3339 timestamp such as `2026-01-01T00:00:00Z` or `2026-01-01T01:00:00+01:00`. OpenShell stores that value as epoch milliseconds in both refresh state and provider credential metadata. An explicit Unix epoch value remains distinct from an omitted expiry and is already expired. Later gateway-managed refreshes replace it with the minted token expiry. Force a refresh immediately: @@ -804,7 +810,7 @@ openshell provider refresh delete my-graph \ --credential-key MS_GRAPH_ACCESS_TOKEN ``` -Deleting refresh state clears the provider credential expiry only when that expiry came from the deleted refresh state. If you later set a different expiry manually with `openshell provider update --credential-expires-at`, OpenShell preserves the manual value. +Deleting refresh state clears the provider credential expiry only when that expiry came from the deleted refresh state, including an explicit Unix epoch expiry. If you later set a different expiry manually with `openshell provider update --credential-expires-at`, OpenShell preserves the manual value. ### Refresh Logs diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 84edb41b19..9940f2ee2e 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -269,7 +269,7 @@ The client-certificate handshake policy is derived and has no `require_client_au `[openshell.gateway] policy_validation_failure_mode` controls what sandbox supervisors do when a complete candidate policy fails runtime validation. The default, `fail_closed`, deactivates the previous network policy, closes relays pinned to it, and denies new egress until a valid generation loads. `retain_last_valid` leaves the previous valid generation active. Both modes reject the candidate atomically; startup always fails closed when no previous valid generation exists. Gateway mutation paths that can preflight a known effective scope reject invalid candidates before persistence and leave the active policy unchanged regardless of this setting. Changing the value requires restarting the gateway so it can reload `gateway.toml` and distribute the new posture to sandbox supervisors. -`[openshell.gateway.gateway_jwt] ttl_secs` controls gateway-minted sandbox JWT lifetime. Omit it for a non-expiring token: the token `exp` claim and `expires_at_ms` response field become `0`. Use this only for local single-player Docker, Podman, or VM gateways. Explicit `0` is invalid. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway omits the field. +`[openshell.gateway.gateway_jwt] ttl_secs` controls gateway-minted sandbox JWT lifetime. Omit it for a non-expiring token: the token has no `exp` claim and the response omits `expiration_time`. Use this only for local single-player Docker, Podman, or VM gateways. Explicit `0` is invalid. Kubernetes and other shared deployments should set a positive TTL; Helm renders `3600` seconds by default, and the gateway logs a warning when a Kubernetes gateway omits the field. `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. diff --git a/docs/reference/protobuf-time-types.mdx b/docs/reference/protobuf-time-types.mdx new file mode 100644 index 0000000000..f658abf776 --- /dev/null +++ b/docs/reference/protobuf-time-types.mdx @@ -0,0 +1,62 @@ +--- +title: Protobuf time types +description: Timestamp and duration representation in the OpenShell API +--- + +OpenShell represents absolute times with `google.protobuf.Timestamp` and +elapsed times with `google.protobuf.Duration` in its protobuf APIs. + +Provider profile YAML and JSON accept canonical duration strings such as +`cache_ttl: "0.500s"`. The legacy `cache_ttl_seconds`, `refresh_before_seconds`, +and `max_lifetime_seconds` inputs remain supported for existing profiles. + +In protobuf JSON, timestamps are RFC 3339 strings and durations are strings +ending in `s`: + +```json +{ + "createdTime": "2026-08-31T14:05:06.123456789Z", + "executionTimeout": "1.500s" +} +``` + +An absent message means that no timestamp or duration was supplied. It is +different from the Unix epoch (`1970-01-01T00:00:00Z`) and from a zero duration +(`0s`). OpenShell validates protobuf timestamp and duration bounds at API +boundaries. + +## Upgrade from scalar time fields + +The well-known time fields replace earlier public fields encoded as Unix +milliseconds, integer seconds, or duration strings. Their names and protobuf +tags changed, so clients generated from the old schema are not wire-compatible +with a gateway generated from the new schema. Upgrade the gateway, CLI, all SDK +clients, compute drivers, credential drivers, middleware services, and sandbox +supervisors together. Mixing versions can silently discard fields whose tags +changed. For example, the new gateway does not recognize an old credential +driver's tag-3 expiry and would treat that credential as non-expiring. + +Before upgrading a gateway with persisted state: + +1. Stop all gateway replicas. +2. Back up the SQLite database or PostgreSQL database. +3. Upgrade every gateway replica, client, driver, middleware service, and + sandbox supervisor from the same OpenShell release. +4. Start one gateway replica and wait for startup to complete before starting + the remaining replicas. + +At startup, the gateway migrates affected protobuf payloads in one database +transaction. Legacy zero timestamps become absent. Zero expiry-map values are +removed, and the legacy maximum-integer refresh sentinel becomes an absent +next-refresh timestamp. The database columns `created_at_ms` and +`updated_at_ms` remain unchanged because they are internal ordering metadata. + +If a legacy payload is malformed or outside protobuf time bounds, startup +fails with the affected object type and ID and rolls back the transaction. +Legacy sandbox condition transition strings that are not valid RFC 3339 are +dropped because earlier releases allowed arbitrary driver-provided strings. +Restore the backup or repair any other reported record with the previous +OpenShell version before retrying the upgrade. + +User-facing timeout options and gateway TOML settings keep their existing +units. The CLI and SDKs convert those values at the protobuf boundary. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 730b7da794..380d58cda5 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -553,7 +553,7 @@ openshell sandbox get my-sandbox --policy-only A sandbox can be `Ready` while an agent's call to an external tool server fails with `fetch failed`. Run `openshell sandbox get my-sandbox` and look under `Tool server connections` for the server's address, `Last result`, and `Reported at`. This information is available for configured endpoints that use MCP over HTTP. It helps you find where the call failed without changing sandbox readiness. -JSON and YAML output include an `endpoint_statuses` list. The API and SDK expose it as `Sandbox.status.endpoint_statuses`. Each record contains `endpoint_id`, `host`, `ports`, `path`, `last_result`, and `last_reported_at`. Select the endpoint by its address to read the result directly. Treat `endpoint_id` as opaque. +JSON and YAML output include an `endpoint_statuses` list with `last_reported_at`. The protobuf API exposes the corresponding timestamp as `Sandbox.status.endpoint_statuses[].last_reported_time`; SDKs render it using their native or curated time representation. Select the endpoint by its address to read the result directly. Treat `endpoint_id` as opaque. For example, use `jq` to select the tool server at `tools.example.com`, path `/mcp`, and port `443`: @@ -580,7 +580,7 @@ For example, a stopped server can produce `TransportFailed` while the sandbox st Results come from actual traffic and do not expire. A result can remain unchanged after the server stops until another observed exchange or configuration/session reset. Reports combine recent observations and can drop them when full, so this list is not a request history. The address remains available when a result resets to `NoObservedExchange`. To verify current tool availability, run the actual operation. -`last_reported_at` is the RFC 3339 UTC time when the gateway accepted the result. It is empty until a result is reported. New accepted observations advance it, including repeated results; identical report retries do not. If a configuration reset supersedes a report whose acknowledgement was lost, the gateway can accept still-valid pending evidence again and advance this timestamp without another exchange. +`last_reported_at` is the RFC 3339 rendering of the protobuf `last_reported_time` when the gateway accepted the result. It is empty until a result is reported. New accepted observations advance it, including repeated results; identical report retries do not. If a configuration reset supersedes a report whose acknowledgement was lost, the gateway can accept still-valid pending evidence again and advance this timestamp without another exchange. Addresses use lowercase hosts, canonical paths, and sorted distinct effective ports. Equivalent addresses share one record that combines observations from all callers and ports; it does not establish that every caller or port works. A failure before the HTTP path is known, such as a TLS failure during CONNECT, updates status only when the host and port identify one distinct endpoint. If several configured paths share that host and port, inspect the logs for the failure. diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 5407bdf69e..06c1d325fc 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -6,6 +6,7 @@ import threading from typing import TYPE_CHECKING +from google.protobuf import duration_pb2 from openshell._proto import openshell_pb2 if TYPE_CHECKING: @@ -87,7 +88,7 @@ def exec_interactive(sandbox_id: str, *, tty: bool) -> tuple[bytes, bytes]: "printf 'stderr-sentinel\\n' >&2", ], tty=tty, - timeout_seconds=20, + execution_timeout=duration_pb2.Duration(seconds=20), ) ) diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index 842196aca4..9aeef0fec9 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1152,6 +1152,7 @@ dependencies = [ "noyalib", "openshell-core", "openshell-policy", + "prost-types", "serde", "serde_json", "thiserror", diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index 8d714264e7..0e9430bb7d 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -230,13 +230,13 @@ impl SupervisorMiddleware for ContentGuard { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: PHASE as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), + request_timeout: None, }, MiddlewareBinding { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: PHASE as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), + request_timeout: None, }, ], expected_audience: String::new(), diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 45ddd63d55..7147c7a6f6 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.compute.v1; import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; import "options.proto"; import "sandbox.proto"; @@ -346,6 +347,8 @@ message DriverSandboxStatus { // Raw compute-platform condition. message DriverCondition { + reserved 5; + reserved "last_transition_time"; // Condition class reported by the compute platform. string type = 1; // Condition status value such as `True`, `False`, or `Unknown`. @@ -354,14 +357,16 @@ message DriverCondition { string reason = 3; // Human-readable condition message. string message = 4; - // Timestamp reported by the platform for the last transition. - string last_transition_time = 5; + // Time reported by the platform for the last transition. + google.protobuf.Timestamp transition_time = 105; } // Raw compute-platform event correlated to a sandbox. message DriverPlatformEvent { - // Event timestamp in milliseconds since epoch. - int64 timestamp_ms = 1; + reserved 1; + reserved "timestamp_ms"; + // Time when the event occurred. + google.protobuf.Timestamp event_time = 101; // Event source (for example `kubernetes`). string source = 2; // Event type or severity (for example `Normal` or `Warning`). diff --git a/proto/credential_driver.proto b/proto/credential_driver.proto index b25e9256d0..fd7bb1b92c 100644 --- a/proto/credential_driver.proto +++ b/proto/credential_driver.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.credentials.v1; import "datamodel.proto"; +import "google/protobuf/timestamp.proto"; // Internal credential-driver contract used by the gateway. // @@ -43,7 +44,7 @@ message GetCredentialDriverCapabilitiesResponse { string backend_kind = 3; // True when ListCredentials is supported. bool supports_list = 4; - // True when ResolveCredentials may return expires_at_ms values. + // True when ResolveCredentials may return expiration_time values. bool supports_expires_at = 5; } @@ -111,12 +112,14 @@ message ResolveCredentialsResponse { } message ResolvedCredential { + reserved 3; + reserved "expires_at_ms"; // Echoes ResolveCredentialRequest.request_id. string request_id = 1; // Secret string value. Drivers must never log this field. string value = 2; - // Expiration timestamp in milliseconds since Unix epoch, or zero when absent. - int64 expires_at_ms = 3; + // Expiration time. Absence means the credential does not expire. + google.protobuf.Timestamp expiration_time = 103; } message ListCredentialsRequest {} diff --git a/proto/datamodel.proto b/proto/datamodel.proto index d39ee75500..f34d8a0098 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.datamodel.v1; import "options.proto"; +import "google/protobuf/timestamp.proto"; // Selects the workspace scope for a public API request. // @@ -33,14 +34,16 @@ message AllWorkspaces {} // timestamps, resource versioning) across Sandbox, Provider, SshSession, and // other resources. message ObjectMeta { + reserved 3, 8; + reserved "created_at_ms", "deletion_timestamp_ms"; // Stable object ID generated by the gateway. string id = 1; // Human-readable object name (unique per object type). string name = 2; - // Milliseconds since Unix epoch when the object was created. - int64 created_at_ms = 3; + // Time when the object was created. + google.protobuf.Timestamp created_time = 103; // Key-value labels for filtering and organization. // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. @@ -58,10 +61,10 @@ message ObjectMeta { // gateway. Immutable after creation. string workspace = 7; - // Milliseconds since Unix epoch when graceful deletion was initiated. - // Zero means the object is not being deleted. Once set, this field is + // Time when graceful deletion was initiated. Absence means the object is + // not being deleted. Once set, this field is // immutable — the only path forward is completing deletion. - int64 deletion_timestamp_ms = 8; + google.protobuf.Timestamp deletion_time = 108; } // Phase of a workspace's lifecycle. @@ -101,6 +104,8 @@ message CredentialHandle { // Provider model stored by OpenShell. message Provider { + reserved 5; + reserved "credential_expires_at_ms"; // Kubernetes-style metadata (id, name, labels, timestamps, resource version). ObjectMeta metadata = 1; // Canonical provider type slug (for example: "claude", "gitlab"). @@ -109,9 +114,9 @@ message Provider { map credentials = 3 [(openshell.options.v1.secret) = true]; // Non-secret provider configuration. map config = 4; - // Expiration timestamps for credential values, keyed by credential/env var - // name. A zero or missing value means the credential does not expire. - map credential_expires_at_ms = 5; + // Expiration times for credential values, keyed by credential/env var name. + // A missing key means the credential does not expire. + map credential_expiration_times = 105; // Workspace where this provider's type profile is stored. // Empty string = platform/global scope. Must be empty or match // metadata.workspace; cross-workspace references are rejected. diff --git a/proto/openshell.proto b/proto/openshell.proto index 2462799d6d..33e3d0ce85 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -8,6 +8,7 @@ package openshell.v1; import "datamodel.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; import "options.proto"; import "sandbox.proto"; @@ -764,11 +765,12 @@ message IssueSandboxTokenRequest {} // memory and presents it as `Authorization: Bearer` on every subsequent // gateway RPC. message IssueSandboxTokenResponse { + reserved 2; + reserved "expires_at_ms"; // Gateway-minted JWT bound to the calling sandbox's UUID. string token = 1 [(openshell.options.v1.secret) = true]; - // Absolute expiry of the issued token, milliseconds since the epoch. 0 means - // the token is non-expiring. - int64 expires_at_ms = 2; + // Absolute expiry of the issued token. Absence means the token is non-expiring. + google.protobuf.Timestamp expiration_time = 102; } // RefreshSandboxToken request. The calling principal must already be a @@ -785,18 +787,19 @@ message RefreshSandboxTokenRequest { // RefreshSandboxToken response. The new token replaces the supervisor's // in-memory bearer credential. message RefreshSandboxTokenResponse { + reserved 2, 5; + reserved "expires_at_ms", "sandbox_expires_at_ms"; // Fresh gateway-minted JWT bound to the same sandbox UUID. string token = 1 [(openshell.options.v1.secret) = true]; - // Absolute expiry of the new token, milliseconds since the epoch. 0 means - // the token is non-expiring. - int64 expires_at_ms = 2; + // Absolute expiry of the new token. Absence means the token is non-expiring. + google.protobuf.Timestamp expiration_time = 102; // Fresh credentials for the requested, policy-authorized extension // services. These remain in supervisor memory and are never persisted. repeated ExtensionServiceCredential extension_credentials = 3; // Fresh Sandbox Protocol bearer token from the same atomic refresh. string sandbox_token = 4 [(openshell.options.v1.secret) = true]; - // Absolute Sandbox Protocol token expiry, milliseconds since the epoch. - int64 sandbox_expires_at_ms = 5; + // Absolute Sandbox Protocol token expiry. Required when sandbox_token is set. + google.protobuf.Timestamp sandbox_expiration_time = 105; // Launch generation to which both refreshed credentials are bound. string session_id = 6; // Durable authorization epoch shared by the gateway and Sandbox Runtime. @@ -1089,6 +1092,8 @@ message SandboxStatus { // User-facing sandbox condition derived from platform or gateway observations. message SandboxCondition { + reserved 5; + reserved "last_transition_time"; // Condition class, typically mirroring the underlying platform condition type. string type = 1; // Condition status value such as `True`, `False`, or `Unknown`. @@ -1097,8 +1102,8 @@ message SandboxCondition { string reason = 3; // Human-readable condition message. string message = 4; - // RFC 3339 UTC timestamp supplied by the condition owner for the last transition. - string last_transition_time = 5; + // Timestamp reported by the condition owner for the last transition. + google.protobuf.Timestamp transition_time = 105; } // High-level sandbox lifecycle phase derived by the gateway. @@ -1121,8 +1126,10 @@ enum SandboxPhase { // Public platform event exposed on the sandbox watch stream. message PlatformEvent { - // Event timestamp in milliseconds since epoch. - int64 timestamp_ms = 1; + reserved 1; + reserved "timestamp_ms"; + // Time when the event occurred. + google.protobuf.Timestamp event_time = 101; // Event source (e.g. "kubernetes", "docker", "process"). string source = 2; // Event type/severity (e.g. "Normal", "Warning"). @@ -1226,6 +1233,8 @@ message BeginRootfsTarStagingRequest { // Gateway-issued staging slot. message BeginRootfsTarStagingResponse { + reserved 4; + reserved "expires_at_ms"; // Opaque single-use token. Pass it as // `template.driver_config..rootfs_tar_staging_token` on // CreateSandbox. The first CreateSandbox presenting it consumes it. @@ -1235,7 +1244,7 @@ message BeginRootfsTarStagingResponse { // Maximum accepted archive size in bytes, enforced again by the driver. uint64 max_bytes = 3; // Wall-clock deadline after which the gateway reclaims the slot. - int64 expires_at_ms = 4; + google.protobuf.Timestamp expiration_time = 104; } // Get sandbox request. @@ -1388,6 +1397,8 @@ message CreateSshSessionRequest { // violate it. The client's own escaping provides defense-in-depth, but // narrow charsets close injection vectors at the trust boundary. message CreateSshSessionResponse { + reserved 8; + reserved "expires_at_ms"; // Sandbox id. [A-Za-z0-9._-]{1,128}. string sandbox_id = 1; @@ -1410,8 +1421,8 @@ message CreateSshSessionResponse { // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. string host_key_fingerprint = 7; - // Expiry timestamp in milliseconds since epoch. 0 means no expiry. - int64 expires_at_ms = 8; + // Absolute expiry. Absence means no expiry. + google.protobuf.Timestamp expiration_time = 108; } // Request to expose an HTTP service running inside a sandbox. @@ -1519,6 +1530,8 @@ message RevokeSshSessionResponse { // Execute command request. message ExecSandboxRequest { + reserved 5; + reserved "timeout_seconds"; // Sandbox id. string sandbox_id = 1; @@ -1531,8 +1544,8 @@ message ExecSandboxRequest { // Optional environment overrides. map environment = 4; - // Optional timeout in seconds. 0 means no timeout. - uint32 timeout_seconds = 5; + // Optional execution timeout. Absence means no timeout. + google.protobuf.Duration execution_timeout = 105; // Optional stdin payload passed to the command. bytes stdin = 6; @@ -1623,6 +1636,8 @@ message ExecSandboxWindowResize { // SSH session record stored in persistence. message SshSession { + reserved 4; + reserved "expires_at_ms"; // Kubernetes-style metadata (id, name, labels, timestamps, resource version). openshell.datamodel.v1.ObjectMeta metadata = 1; @@ -1632,9 +1647,8 @@ message SshSession { // Session token. string token = 3 [(openshell.options.v1.secret) = true]; - // Expiry timestamp in milliseconds since epoch. 0 means no expiry - // (backward-compatible default for sessions created before this field existed). - int64 expires_at_ms = 4; + // Absolute expiry. Absence means no expiry. + google.protobuf.Timestamp expiration_time = 104; // Revoked flag. bool revoked = 5; @@ -1642,6 +1656,8 @@ message SshSession { // Watch sandbox request. message WatchSandboxRequest { + reserved 8; + reserved "log_since_ms"; // Sandbox id. string id = 1; @@ -1664,9 +1680,9 @@ message WatchSandboxRequest { // (COMPLETED, STOPPED, or ERROR). bool stop_on_terminal = 7; - // Only include log lines with timestamp >= this value (milliseconds since epoch). - // 0 means no time filter. Applies to both tail replay and live streaming. - int64 log_since_ms = 8; + // Only include log lines at or after this time. Absence means no time filter. + // Applies to both tail replay and live streaming. + google.protobuf.Timestamp since_time = 108; // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. repeated string log_sources = 9; @@ -1693,8 +1709,10 @@ message SandboxStreamEvent { // Log line correlated to a sandbox. message SandboxLogLine { + reserved 2; + reserved "timestamp_ms"; string sandbox_id = 1; - int64 timestamp_ms = 2; + google.protobuf.Timestamp event_time = 102; string level = 3; string target = 4; string message = 5; @@ -1745,10 +1763,15 @@ message ListProvidersRequest { message UpdateProviderRequest { reserved 3; reserved "workspace"; + reserved 2; + reserved "credential_expires_at_ms"; openshell.datamodel.v1.Provider provider = 1; // Optional per-credential expiry timestamps to merge into the provider. - // A zero value removes the expiry for that credential. - map credential_expires_at_ms = 2; + // Omitted keys are unchanged. Use clear_credential_expiration_keys to remove + // an existing expiry. + map credential_expiration_times = 102; + // Credential keys whose existing expiry should be removed. + repeated string clear_credential_expiration_keys = 103; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 4; } @@ -1851,6 +1874,8 @@ message ProviderCredentialTokenGrantSubjectToken { } message ProviderCredentialTokenGrant { + reserved 4; + reserved "cache_ttl_seconds"; // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) string token_endpoint = 1; @@ -1864,9 +1889,8 @@ message ProviderCredentialTokenGrant { // Optional: OAuth2 scopes to request repeated string scopes = 3; - // Optional: override token cache TTL (seconds) - // If 0 or omitted, use expires_in from token response - int64 cache_ttl_seconds = 4; + // Optional token cache TTL override. If absent, use expires_in from the token response. + google.protobuf.Duration cache_ttl = 104; // Optional: endpoint-specific resource audience overrides. repeated ProviderCredentialTokenGrantAudienceOverride audience_overrides = 5; @@ -1928,28 +1952,30 @@ message ProviderCredentialRefreshOutput { } message ProviderCredentialRefresh { + reserved 4, 5; + reserved "refresh_before_seconds", "max_lifetime_seconds"; ProviderCredentialRefreshStrategy strategy = 1; string token_url = 2; repeated string scopes = 3; - int64 refresh_before_seconds = 4; - int64 max_lifetime_seconds = 5; + google.protobuf.Duration refresh_before = 104; + google.protobuf.Duration max_lifetime = 105; repeated ProviderCredentialRefreshMaterial material = 6; repeated ProviderCredentialRefreshOutput additional_outputs = 7; } message ProviderCredentialRefreshStatus { + reserved 6, 7, 8, 13; + reserved "expires_at_ms", "next_refresh_at_ms", "last_refresh_at_ms", "last_error_at_ms"; string provider_name = 1; string provider_id = 2; string credential_key = 3; ProviderCredentialRefreshStrategy strategy = 4; string status = 5; - int64 expires_at_ms = 6; - // Next automatic refresh time in Unix epoch milliseconds. A value of - // 9223372036854775807 (int64 max) means no automatic retry is scheduled; - // consumers should render it as unset and use recovery_action to determine - // the required recovery workflow. - int64 next_refresh_at_ms = 7; - int64 last_refresh_at_ms = 8; + google.protobuf.Timestamp expiration_time = 106; + // Next automatic refresh time. Absence means no automatic retry is scheduled; + // use recovery_action to determine the required recovery workflow. + google.protobuf.Timestamp next_refresh_time = 107; + google.protobuf.Timestamp last_refresh_time = 108; string last_error = 9; ProviderCredentialRefreshRecoveryAction recovery_action = 10; // Stable gateway-owned failure identifier, for example @@ -1960,7 +1986,7 @@ message ProviderCredentialRefreshStatus { // do not need a separate provider_error field. Unknown provider-controlled // values are not persisted or returned. string provider_error_subtype = 12; - int64 last_error_at_ms = 13; + google.protobuf.Timestamp last_error_time = 113; } // Provider profile local discovery declaration. @@ -1985,6 +2011,8 @@ message GetProviderRefreshStatusResponse { message ConfigureProviderRefreshRequest { reserved 7; reserved "workspace"; + reserved 6; + reserved "expires_at_ms"; string provider = 1; string credential_key = 2; ProviderCredentialRefreshStrategy strategy = 3; @@ -1993,7 +2021,7 @@ message ConfigureProviderRefreshRequest { // name must be present in material. The server also classifies secrets from // the authoritative provider profile and refresh strategy. repeated string secret_material_keys = 5; - optional int64 expires_at_ms = 6; + google.protobuf.Timestamp expiration_time = 106; // Explicit workspace scope. The all-workspaces selection is invalid. openshell.datamodel.v1.WorkspaceSelector workspace_scope = 8; } @@ -2180,12 +2208,14 @@ message StaticCredentialBinding { // Get sandbox provider environment response. message GetSandboxProviderEnvironmentResponse { + reserved 3; + reserved "credential_expires_at_ms"; // Provider credential environment variables. map environment = 1 [(openshell.options.v1.secret) = true]; // Fingerprint for the provider credential inputs that produced environment. uint64 provider_env_revision = 2; // Expiration timestamps for returned environment variables. - map credential_expires_at_ms = 3; + map credential_expiration_times = 103; // Dynamic credentials that require token grants or other runtime injection. // Maps endpoint-bound provider metadata to credential metadata. // Supervisor uses this to inject Authorization headers for token grant credentials. @@ -2216,8 +2246,10 @@ message ExchangeProviderSubjectTokenRequest { } message ExchangeProviderSubjectTokenResponse { + reserved 2; + reserved "expires_in"; string access_token = 1 [(openshell.options.v1.secret) = true]; - int64 expires_in = 2; + google.protobuf.Duration expires_after = 102; string token_type = 3; } @@ -2395,6 +2427,8 @@ message ReportPolicyStatusResponse {} // A versioned policy revision with metadata. message SandboxPolicyRevision { + reserved 5, 6; + reserved "created_at_ms", "loaded_at_ms"; // Policy version (monotonically increasing per sandbox). uint32 version = 1; // SHA-256 hash of the canonical serialized policy payload. Empty in a @@ -2408,10 +2442,10 @@ message SandboxPolicyRevision { // Sandbox load error, or the schema-validation diagnostic for an invalid // historical row returned by ListSandboxPolicies. string load_error = 4; - // Milliseconds since epoch when this revision was created. - int64 created_at_ms = 5; - // Milliseconds since epoch when this revision was loaded by the sandbox. - int64 loaded_at_ms = 6; + // Time when this revision was created. + google.protobuf.Timestamp created_time = 105; + // Time when this revision was loaded by the sandbox. Absent if not loaded. + google.protobuf.Timestamp loaded_time = 106; // The full policy (only populated when explicitly requested). openshell.sandbox.v1.SandboxPolicy policy = 7; // Immutable provenance supplied with this policy revision. @@ -2441,12 +2475,14 @@ enum PolicyStatus { message GetSandboxLogsRequest { reserved 6; reserved "workspace"; + reserved 3; + reserved "since_ms"; // Sandbox id. string sandbox_id = 1; // Maximum number of log lines to return. 0 means use default (2000). uint32 lines = 2; - // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. - int64 since_ms = 3; + // Only include logs at or after this time. Absence means no filter. + google.protobuf.Timestamp since_time = 103; // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. repeated string sources = 4; // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. @@ -2509,10 +2545,12 @@ message SupervisorHello { // Gateway accepts the supervisor session. message SessionAccepted { + reserved 2; + reserved "heartbeat_interval_secs"; // Gateway-assigned session ID for this connection. string session_id = 1; - // Recommended heartbeat interval in seconds. - uint32 heartbeat_interval_secs = 2; + // Recommended heartbeat interval. + google.protobuf.Duration heartbeat_interval = 102; } // Gateway rejects the supervisor session. @@ -2642,6 +2680,8 @@ message L7RequestSample { // Structured denial summary from sandbox aggregator. message DenialSummary { + reserved 7, 8; + reserved "first_seen_ms", "last_seen_ms"; // Sandbox ID that produced this summary. string sandbox_id = 1; // Denied destination host. @@ -2654,10 +2694,10 @@ message DenialSummary { repeated string ancestors = 5; // Denial reason from OPA evaluation. string deny_reason = 6; - // First denial timestamp (ms since epoch). - int64 first_seen_ms = 7; - // Most recent denial timestamp (ms since epoch). - int64 last_seen_ms = 8; + // Time of the first denial. + google.protobuf.Timestamp first_seen_time = 107; + // Time of the most recent denial. + google.protobuf.Timestamp last_seen_time = 108; // Number of denials in the current window. uint32 count = 9; // Events dropped during aggregator cooldown. @@ -2699,6 +2739,8 @@ message NetworkActivitySummary { // A proposed policy rule with rationale and approval status. message PolicyChunk { + reserved 9, 10, 14, 15; + reserved "created_at_ms", "decided_at_ms", "first_seen_ms", "last_seen_ms"; // Unique chunk identifier. string id = 1; // Approval status: "pending", "approved", "rejected". @@ -2715,20 +2757,20 @@ message PolicyChunk { float confidence = 7; // IDs of denial summaries that led to this chunk. repeated string denial_summary_ids = 8; - // Creation timestamp (ms since epoch). - int64 created_at_ms = 9; - // When the user approved/rejected (ms since epoch). 0 if undecided. - int64 decided_at_ms = 10; + // Time when this chunk was created. + google.protobuf.Timestamp created_time = 109; + // Time when the user approved or rejected the chunk. Absent if undecided. + google.protobuf.Timestamp decided_time = 110; // Recommendation stage: "initial" or "refined" (progressive L7 visibility). string stage = 11; // For stage="refined": the initial chunk this replaces. string supersedes_chunk_id = 12; // How many times this endpoint has been seen across denial flush cycles. int32 hit_count = 13; - // First time this endpoint was proposed (ms since epoch). - int64 first_seen_ms = 14; - // Most recent time this endpoint was re-proposed (ms since epoch). - int64 last_seen_ms = 15; + // First time this endpoint was proposed. + google.protobuf.Timestamp first_seen_time = 114; + // Most recent time this endpoint was proposed again. + google.protobuf.Timestamp last_seen_time = 115; // Binary path that triggered the denial (denormalized for display convenience). string binary = 16; // Validation verdict from gateway-side static checks (prover output). @@ -2816,14 +2858,16 @@ message GetDraftPolicyRequest { } message GetDraftPolicyResponse { + reserved 4; + reserved "last_analyzed_at_ms"; // Draft policy chunks. repeated PolicyChunk chunks = 1; // LLM-generated summary of all analysis (empty in mechanistic mode). string rolling_summary = 2; // Current draft version. uint64 draft_version = 3; - // When the last analysis completed (ms since epoch). - int64 last_analyzed_at_ms = 4; + // Time when the last analysis completed. + google.protobuf.Timestamp last_analyzed_time = 104; } // Approve a single draft chunk. @@ -2957,8 +3001,10 @@ message GetDraftHistoryRequest { } message DraftHistoryEntry { - // Event timestamp (ms since epoch). - int64 timestamp_ms = 1; + reserved 1; + reserved "timestamp_ms"; + // Time when the event occurred. + google.protobuf.Timestamp event_time = 101; // Event type: "denial_detected", "analysis_cycle", "approved", // "rejected", "edited", "undone", "cleared". string event_type = 2; @@ -3114,13 +3160,15 @@ message ListWorkspaceMembersResponse { // Kept at the end of the file so adding it does not renumber existing // generated message descriptors. message ExtensionServiceCredential { + reserved 3; + reserved "expires_at_ms"; // Operator registration name used to correlate the credential with the // stable service registration delivered by GetSandboxConfig. string service_name = 1; // Gateway-minted JWT with an audience derived from the registration. string token = 2 [(openshell.options.v1.secret) = true]; - // Absolute expiry of the token, milliseconds since the epoch. - int64 expires_at_ms = 3; + // Absolute expiry of the token. + google.protobuf.Timestamp expiration_time = 103; } // Last observed network result for a configured external tool endpoint. @@ -3179,6 +3227,8 @@ message ReportEndpointStatusResponse {} // A configured endpoint and its last accepted network result in one record. // Address fields contain policy selectors, never request URLs or credentials. message EndpointStatus { + reserved 6; + reserved "last_reported_at"; // Stable identifier for selecting this endpoint without parsing display text. string endpoint_id = 1; // Lowercase configured endpoint host. @@ -3191,8 +3241,8 @@ message EndpointStatus { // Last accepted result, aggregated across configured callers and ports. // NoObservedExchange retains the address and has no report timestamp. EndpointResult last_result = 5; - // RFC 3339 UTC time when the gateway accepted the observation. This is not - // the request time: still-valid evidence can be reaccepted after a reset. - // Identical same-sequence retries do not advance it. - string last_reported_at = 6; + // Time when the gateway accepted the observation. This is not the request + // time: still-valid evidence can be reaccepted after a reset. Identical + // same-sequence retries do not advance it. Absent until a result is reported. + google.protobuf.Timestamp last_reported_time = 106; } diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 894e3b2754..600f4a750b 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package openshell.sandbox.v1; import "google/protobuf/struct.proto"; +import "google/protobuf/duration.proto"; // Sandbox-supervisor configuration and policy messages. // @@ -403,6 +404,8 @@ message GetSandboxConfigResponse { // Connection details for one operator-registered supervisor middleware service. // V1 supports plaintext and server-authenticated TLS gRPC. message SupervisorMiddlewareService { + reserved 4; + reserved "timeout"; // Operator-owned registration name used by policy attachments and diagnostics. string name = 1; // gRPC endpoint reachable from the sandbox supervisor. @@ -410,10 +413,9 @@ message SupervisorMiddlewareService { // Operator-owned logical payload limit applied to every binding exposed by // the service. This caps HTTP bodies and complete WebSocket messages. uint64 max_payload_bytes = 3; - // Default RPC timeout for this service. Empty uses the platform default of - // 500ms. Values use an integer with an `ms` or `s` suffix and must be - // between 10ms and 30s. - string timeout = 4; + // Default RPC timeout for this service. Absence uses the platform default of + // 500ms. Values must be between 10ms and 30s. + google.protobuf.Duration request_timeout = 104; // PEM-encoded trust roots loaded by the gateway from the operator-configured // tls_ca_cert_path. Empty uses the platform trust store. bytes tls_ca_cert_pem = 5; diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 7e345e68ea..81e1c72f86 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -7,6 +7,7 @@ package openshell.middleware.v1; import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; +import "google/protobuf/duration.proto"; // SupervisorMiddleware discovers and configures one operator-run middleware. // It evaluates HTTP requests and WebSocket messages before credentials. @@ -63,6 +64,8 @@ message MiddlewareManifest { // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { + reserved 4; + reserved "timeout"; // Supported operation. SupervisorMiddlewareOperation operation = 1; // Supported phase. @@ -73,9 +76,8 @@ message MiddlewareBinding { // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. // A non-empty value may shorten but cannot extend the operator timeout. - // Values use an integer with an `ms` or `s` suffix and must be between - // 10ms and 30s. - string timeout = 4; + // Values must be between 10ms and 30s. + google.protobuf.Duration request_timeout = 104; } // ValidateConfigRequest contains one policy configuration to validate. diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index b6fe0a7347..ba69a0a695 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -1075,10 +1075,11 @@ def exec_stream( command=list(command), workdir=workdir or "", environment=dict(env or {}), - timeout_seconds=timeout_seconds or 0, stdin=stdin or b"", no_login_shell=no_login_shell, ) + if timeout_seconds: + request.execution_timeout.seconds = timeout_seconds # Use whichever is larger: the default client timeout or the command # timeout plus headroom for SSH setup / teardown overhead. grpc_deadline = self._timeout diff --git a/sdk/go/README.md b/sdk/go/README.md index 86c6e25479..6d695c6b4b 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -250,6 +250,8 @@ The pre-1.0 SDK intentionally includes source-incompatible API corrections: types preserve that scope. - Several public struct field orders changed. Use keyed struct literals. - Initialisms use Go spelling, including `JSONRPCMaxBodyBytes`. +- Provider profile durations use the exact `RefreshBefore`, `MaxLifetime`, and + `CacheTTL` fields. The legacy whole-second fields were removed. These changes are intentional while the module remains below v1. Update callers as one migration rather than relying on the v0.0.101 API shape. diff --git a/sdk/go/docs/src/api/providers.md b/sdk/go/docs/src/api/providers.md index 3a21ca96b0..4354843ae0 100644 --- a/sdk/go/docs/src/api/providers.md +++ b/sdk/go/docs/src/api/providers.md @@ -153,4 +153,9 @@ The `Provider` type represents a registered compute provider. | `Config` | map[string]string | Provider-specific configuration values | | `CredentialExpiresAt` | map[string]time.Time | Expiration timestamps for credentials | +The curated Go API uses a zero `time.Time` value to clear an expiration during +an update. Consequently, it cannot represent the protobuf minimum timestamp +(`0001-01-01T00:00:00Z`) in this map. Use the raw protobuf API if that exact +timestamp is required. Other pointer-based timestamp fields preserve it. + See also: [Profiles](profiles.md), [Refresh](refresh.md), [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 5103d4b1fc..77d8f72ea6 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -132,11 +132,11 @@ func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) { func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { handled := fieldSet{ - "type": true, - "status": true, - "reason": true, - "message": true, - "last_transition_time": true, + "type": true, + "status": true, + "reason": true, + "message": true, + "transition_time": true, } assertAllFieldsCovered(t, (&pb.SandboxCondition{}).ProtoReflect().Descriptor(), handled, nil) @@ -144,12 +144,12 @@ func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { func TestConverterCoversAllProtoFields_EndpointStatus(t *testing.T) { handled := fieldSet{ - "endpoint_id": true, - "host": true, - "ports": true, - "path": true, - "last_result": true, - "last_reported_at": true, + "endpoint_id": true, + "host": true, + "ports": true, + "path": true, + "last_result": true, + "last_reported_time": true, } assertAllFieldsCovered(t, (&pb.EndpointStatus{}).ProtoReflect().Descriptor(), handled, nil) @@ -255,13 +255,13 @@ func TestConverterCoversAllProtoFields_L7DenyRule(t *testing.T) { func TestConverterCoversAllProtoFields_Provider(t *testing.T) { handled := fieldSet{ - "metadata": true, - "type": true, - "credentials": true, - "config": true, - "credential_expires_at_ms": true, - "profile_workspace": true, - "credential_handles": true, + "metadata": true, + "type": true, + "credentials": true, + "config": true, + "credential_expiration_times": true, + "profile_workspace": true, + "credential_handles": true, } assertAllFieldsCovered(t, (&dm.Provider{}).ProtoReflect().Descriptor(), handled, nil) @@ -279,14 +279,14 @@ func TestConverterCoversAllProtoFields_CredentialHandle(t *testing.T) { func TestConverterCoversAllProtoFields_SandboxPolicyRevision(t *testing.T) { handled := fieldSet{ - "version": true, - "policy_hash": true, - "status": true, - "load_error": true, - "created_at_ms": true, - "loaded_at_ms": true, - "policy": true, - "provenance": true, + "version": true, + "policy_hash": true, + "status": true, + "load_error": true, + "created_time": true, + "loaded_time": true, + "policy": true, + "provenance": true, } assertAllFieldsCovered(t, (&pb.SandboxPolicyRevision{}).ProtoReflect().Descriptor(), handled, nil) @@ -335,7 +335,7 @@ func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrant(t *testing.T "audience": true, "jwt_svid_audience": true, "scopes": true, - "cache_ttl_seconds": true, + "cache_ttl": true, "audience_overrides": true, "client_assertion_type": true, "grant_type": true, diff --git a/sdk/go/openshell/v1/internal/converter/log.go b/sdk/go/openshell/v1/internal/converter/log.go index 42f530fb1c..9a10e51987 100644 --- a/sdk/go/openshell/v1/internal/converter/log.go +++ b/sdk/go/openshell/v1/internal/converter/log.go @@ -16,7 +16,7 @@ func LogLineFromProto(l *pb.SandboxLogLine) *types.LogLine { return nil } return &types.LogLine{ - Timestamp: TimeFromMillis(l.GetTimestampMs()), + Timestamp: TimeFromProto(l.GetEventTime()), Level: l.GetLevel(), Target: l.GetTarget(), Message: l.GetMessage(), diff --git a/sdk/go/openshell/v1/internal/converter/log_test.go b/sdk/go/openshell/v1/internal/converter/log_test.go index 7462396262..6bd285b763 100644 --- a/sdk/go/openshell/v1/internal/converter/log_test.go +++ b/sdk/go/openshell/v1/internal/converter/log_test.go @@ -15,12 +15,12 @@ import ( func TestLogLineFromProto(t *testing.T) { proto := &pb.SandboxLogLine{ - SandboxId: "sbx-1", - TimestampMs: 1700000000000, - Level: "INFO", - Target: "network", - Message: "Connection established", - Source: "sandbox-agent", + SandboxId: "sbx-1", + EventTime: TimestampFromMillis(1700000000000), + Level: "INFO", + Target: "network", + Message: "Connection established", + Source: "sandbox-agent", Fields: map[string]string{ "host": "api.example.com", "port": "443", @@ -45,9 +45,9 @@ func TestLogLineFromProto_Nil(t *testing.T) { func TestLogLineDeepCopy(t *testing.T) { proto := &pb.SandboxLogLine{ - TimestampMs: 1700000000000, - Level: "WARN", - Message: "test", + EventTime: TimestampFromMillis(1700000000000), + Level: "WARN", + Message: "test", Fields: map[string]string{ "key": "value", }, @@ -64,8 +64,8 @@ func TestLogLineDeepCopy(t *testing.T) { func TestLogResultFromProto(t *testing.T) { proto := &pb.GetSandboxLogsResponse{ Logs: []*pb.SandboxLogLine{ - {TimestampMs: 1700000000000, Level: "INFO", Message: "first"}, - {TimestampMs: 1700000001000, Level: "DEBUG", Message: "second"}, + {EventTime: TimestampFromMillis(1700000000000), Level: "INFO", Message: "first"}, + {EventTime: TimestampFromMillis(1700000001000), Level: "DEBUG", Message: "second"}, }, BufferTotal: 100, } diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 8d68ca2f57..1a473654e9 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -62,13 +62,13 @@ func PolicyChunkFromProto(c *pb.PolicyChunk) *types.PolicyChunk { SecurityNotes: c.GetSecurityNotes(), Confidence: c.GetConfidence(), DenialSummaryIDs: CopyStringSlice(c.GetDenialSummaryIds()), - CreatedAt: TimeFromMillis(c.GetCreatedAtMs()), - DecidedAt: TimeFromMillis(c.GetDecidedAtMs()), + CreatedAt: TimeFromProto(c.GetCreatedTime()), + DecidedAt: TimeFromProto(c.GetDecidedTime()), Stage: c.GetStage(), SupersedesChunkID: c.GetSupersedesChunkId(), HitCount: c.GetHitCount(), - FirstSeen: TimeFromMillis(c.GetFirstSeenMs()), - LastSeen: TimeFromMillis(c.GetLastSeenMs()), + FirstSeen: TimeFromProto(c.GetFirstSeenTime()), + LastSeen: TimeFromProto(c.GetLastSeenTime()), Binary: c.GetBinary(), ValidationResult: c.GetValidationResult(), RejectionReason: c.GetRejectionReason(), @@ -91,7 +91,7 @@ func DraftPolicyFromProto(r *pb.GetDraftPolicyResponse) *types.DraftPolicy { result := &types.DraftPolicy{ RollingSummary: r.GetRollingSummary(), DraftVersion: r.GetDraftVersion(), - LastAnalyzedAt: TimeFromMillis(r.GetLastAnalyzedAtMs()), + LastAnalyzedAt: TimeFromProto(r.GetLastAnalyzedTime()), } if chunks := r.GetChunks(); len(chunks) > 0 { result.Chunks = make([]types.PolicyChunk, 0, len(chunks)) @@ -299,8 +299,8 @@ func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxP PolicyHash: r.GetPolicyHash(), Status: PolicyLoadStatusFromProto(r.GetStatus()), LoadError: r.GetLoadError(), - CreatedAt: TimeFromMillis(r.GetCreatedAtMs()), - LoadedAt: TimeFromMillis(r.GetLoadedAtMs()), + CreatedAt: TimeFromProto(r.GetCreatedTime()), + LoadedAt: TimeFromProto(r.GetLoadedTime()), Policy: SandboxPolicyFromProto(r.GetPolicy()), Provenance: CopyStringMap(r.GetProvenance()), } @@ -383,7 +383,7 @@ func DraftHistoryEntryFromProto(e *pb.DraftHistoryEntry) *types.DraftHistoryEntr return nil } return &types.DraftHistoryEntry{ - Timestamp: TimeFromMillis(e.GetTimestampMs()), + Timestamp: TimeFromProto(e.GetEventTime()), EventType: e.GetEventType(), Description: e.GetDescription(), ChunkID: e.GetChunkId(), diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index 6935b3def0..dde2205773 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -75,13 +75,13 @@ func TestPolicyChunkFromProto(t *testing.T) { SecurityNotes: "No concerns", Confidence: 0.95, DenialSummaryIds: []string{"d1", "d2"}, - CreatedAtMs: 1700000000000, - DecidedAtMs: 1700000001000, + CreatedTime: TimestampFromMillis(1700000000000), + DecidedTime: TimestampFromMillis(1700000001000), Stage: "initial", SupersedesChunkId: "chunk-0", HitCount: 5, - FirstSeenMs: 1699999999000, - LastSeenMs: 1700000000500, + FirstSeenTime: TimestampFromMillis(1699999999000), + LastSeenTime: TimestampFromMillis(1700000000500), Binary: "/usr/bin/curl", ValidationResult: "valid", RejectionReason: "", @@ -146,7 +146,7 @@ func TestDraftPolicyFromProto(t *testing.T) { }, RollingSummary: "Analysis summary", DraftVersion: 42, - LastAnalyzedAtMs: 1700000000000, + LastAnalyzedTime: TimestampFromMillis(1700000000000), } draft := DraftPolicyFromProto(proto) @@ -442,8 +442,8 @@ func TestSandboxPolicyRevisionFromProto(t *testing.T) { PolicyHash: "sha256:abc123", Status: pb.PolicyStatus_POLICY_STATUS_LOADED, LoadError: "", - CreatedAtMs: 1700000000000, - LoadedAtMs: 1700000001000, + CreatedTime: TimestampFromMillis(1700000000000), + LoadedTime: TimestampFromMillis(1700000001000), Provenance: map[string]string{"source": "api", "user": "admin"}, } @@ -594,7 +594,7 @@ func TestClearResultFromProto_Nil(t *testing.T) { func TestDraftHistoryEntryFromProto(t *testing.T) { proto := &pb.DraftHistoryEntry{ - TimestampMs: 1700000000000, + EventTime: TimestampFromMillis(1700000000000), EventType: "approved", Description: "Chunk c1 approved", ChunkId: "c1", diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go index 8bbb346829..12be9618b8 100644 --- a/sdk/go/openshell/v1/internal/converter/profile.go +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -7,6 +7,7 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "google.golang.org/protobuf/types/known/durationpb" ) // --- ProfileCategory enum mapping --- @@ -170,14 +171,28 @@ func ProfileCredentialToProto(c *types.ProfileCredential) *pb.ProviderProfileCre } } +func profileDurationFromProto(value *durationpb.Duration) *types.ProfileDuration { + if value == nil { + return nil + } + return &types.ProfileDuration{Seconds: value.Seconds, Nanos: value.Nanos} +} + +func profileDurationToProto(value *types.ProfileDuration) *durationpb.Duration { + if value == nil { + return nil + } + return &durationpb.Duration{Seconds: value.Seconds, Nanos: value.Nanos} +} + func profileCredentialRefreshFromProto(r *pb.ProviderCredentialRefresh) *types.ProfileCredentialRefresh { if r == nil { return nil } result := &types.ProfileCredentialRefresh{ Strategy: RefreshStrategyFromProto(r.GetStrategy()), TokenURL: r.GetTokenUrl(), - Scopes: CopyStringSlice(r.GetScopes()), RefreshBeforeSeconds: r.GetRefreshBeforeSeconds(), - MaxLifetimeSeconds: r.GetMaxLifetimeSeconds(), + Scopes: CopyStringSlice(r.GetScopes()), RefreshBefore: profileDurationFromProto(r.RefreshBefore), + MaxLifetime: profileDurationFromProto(r.MaxLifetime), } for _, material := range r.GetMaterial() { result.Material = append(result.Material, types.ProfileCredentialRefreshMaterial{Name: material.GetName(), Description: material.GetDescription(), Required: material.GetRequired(), Secret: material.GetSecret()}) @@ -194,8 +209,8 @@ func profileCredentialRefreshToProto(r *types.ProfileCredentialRefresh) *pb.Prov } result := &pb.ProviderCredentialRefresh{ Strategy: RefreshStrategyToProto(r.Strategy), TokenUrl: r.TokenURL, - Scopes: CopyStringSlice(r.Scopes), RefreshBeforeSeconds: r.RefreshBeforeSeconds, - MaxLifetimeSeconds: r.MaxLifetimeSeconds, + Scopes: CopyStringSlice(r.Scopes), RefreshBefore: profileDurationToProto(r.RefreshBefore), + MaxLifetime: profileDurationToProto(r.MaxLifetime), } for _, material := range r.Material { result.Material = append(result.Material, &pb.ProviderCredentialRefreshMaterial{Name: material.Name, Description: material.Description, Required: material.Required, Secret: material.Secret}) @@ -215,7 +230,7 @@ func tokenGrantFromProto(tg *pb.ProviderCredentialTokenGrant) *types.CredentialT Audience: tg.GetAudience(), JWTSVIDAudience: tg.GetJwtSvidAudience(), Scopes: CopyStringSlice(tg.GetScopes()), - CacheTTLSeconds: tg.GetCacheTtlSeconds(), + CacheTTL: profileDurationFromProto(tg.CacheTtl), ClientAssertionType: tg.GetClientAssertionType(), GrantType: CredentialTokenGrantTypeFromProto(tg.GetGrantType()), SubjectToken: subjectTokenFromProto(tg.GetSubjectToken()), @@ -239,7 +254,7 @@ func tokenGrantToProto(tg *types.CredentialTokenGrant) *pb.ProviderCredentialTok Audience: tg.Audience, JwtSvidAudience: tg.JWTSVIDAudience, Scopes: CopyStringSlice(tg.Scopes), - CacheTtlSeconds: tg.CacheTTLSeconds, + CacheTtl: profileDurationToProto(tg.CacheTTL), ClientAssertionType: tg.ClientAssertionType, GrantType: CredentialTokenGrantTypeToProto(tg.GrantType), SubjectToken: subjectTokenToProto(tg.SubjectToken), diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go index 5d52c0a9ee..162542d1a0 100644 --- a/sdk/go/openshell/v1/internal/converter/profile_test.go +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -11,6 +11,7 @@ import ( sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" ) // --- ProfileCategory --- @@ -154,7 +155,7 @@ func TestProfileCredentialFromProto(t *testing.T) { Audience: "https://api.example.com", JwtSvidAudience: "spiffe://example.com", Scopes: []string{"read", "write"}, - CacheTtlSeconds: 300, + CacheTtl: DurationFromSeconds(300), ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", GrantType: pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE, SubjectToken: &pb.ProviderCredentialTokenGrantSubjectToken{ @@ -187,7 +188,7 @@ func TestProfileCredentialFromProto(t *testing.T) { assert.Equal(t, "https://api.example.com", cred.TokenGrant.Audience) assert.Equal(t, "spiffe://example.com", cred.TokenGrant.JWTSVIDAudience) assert.Equal(t, []string{"read", "write"}, cred.TokenGrant.Scopes) - assert.Equal(t, int64(300), cred.TokenGrant.CacheTTLSeconds) + assert.Equal(t, &v1.ProfileDuration{Seconds: 300}, cred.TokenGrant.CacheTTL) assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", cred.TokenGrant.ClientAssertionType) assert.Equal(t, v1.CredentialTokenGrantTypeTokenExchange, cred.TokenGrant.GrantType) require.NotNil(t, cred.TokenGrant.SubjectToken) @@ -203,6 +204,75 @@ func TestProfileCredentialFromProto(t *testing.T) { assert.Equal(t, []string{"admin"}, cred.TokenGrant.AudienceOverrides[0].Scopes) } +func TestProfileCredentialDurationRoundTripPreservesPresencePrecisionAndValidationSignal(t *testing.T) { + original := &pb.ProviderProfileCredential{ + Refresh: &pb.ProviderCredentialRefresh{ + RefreshBefore: &durationpb.Duration{Nanos: 500_000_000}, + MaxLifetime: &durationpb.Duration{Seconds: 1, Nanos: -1}, + }, + TokenGrant: &pb.ProviderCredentialTokenGrant{ + CacheTtl: &durationpb.Duration{Seconds: 1, Nanos: -1}, + }, + } + credential := ProfileCredentialFromProto(original) + + assert.Equal(t, &v1.ProfileDuration{Nanos: 500_000_000}, credential.Refresh.RefreshBefore) + assert.Equal(t, &v1.ProfileDuration{Seconds: 1, Nanos: -1}, credential.Refresh.MaxLifetime) + assert.Equal(t, &v1.ProfileDuration{Seconds: 1, Nanos: -1}, credential.TokenGrant.CacheTTL) + + roundTripped := ProfileCredentialToProto(credential) + assert.Equal(t, original.Refresh.RefreshBefore.Seconds, roundTripped.Refresh.RefreshBefore.Seconds) + assert.Equal(t, original.Refresh.RefreshBefore.Nanos, roundTripped.Refresh.RefreshBefore.Nanos) + assert.Equal(t, original.Refresh.MaxLifetime.Seconds, roundTripped.Refresh.MaxLifetime.Seconds) + assert.Equal(t, original.Refresh.MaxLifetime.Nanos, roundTripped.Refresh.MaxLifetime.Nanos) + assert.Equal(t, original.TokenGrant.CacheTtl.Seconds, roundTripped.TokenGrant.CacheTtl.Seconds) + assert.Equal(t, original.TokenGrant.CacheTtl.Nanos, roundTripped.TokenGrant.CacheTtl.Nanos) +} + +func TestProfileCredentialDurationRoundTripDistinguishesAbsentAndZero(t *testing.T) { + original := &pb.ProviderProfileCredential{ + Refresh: &pb.ProviderCredentialRefresh{ + RefreshBefore: &durationpb.Duration{}, + MaxLifetime: nil, + }, + } + + roundTripped := ProfileCredentialToProto(ProfileCredentialFromProto(original)) + require.NotNil(t, roundTripped.Refresh.RefreshBefore) + assert.Equal(t, int64(0), roundTripped.Refresh.RefreshBefore.Seconds) + assert.Nil(t, roundTripped.Refresh.MaxLifetime) +} + +func TestProfileCredentialDurationExactValuesAreAuthoritative(t *testing.T) { + credential := &v1.ProfileCredential{ + Refresh: &v1.ProfileCredentialRefresh{ + RefreshBefore: &v1.ProfileDuration{Seconds: 60}, + MaxLifetime: &v1.ProfileDuration{Seconds: 3600, Nanos: 500_000_000}, + }, + TokenGrant: &v1.CredentialTokenGrant{ + CacheTTL: &v1.ProfileDuration{Nanos: 500_000_000}, + }, + } + + converted := ProfileCredentialToProto(credential) + assert.Equal(t, &durationpb.Duration{Seconds: 60}, converted.Refresh.RefreshBefore) + assert.Equal(t, &durationpb.Duration{Seconds: 3600, Nanos: 500_000_000}, converted.Refresh.MaxLifetime) + assert.Equal(t, &durationpb.Duration{Nanos: 500_000_000}, converted.TokenGrant.CacheTtl) +} + +func TestProfileCredentialDurationPreservesExactMutationAfterRead(t *testing.T) { + credential := ProfileCredentialFromProto(&pb.ProviderProfileCredential{ + TokenGrant: &pb.ProviderCredentialTokenGrant{ + CacheTtl: &durationpb.Duration{Seconds: 30, Nanos: 500_000_000}, + }, + }) + credential.TokenGrant.CacheTTL.Seconds = 60 + + roundTripped := ProfileCredentialToProto(credential) + assert.Equal(t, int64(60), roundTripped.TokenGrant.CacheTtl.Seconds) + assert.Equal(t, int32(500_000_000), roundTripped.TokenGrant.CacheTtl.Nanos) +} + func TestProfileCredentialFromProto_DeepCopy(t *testing.T) { proto := &pb.ProviderProfileCredential{ Name: "KEY", @@ -255,13 +325,13 @@ func TestProfileCredentialToProto(t *testing.T) { Required: true, Secret: true, Refresh: &v1.ProfileCredentialRefresh{ - Strategy: v1.RefreshStrategyOAuth2RefreshToken, - TokenURL: "https://auth.example.com/token", - Scopes: []string{"offline_access"}, - RefreshBeforeSeconds: 60, - MaxLifetimeSeconds: 3600, - Material: []v1.ProfileCredentialRefreshMaterial{{Name: "refresh_token", Required: true, Secret: true}}, - AdditionalOutputs: []v1.ProfileCredentialRefreshOutput{{Output: "session_token", Credential: "SESSION_TOKEN"}}, + Strategy: v1.RefreshStrategyOAuth2RefreshToken, + TokenURL: "https://auth.example.com/token", + Scopes: []string{"offline_access"}, + RefreshBefore: &v1.ProfileDuration{Seconds: 60}, + MaxLifetime: &v1.ProfileDuration{Seconds: 3600}, + Material: []v1.ProfileCredentialRefreshMaterial{{Name: "refresh_token", Required: true, Secret: true}}, + AdditionalOutputs: []v1.ProfileCredentialRefreshOutput{{Output: "session_token", Credential: "SESSION_TOKEN"}}, }, AuthStyle: "header", HeaderName: "X-API-Key", @@ -272,7 +342,7 @@ func TestProfileCredentialToProto(t *testing.T) { Audience: "https://api.example.com", JWTSVIDAudience: "spiffe://example.com", Scopes: []string{"read"}, - CacheTTLSeconds: 300, + CacheTTL: &v1.ProfileDuration{Seconds: 300}, ClientAssertionType: "urn:custom", GrantType: v1.CredentialTokenGrantTypeTokenExchange, SubjectToken: &v1.TokenGrantSubjectToken{ @@ -302,6 +372,8 @@ func TestProfileCredentialToProto(t *testing.T) { assert.Equal(t, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, proto.Refresh.Strategy) assert.Equal(t, "https://auth.example.com/token", proto.Refresh.TokenUrl) assert.Equal(t, []string{"offline_access"}, proto.Refresh.Scopes) + assert.Equal(t, &durationpb.Duration{Seconds: 60}, proto.Refresh.RefreshBefore) + assert.Equal(t, &durationpb.Duration{Seconds: 3600}, proto.Refresh.MaxLifetime) require.Len(t, proto.Refresh.Material, 1) require.Len(t, proto.Refresh.AdditionalOutputs, 1) @@ -310,7 +382,7 @@ func TestProfileCredentialToProto(t *testing.T) { assert.Equal(t, "https://api.example.com", proto.TokenGrant.Audience) assert.Equal(t, "spiffe://example.com", proto.TokenGrant.JwtSvidAudience) assert.Equal(t, []string{"read"}, proto.TokenGrant.Scopes) - assert.Equal(t, int64(300), proto.TokenGrant.CacheTtlSeconds) + assert.Equal(t, int64(300), proto.TokenGrant.CacheTtl.GetSeconds()) assert.Equal(t, "urn:custom", proto.TokenGrant.ClientAssertionType) assert.Equal(t, pb.ProviderCredentialTokenGrantType_PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE, proto.TokenGrant.GrantType) require.NotNil(t, proto.TokenGrant.SubjectToken) @@ -322,6 +394,22 @@ func TestProfileCredentialToProto(t *testing.T) { assert.Equal(t, "h", proto.TokenGrant.AudienceOverrides[0].Host) } +func TestProfileCredentialToProto_PreservesInvalidDurationsForValidation(t *testing.T) { + proto := ProfileCredentialToProto(&v1.ProfileCredential{ + Refresh: &v1.ProfileCredentialRefresh{ + RefreshBefore: &v1.ProfileDuration{Seconds: -1}, + MaxLifetime: &v1.ProfileDuration{Seconds: 1, Nanos: -1}, + }, + TokenGrant: &v1.CredentialTokenGrant{ + CacheTTL: &v1.ProfileDuration{Nanos: 1_000_000_000}, + }, + }) + + assert.Equal(t, &durationpb.Duration{Seconds: -1}, proto.Refresh.RefreshBefore) + assert.Equal(t, &durationpb.Duration{Seconds: 1, Nanos: -1}, proto.Refresh.MaxLifetime) + assert.Equal(t, &durationpb.Duration{Nanos: 1_000_000_000}, proto.TokenGrant.CacheTtl) +} + func TestProfileCredentialToProto_Nil(t *testing.T) { proto := ProfileCredentialToProto(nil) assert.Nil(t, proto) @@ -574,7 +662,7 @@ func TestProviderProfileRoundTrip(t *testing.T) { Audience: "https://api.example.com", JWTSVIDAudience: "spiffe://example.com", Scopes: []string{"read"}, - CacheTTLSeconds: 600, + CacheTTL: &v1.ProfileDuration{Seconds: 600}, ClientAssertionType: "urn:custom", AudienceOverrides: []v1.TokenGrantAudienceOverride{ {Host: "h", Port: 443, Path: "/p", Audience: "aud", Scopes: []string{"s"}}, @@ -626,7 +714,7 @@ func TestProviderProfileRoundTrip(t *testing.T) { assert.Equal(t, original.Credentials[0].TokenGrant.Audience, c.TokenGrant.Audience) assert.Equal(t, original.Credentials[0].TokenGrant.JWTSVIDAudience, c.TokenGrant.JWTSVIDAudience) assert.Equal(t, original.Credentials[0].TokenGrant.Scopes, c.TokenGrant.Scopes) - assert.Equal(t, original.Credentials[0].TokenGrant.CacheTTLSeconds, c.TokenGrant.CacheTTLSeconds) + assert.Equal(t, original.Credentials[0].TokenGrant.CacheTTL, c.TokenGrant.CacheTTL) assert.Equal(t, original.Credentials[0].TokenGrant.ClientAssertionType, c.TokenGrant.ClientAssertionType) require.Len(t, c.TokenGrant.AudienceOverrides, 1) assert.Equal(t, original.Credentials[0].TokenGrant.AudienceOverrides[0], c.TokenGrant.AudienceOverrides[0]) diff --git a/sdk/go/openshell/v1/internal/converter/provider.go b/sdk/go/openshell/v1/internal/converter/provider.go index 42799feab0..119e288be0 100644 --- a/sdk/go/openshell/v1/internal/converter/provider.go +++ b/sdk/go/openshell/v1/internal/converter/provider.go @@ -8,6 +8,7 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + "google.golang.org/protobuf/types/known/timestamppb" ) // ProviderFromProto converts a proto Provider to an SDK Provider. @@ -27,18 +28,18 @@ func ProviderFromProto(p *dm.Provider) *types.Provider { if m := p.GetMetadata(); m != nil { result.ID = m.GetId() result.Name = m.GetName() - result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.CreatedAt = TimeFromProto(m.GetCreatedTime()) result.Labels = CopyStringMap(m.GetLabels()) result.Annotations = CopyStringMap(m.GetAnnotations()) result.ResourceVersion = m.GetResourceVersion() result.Workspace = m.GetWorkspace() - result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) + result.DeletionTimestamp = TimePtrFromProto(m.GetDeletionTime()) } - if expires := p.GetCredentialExpiresAtMs(); len(expires) > 0 { + if expires := p.GetCredentialExpirationTimes(); len(expires) > 0 { result.Spec.CredentialExpiresAt = make(map[string]time.Time, len(expires)) - for k, ms := range expires { - result.Spec.CredentialExpiresAt[k] = TimeFromMillis(ms) + for k, timestamp := range expires { + result.Spec.CredentialExpiresAt[k] = TimeFromProto(timestamp) } } @@ -64,14 +65,14 @@ func ProviderToProto(p *types.Provider) *dm.Provider { result := &dm.Provider{ Metadata: &dm.ObjectMeta{ - Id: p.ID, - Name: p.Name, - CreatedAtMs: MillisFromTime(p.CreatedAt), - Labels: CopyStringMap(p.Labels), - Annotations: CopyStringMap(p.Annotations), - ResourceVersion: p.ResourceVersion, - Workspace: p.Workspace, - DeletionTimestampMs: MillisFromTimePtr(p.DeletionTimestamp), + Id: p.ID, + Name: p.Name, + CreatedTime: TimestampFromTime(p.CreatedAt), + Labels: CopyStringMap(p.Labels), + Annotations: CopyStringMap(p.Annotations), + ResourceVersion: p.ResourceVersion, + Workspace: p.Workspace, + DeletionTime: TimestampFromTimePtr(p.DeletionTimestamp), }, Type: p.Type, Credentials: CopyStringMap(p.Spec.Credentials), @@ -80,9 +81,11 @@ func ProviderToProto(p *types.Provider) *dm.Provider { } if len(p.Spec.CredentialExpiresAt) > 0 { - result.CredentialExpiresAtMs = make(map[string]int64, len(p.Spec.CredentialExpiresAt)) + result.CredentialExpirationTimes = make(map[string]*timestamppb.Timestamp, len(p.Spec.CredentialExpiresAt)) for k, t := range p.Spec.CredentialExpiresAt { - result.CredentialExpiresAtMs[k] = MillisFromTime(t) + if !t.IsZero() { + result.CredentialExpirationTimes[k] = TimestampFromTime(t) + } } } diff --git a/sdk/go/openshell/v1/internal/converter/provider_test.go b/sdk/go/openshell/v1/internal/converter/provider_test.go index 84411830c4..614077a3d9 100644 --- a/sdk/go/openshell/v1/internal/converter/provider_test.go +++ b/sdk/go/openshell/v1/internal/converter/provider_test.go @@ -11,6 +11,7 @@ import ( dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestProviderFromProto_Nil(t *testing.T) { @@ -22,7 +23,7 @@ func TestProviderFromProto_Full(t *testing.T) { Metadata: &dm.ObjectMeta{ Id: "prov-1", Name: "claude-provider", - CreatedAtMs: 1700000000000, + CreatedTime: TimestampFromMillis(1700000000000), Labels: map[string]string{"env": "prod"}, Annotations: map[string]string{"note": "test"}, ResourceVersion: 42, @@ -32,8 +33,8 @@ func TestProviderFromProto_Full(t *testing.T) { Credentials: map[string]string{"api_key": "secret"}, Config: map[string]string{"base_url": "https://api.example.com"}, ProfileWorkspace: "shared", - CredentialExpiresAtMs: map[string]int64{ - "api_key": 1700003600000, + CredentialExpirationTimes: map[string]*timestamppb.Timestamp{ + "api_key": TimestampFromMillis(1700003600000), }, CredentialHandles: map[string]*dm.CredentialHandle{ "api_key": { @@ -133,8 +134,8 @@ func TestProviderToProto_Full(t *testing.T) { assert.Equal(t, map[string]string{"token": "abc"}, result.Credentials) assert.Equal(t, map[string]string{"url": "https://example.com"}, result.Config) - require.Len(t, result.CredentialExpiresAtMs, 1) - assert.Greater(t, result.CredentialExpiresAtMs["token"], int64(0)) + require.Len(t, result.CredentialExpirationTimes, 1) + assert.Greater(t, MillisFromProto(result.CredentialExpirationTimes["token"]), int64(0)) require.Len(t, result.CredentialHandles, 1) h := result.CredentialHandles["token"] @@ -143,6 +144,14 @@ func TestProviderToProto_Full(t *testing.T) { assert.Equal(t, map[string]string{"k": "v"}, h.Metadata) } +func TestProviderToProto_OmitsZeroCredentialExpiry(t *testing.T) { + result := ProviderToProto(&types.Provider{Spec: types.ProviderSpec{ + CredentialExpiresAt: map[string]time.Time{"token": {}}, + }}) + + assert.NotContains(t, result.CredentialExpirationTimes, "token") +} + func TestProviderFromProto_DeepCopyCredentialHandles(t *testing.T) { proto := &dm.Provider{ Metadata: &dm.ObjectMeta{Name: "deep-copy-test"}, diff --git a/sdk/go/openshell/v1/internal/converter/refresh.go b/sdk/go/openshell/v1/internal/converter/refresh.go index be5ee8d03a..a3e98c3bce 100644 --- a/sdk/go/openshell/v1/internal/converter/refresh.go +++ b/sdk/go/openshell/v1/internal/converter/refresh.go @@ -4,9 +4,6 @@ package converter import ( - "math" - "time" - "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" ) @@ -51,13 +48,6 @@ func RefreshRecoveryActionFromProto(a pb.ProviderCredentialRefreshRecoveryAction } } -func refreshNextTimeFromMillis(ms int64) time.Time { - if ms == math.MaxInt64 { - return time.Time{} - } - return TimeFromMillis(ms) -} - // RefreshStrategyToProto converts an SDK RefreshStrategy to a proto ProviderCredentialRefreshStrategy. func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefreshStrategy { switch s { @@ -91,14 +81,14 @@ func RefreshStatusFromProto(s *pb.ProviderCredentialRefreshStatus) *types.Refres CredentialKey: s.GetCredentialKey(), Strategy: RefreshStrategyFromProto(s.GetStrategy()), Status: s.GetStatus(), - ExpiresAt: TimeFromMillis(s.GetExpiresAtMs()), - NextRefreshAt: refreshNextTimeFromMillis(s.GetNextRefreshAtMs()), - LastRefreshAt: TimeFromMillis(s.GetLastRefreshAtMs()), + ExpiresAt: TimeFromProto(s.GetExpirationTime()), + NextRefreshAt: TimeFromProto(s.GetNextRefreshTime()), + LastRefreshAt: TimeFromProto(s.GetLastRefreshTime()), LastError: s.GetLastError(), RecoveryAction: RefreshRecoveryActionFromProto(s.GetRecoveryAction()), FailureCode: s.GetFailureCode(), ProviderErrorSubtype: s.GetProviderErrorSubtype(), - LastErrorAt: TimeFromMillis(s.GetLastErrorAtMs()), + LastErrorAt: TimeFromProto(s.GetLastErrorTime()), } } @@ -120,8 +110,7 @@ func RefreshConfigToProto(c *types.RefreshConfig) *pb.ConfigureProviderRefreshRe } if c.ExpiresAt != nil { - ms := MillisFromTime(*c.ExpiresAt) - result.ExpiresAtMs = &ms + result.ExpirationTime = TimestampFromTime(*c.ExpiresAt) } return result diff --git a/sdk/go/openshell/v1/internal/converter/refresh_test.go b/sdk/go/openshell/v1/internal/converter/refresh_test.go index 9ffa6e590a..16e98c96a2 100644 --- a/sdk/go/openshell/v1/internal/converter/refresh_test.go +++ b/sdk/go/openshell/v1/internal/converter/refresh_test.go @@ -83,14 +83,14 @@ func TestRefreshStatusFromProto(t *testing.T) { CredentialKey: "API_KEY", Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, Status: "active", - ExpiresAtMs: 1700000000000, - NextRefreshAtMs: 1699999000000, - LastRefreshAtMs: 1699998000000, + ExpirationTime: TimestampFromMillis(1700000000000), + NextRefreshTime: TimestampFromMillis(1699999000000), + LastRefreshTime: TimestampFromMillis(1699998000000), LastError: "none", RecoveryAction: pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE, FailureCode: "oauth_invalid_grant", ProviderErrorSubtype: "invalid_rapt", - LastErrorAtMs: 1699997000000, + LastErrorTime: TimestampFromMillis(1699997000000), } status := RefreshStatusFromProto(proto) @@ -138,7 +138,7 @@ func TestRefreshStatusFromProto_ParkedRefreshHasNoNextTime(t *testing.T) { proto := &pb.ProviderCredentialRefreshStatus{ ProviderName: "test", CredentialKey: "KEY", - NextRefreshAtMs: math.MaxInt64, + NextRefreshTime: nil, RecoveryAction: pb.ProviderCredentialRefreshRecoveryAction_PROVIDER_CREDENTIAL_REFRESH_RECOVERY_ACTION_REAUTHORIZE, } @@ -189,8 +189,8 @@ func TestRefreshConfigToProto(t *testing.T) { assert.Equal(t, "client_secret", proto.SecretMaterialKeys[0], "secret keys must be deep copied") // ExpiresAt conversion - require.NotNil(t, proto.ExpiresAtMs) - assert.Equal(t, MillisFromTime(expiresAt), *proto.ExpiresAtMs) + require.NotNil(t, proto.ExpirationTime) + assert.Equal(t, MillisFromTime(expiresAt), MillisFromProto(proto.ExpirationTime)) } func TestRefreshConfigToProto_NilExpiresAt(t *testing.T) { @@ -203,7 +203,7 @@ func TestRefreshConfigToProto_NilExpiresAt(t *testing.T) { proto := RefreshConfigToProto(config) require.NotNil(t, proto) - assert.Nil(t, proto.ExpiresAtMs) + assert.Nil(t, proto.ExpirationTime) } func TestRefreshConfigToProto_Nil(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index bf59d7a1d4..ab064c2231 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -26,12 +26,12 @@ func SandboxFromProto(s *pb.Sandbox) *types.Sandbox { if m := s.GetMetadata(); m != nil { result.ID = m.GetId() result.Name = m.GetName() - result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.CreatedAt = TimeFromProto(m.GetCreatedTime()) result.Labels = CopyStringMap(m.GetLabels()) result.Annotations = CopyStringMap(m.GetAnnotations()) result.ResourceVersion = m.GetResourceVersion() result.Workspace = m.GetWorkspace() - result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) + result.DeletionTimestamp = TimePtrFromProto(m.GetDeletionTime()) } if provenance := s.GetCreatedFromWorkloadTemplate(); provenance != nil { @@ -111,7 +111,7 @@ func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus { Status: c.GetStatus(), Reason: c.GetReason(), Message: c.GetMessage(), - LastTransitionTime: c.GetLastTransitionTime(), + LastTransitionTime: TimestampStringFromProto(c.GetTransitionTime()), }) } for _, endpoint := range status.GetEndpointStatuses() { @@ -121,7 +121,7 @@ func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus { Ports: slices.Clone(endpoint.GetPorts()), Path: endpoint.GetPath(), LastResult: endpointResultFromProto(endpoint.GetLastResult()), - LastReportedAt: endpoint.GetLastReportedAt(), + LastReportedAt: TimestampStringFromProto(endpoint.GetLastReportedTime()), }) } result.ExitCode = CopyInt32Ptr(status.ExitCode) @@ -211,14 +211,14 @@ func SandboxToProto(s *types.Sandbox) *pb.Sandbox { return &pb.Sandbox{ Metadata: &dm.ObjectMeta{ - Id: s.ID, - Name: s.Name, - CreatedAtMs: MillisFromTime(s.CreatedAt), - Labels: CopyStringMap(s.Labels), - Annotations: CopyStringMap(s.Annotations), - ResourceVersion: s.ResourceVersion, - Workspace: s.Workspace, - DeletionTimestampMs: MillisFromTimePtr(s.DeletionTimestamp), + Id: s.ID, + Name: s.Name, + CreatedTime: TimestampFromTime(s.CreatedAt), + Labels: CopyStringMap(s.Labels), + Annotations: CopyStringMap(s.Annotations), + ResourceVersion: s.ResourceVersion, + Workspace: s.Workspace, + DeletionTime: TimestampFromTimePtr(s.DeletionTimestamp), }, Spec: SandboxSpecToProto(&s.Spec), } @@ -320,12 +320,12 @@ func SandboxWorkloadTemplateFromProto(t *pb.SandboxWorkloadTemplate) *types.Sand if m := t.GetMetadata(); m != nil { result.ID = m.GetId() result.Name = m.GetName() - result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.CreatedAt = TimeFromProto(m.GetCreatedTime()) result.Labels = CopyStringMap(m.GetLabels()) result.Annotations = CopyStringMap(m.GetAnnotations()) result.ResourceVersion = m.GetResourceVersion() result.Workspace = m.GetWorkspace() - result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) + result.DeletionTimestamp = TimePtrFromProto(m.GetDeletionTime()) } if spec := t.GetSpec(); spec != nil { result.Spec = SandboxWorkloadTemplateSpecFromProto(spec) @@ -399,14 +399,14 @@ func SandboxWorkloadTemplateToProto(t *types.SandboxWorkloadTemplate) *pb.Sandbo } return &pb.SandboxWorkloadTemplate{ Metadata: &dm.ObjectMeta{ - Id: t.ID, - Name: t.Name, - CreatedAtMs: MillisFromTime(t.CreatedAt), - Labels: CopyStringMap(t.Labels), - Annotations: CopyStringMap(t.Annotations), - ResourceVersion: t.ResourceVersion, - Workspace: t.Workspace, - DeletionTimestampMs: MillisFromTimePtr(t.DeletionTimestamp), + Id: t.ID, + Name: t.Name, + CreatedTime: TimestampFromTime(t.CreatedAt), + Labels: CopyStringMap(t.Labels), + Annotations: CopyStringMap(t.Annotations), + ResourceVersion: t.ResourceVersion, + Workspace: t.Workspace, + DeletionTime: TimestampFromTimePtr(t.DeletionTimestamp), }, Spec: SandboxWorkloadTemplateSpecToProto(&t.Spec), } diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index ed4575c6f2..f80fef832e 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -16,6 +16,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestSandboxFromProto(t *testing.T) { @@ -24,14 +25,14 @@ func TestSandboxFromProto(t *testing.T) { exitCode := int32(0) proto := &pb.Sandbox{ Metadata: &dm.ObjectMeta{ - Id: "sb-1", - Name: "my-sandbox", - CreatedAtMs: 1700000000000, - Labels: map[string]string{"env": "dev"}, - Annotations: map[string]string{"owner": "team-a"}, - ResourceVersion: 3, - Workspace: "prod", - DeletionTimestampMs: 1700000060000, + Id: "sb-1", + Name: "my-sandbox", + CreatedTime: TimestampFromMillis(1700000000000), + Labels: map[string]string{"env": "dev"}, + Annotations: map[string]string{"owner": "team-a"}, + ResourceVersion: 3, + Workspace: "prod", + DeletionTime: TimestampFromMillis(1700000060000), }, Spec: &pb.SandboxSpec{ LogLevel: "debug", @@ -77,11 +78,11 @@ func TestSandboxFromProto(t *testing.T) { ExitCode: &exitCode, Conditions: []*pb.SandboxCondition{ { - Type: "Ready", - Status: "True", - Reason: "AllGood", - Message: "Sandbox is ready", - LastTransitionTime: "2024-01-01T00:00:00Z", + Type: "Ready", + Status: "True", + Reason: "AllGood", + Message: "Sandbox is ready", + TransitionTime: TimestampFromMillis(1704067200000), }, }, }, @@ -195,7 +196,7 @@ func TestSandboxFromProto_EndpointStatuses(t *testing.T) { Type: "Ready", Status: "True", Reason: "AllGood", Message: "Sandbox is ready", }}, EndpointStatuses: []*pb.EndpointStatus{ - {EndpointId: "endpoint-one", Host: "tools.example.test", Ports: []uint32{443, 8443}, Path: "/mcp", LastResult: pb.EndpointResult_ENDPOINT_RESULT_TRANSPORT_FAILED, LastReportedAt: "2026-09-11T10:00:00Z"}, + {EndpointId: "endpoint-one", Host: "tools.example.test", Ports: []uint32{443, 8443}, Path: "/mcp", LastResult: pb.EndpointResult_ENDPOINT_RESULT_TRANSPORT_FAILED, LastReportedTime: timestamppb.New(time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC))}, {EndpointId: "endpoint-two", Host: "tools.example.test", Ports: []uint32{443}, Path: "/other", LastResult: pb.EndpointResult_ENDPOINT_RESULT_NO_OBSERVED_EXCHANGE}, }, }} @@ -360,12 +361,12 @@ func TestSandboxToProto(t *testing.T) { require.NotNil(t, p.Metadata) assert.Equal(t, "sb-1", p.Metadata.Id) assert.Equal(t, "my-sandbox", p.Metadata.Name) - assert.Equal(t, int64(1700000000000), p.Metadata.CreatedAtMs) + assert.Equal(t, int64(1700000000000), MillisFromProto(p.Metadata.CreatedTime)) assert.Equal(t, map[string]string{"env": "dev"}, p.Metadata.Labels) assert.Equal(t, map[string]string{"owner": "team-a"}, p.Metadata.Annotations) assert.Equal(t, uint64(3), p.Metadata.ResourceVersion) assert.Equal(t, "prod", p.Metadata.Workspace) - assert.Equal(t, int64(1700000060000), p.Metadata.DeletionTimestampMs) + assert.Equal(t, int64(1700000060000), MillisFromProto(p.Metadata.DeletionTime)) require.NotNil(t, p.Spec) assert.Equal(t, "info", p.Spec.LogLevel) diff --git a/sdk/go/openshell/v1/internal/converter/ssh.go b/sdk/go/openshell/v1/internal/converter/ssh.go index 538c1d17f5..e437d8808d 100644 --- a/sdk/go/openshell/v1/internal/converter/ssh.go +++ b/sdk/go/openshell/v1/internal/converter/ssh.go @@ -20,7 +20,7 @@ func SSHSessionFromProto(resp *pb.CreateSshSessionResponse) *v1.SSHSession { GatewayPort: resp.GetGatewayPort(), GatewayScheme: resp.GetGatewayScheme(), HostKeyFingerprint: resp.GetHostKeyFingerprint(), - ExpiresAtMs: resp.GetExpiresAtMs(), + ExpiresAtMs: MillisFromProto(resp.GetExpirationTime()), } } @@ -37,6 +37,6 @@ func SSHSessionToProto(session *v1.SSHSession) *pb.CreateSshSessionResponse { GatewayPort: session.GatewayPort, GatewayScheme: session.GatewayScheme, HostKeyFingerprint: session.HostKeyFingerprint, - ExpiresAtMs: session.ExpiresAtMs, + ExpirationTime: TimestampFromMillis(session.ExpiresAtMs), } } diff --git a/sdk/go/openshell/v1/internal/converter/ssh_test.go b/sdk/go/openshell/v1/internal/converter/ssh_test.go index 11ce395d75..dbff7f194e 100644 --- a/sdk/go/openshell/v1/internal/converter/ssh_test.go +++ b/sdk/go/openshell/v1/internal/converter/ssh_test.go @@ -20,7 +20,7 @@ func TestSSHSessionFromProto(t *testing.T) { GatewayPort: 2222, GatewayScheme: "https", HostKeyFingerprint: "SHA256:abc123", - ExpiresAtMs: 1700000000000, + ExpirationTime: TimestampFromMillis(1700000000000), } session := SSHSessionFromProto(resp) @@ -80,7 +80,7 @@ func TestSSHSessionToProto(t *testing.T) { assert.Equal(t, uint32(2222), resp.GatewayPort) assert.Equal(t, "https", resp.GatewayScheme) assert.Equal(t, "SHA256:abc123", resp.HostKeyFingerprint) - assert.Equal(t, int64(1700000000000), resp.ExpiresAtMs) + assert.Equal(t, int64(1700000000000), MillisFromProto(resp.ExpirationTime)) } func TestSSHSessionToProto_Nil(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/time.go b/sdk/go/openshell/v1/internal/converter/time.go index 28a633cdff..1a304bc11f 100644 --- a/sdk/go/openshell/v1/internal/converter/time.go +++ b/sdk/go/openshell/v1/internal/converter/time.go @@ -3,7 +3,86 @@ package converter -import "time" +import ( + "time" + + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// TimeFromProto converts a valid protobuf timestamp to UTC time. +func TimeFromProto(value *timestamppb.Timestamp) time.Time { + if value == nil || value.CheckValid() != nil { + return time.Time{} + } + return value.AsTime().UTC() +} + +// TimePtrFromProto converts a valid protobuf timestamp to a UTC time pointer. +func TimePtrFromProto(value *timestamppb.Timestamp) *time.Time { + if value == nil || value.CheckValid() != nil { + return nil + } + converted := value.AsTime().UTC() + return &converted +} + +// TimestampFromTime converts a non-zero time to a protobuf timestamp. +func TimestampFromTime(value time.Time) *timestamppb.Timestamp { + if value.IsZero() { + return nil + } + return timestamppb.New(value) +} + +// TimestampFromTimePtr converts a non-nil time pointer to a protobuf timestamp. +func TimestampFromTimePtr(value *time.Time) *timestamppb.Timestamp { + if value == nil { + return nil + } + return timestamppb.New(*value) +} + +// MillisFromProto converts a valid protobuf timestamp to Unix milliseconds. +func MillisFromProto(value *timestamppb.Timestamp) int64 { + converted := TimeFromProto(value) + if converted.IsZero() { + return 0 + } + return converted.UnixMilli() +} + +// TimestampFromMillis converts non-zero Unix milliseconds to a protobuf timestamp. +func TimestampFromMillis(value int64) *timestamppb.Timestamp { + if value == 0 { + return nil + } + return timestamppb.New(time.UnixMilli(value)) +} + +// TimestampStringFromProto formats a valid protobuf timestamp as RFC 3339. +func TimestampStringFromProto(value *timestamppb.Timestamp) string { + if value == nil || value.CheckValid() != nil { + return "" + } + return value.AsTime().UTC().Format(time.RFC3339Nano) +} + +// DurationSecondsFromProto converts a valid non-negative protobuf duration to seconds. +func DurationSecondsFromProto(value *durationpb.Duration) uint64 { + if value == nil || value.CheckValid() != nil || value.AsDuration() < 0 { + return 0 + } + return uint64(value.AsDuration() / time.Second) +} + +// DurationFromSeconds converts non-zero seconds to a protobuf duration. +func DurationFromSeconds(value uint64) *durationpb.Duration { + if value == 0 || value > uint64((time.Duration(1<<63-1))/time.Second) { + return nil + } + return durationpb.New(time.Duration(value) * time.Second) +} // TimeFromMillis converts a millisecond epoch timestamp to time.Time. // A zero value returns the zero time. diff --git a/sdk/go/openshell/v1/internal/converter/time_test.go b/sdk/go/openshell/v1/internal/converter/time_test.go index 854fe98816..f0f6bd421b 100644 --- a/sdk/go/openshell/v1/internal/converter/time_test.go +++ b/sdk/go/openshell/v1/internal/converter/time_test.go @@ -8,8 +8,26 @@ import ( "time" "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" ) +func TestTimePtrProtoRoundTripPreservesMinimumTimestamp(t *testing.T) { + minimum := ×tamppb.Timestamp{Seconds: -62_135_596_800} + + converted := TimePtrFromProto(minimum) + assert.NotNil(t, converted) + assert.True(t, converted.IsZero()) + assert.Equal(t, minimum, TimestampFromTimePtr(converted)) +} + +func TestTimestampStringFromProtoPreservesMinimumTimestamp(t *testing.T) { + minimum := ×tamppb.Timestamp{Seconds: -62_135_596_800} + + assert.Equal(t, "0001-01-01T00:00:00Z", TimestampStringFromProto(minimum)) + assert.Empty(t, TimestampStringFromProto(nil)) + assert.Empty(t, TimestampStringFromProto(×tamppb.Timestamp{Nanos: -1})) +} + func TestTimeFromMillis(t *testing.T) { ms := int64(1719475200000) // 2024-06-27T12:00:00Z tm := TimeFromMillis(ms) diff --git a/sdk/go/openshell/v1/internal/converter/workspace.go b/sdk/go/openshell/v1/internal/converter/workspace.go index 80925a338b..71e6129345 100644 --- a/sdk/go/openshell/v1/internal/converter/workspace.go +++ b/sdk/go/openshell/v1/internal/converter/workspace.go @@ -20,12 +20,12 @@ func WorkspaceFromProto(w *dm.Workspace) *types.Workspace { if m := w.GetMetadata(); m != nil { result.ID = m.GetId() result.Name = m.GetName() - result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.CreatedAt = TimeFromProto(m.GetCreatedTime()) result.Labels = CopyStringMap(m.GetLabels()) result.Annotations = CopyStringMap(m.GetAnnotations()) result.ResourceVersion = m.GetResourceVersion() result.Workspace = m.GetWorkspace() - result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) + result.DeletionTimestamp = TimePtrFromProto(m.GetDeletionTime()) } if status := w.GetStatus(); status != nil { @@ -63,7 +63,7 @@ func WorkspaceMemberFromProto(m *pb.WorkspaceMember) *types.WorkspaceMember { if meta := m.GetMetadata(); meta != nil { result.ID = meta.GetId() result.Name = meta.GetName() - result.CreatedAt = TimeFromMillis(meta.GetCreatedAtMs()) + result.CreatedAt = TimeFromProto(meta.GetCreatedTime()) result.Labels = CopyStringMap(meta.GetLabels()) result.Annotations = CopyStringMap(meta.GetAnnotations()) result.ResourceVersion = meta.GetResourceVersion() diff --git a/sdk/go/openshell/v1/internal/converter/workspace_test.go b/sdk/go/openshell/v1/internal/converter/workspace_test.go index e86ec443c0..9d39f25fed 100644 --- a/sdk/go/openshell/v1/internal/converter/workspace_test.go +++ b/sdk/go/openshell/v1/internal/converter/workspace_test.go @@ -17,14 +17,14 @@ import ( func TestWorkspaceFromProto(t *testing.T) { proto := &dm.Workspace{ Metadata: &dm.ObjectMeta{ - Id: "ws-1", - Name: "my-workspace", - CreatedAtMs: 1700000000000, - Labels: map[string]string{"team": "platform"}, - Annotations: map[string]string{"managed-by": "sdk"}, - ResourceVersion: 3, - Workspace: "", - DeletionTimestampMs: 1700000060000, + Id: "ws-1", + Name: "my-workspace", + CreatedTime: TimestampFromMillis(1700000000000), + Labels: map[string]string{"team": "platform"}, + Annotations: map[string]string{"managed-by": "sdk"}, + ResourceVersion: 3, + Workspace: "", + DeletionTime: TimestampFromMillis(1700000060000), }, Status: &dm.WorkspaceStatus{ Phase: dm.WorkspacePhase_WORKSPACE_PHASE_ACTIVE, @@ -115,7 +115,7 @@ func TestWorkspaceMemberFromProto(t *testing.T) { Metadata: &dm.ObjectMeta{ Id: "mem-1", Name: "member-auto-name", - CreatedAtMs: 1700000000000, + CreatedTime: TimestampFromMillis(1700000000000), Annotations: map[string]string{"source": "cli"}, ResourceVersion: 2, }, diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go index 10e8fd7bb3..3b736fc09f 100644 --- a/sdk/go/openshell/v1/policy_client_test.go +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -8,6 +8,7 @@ import ( "net" "sync" "testing" + "time" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" @@ -19,8 +20,13 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/timestamppb" ) +func testTimestamp(milliseconds int64) *timestamppb.Timestamp { + return timestamppb.New(time.UnixMilli(milliseconds)) +} + // --- Mock server for Policy RPCs --- type mockPolicyServer struct { @@ -206,7 +212,7 @@ func TestPolicyGetDraft(t *testing.T) { Rationale: "DNS access needed", Confidence: 0.95, DenialSummaryIds: []string{"ds-1", "ds-2"}, - CreatedAtMs: 1700000000000, + CreatedTime: testTimestamp(1700000000000), Stage: "initial", HitCount: 3, Binary: "/usr/bin/curl", @@ -222,7 +228,7 @@ func TestPolicyGetDraft(t *testing.T) { }, RollingSummary: "Two rules proposed", DraftVersion: 5, - LastAnalyzedAtMs: 1700000001000, + LastAnalyzedTime: testTimestamp(1700000001000), } client, cleanup := setupPolicyTest(t, mock) @@ -493,13 +499,13 @@ func TestPolicyGetDraftHistory(t *testing.T) { mock.historyResp = &pb.GetDraftHistoryResponse{ Entries: []*pb.DraftHistoryEntry{ { - TimestampMs: 1700000000000, + EventTime: testTimestamp(1700000000000), EventType: "approved", Description: "Chunk chunk-1 approved", ChunkId: "chunk-1", }, { - TimestampMs: 1700000001000, + EventTime: testTimestamp(1700000001000), EventType: "rejected", Description: "Chunk chunk-2 rejected: too broad", ChunkId: "chunk-2", @@ -567,8 +573,8 @@ func TestPolicyGetStatus(t *testing.T) { Version: 3, PolicyHash: "sha256:rev3", Status: pb.PolicyStatus_POLICY_STATUS_LOADED, - CreatedAtMs: 1700000000000, - LoadedAtMs: 1700000001000, + CreatedTime: testTimestamp(1700000000000), + LoadedTime: testTimestamp(1700000001000), }, ActiveVersion: 3, } @@ -758,14 +764,14 @@ func TestPolicyList(t *testing.T) { Version: 1, PolicyHash: "sha256:v1", Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, - CreatedAtMs: 1700000000000, + CreatedTime: testTimestamp(1700000000000), }, { Version: 2, PolicyHash: "sha256:v2", Status: pb.PolicyStatus_POLICY_STATUS_LOADED, - CreatedAtMs: 1700000001000, - LoadedAtMs: 1700000002000, + CreatedTime: testTimestamp(1700000001000), + LoadedTime: testTimestamp(1700000002000), }, }, } diff --git a/sdk/go/openshell/v1/provider_client.go b/sdk/go/openshell/v1/provider_client.go index 5d991052fd..15b3543089 100644 --- a/sdk/go/openshell/v1/provider_client.go +++ b/sdk/go/openshell/v1/provider_client.go @@ -5,6 +5,7 @@ package v1 import ( "context" + "sort" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" @@ -99,7 +100,13 @@ func (p *providerClient) Update(ctx context.Context, workspace string, provider WorkspaceScope: namedWorkspaceScope(workspace), } if proto != nil { - req.CredentialExpiresAtMs = proto.CredentialExpiresAtMs + req.CredentialExpirationTimes = proto.CredentialExpirationTimes + for key, expiresAt := range provider.Spec.CredentialExpiresAt { + if expiresAt.IsZero() { + req.ClearCredentialExpirationKeys = append(req.ClearCredentialExpirationKeys, key) + } + } + sort.Strings(req.ClearCredentialExpirationKeys) } resp, err := p.client.UpdateProvider(ctx, req) diff --git a/sdk/go/openshell/v1/provider_client_test.go b/sdk/go/openshell/v1/provider_client_test.go index 251799502c..a05d975a45 100644 --- a/sdk/go/openshell/v1/provider_client_test.go +++ b/sdk/go/openshell/v1/provider_client_test.go @@ -7,6 +7,7 @@ import ( "context" "net" "testing" + "time" dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" @@ -21,13 +22,14 @@ import ( type mockProviderServer struct { pb.UnimplementedOpenShellServer - providers map[string]*dm.Provider - lastList *pb.ListProvidersRequest - createErr error - getErr error - listErr error - updateErr error - deleteErr error + providers map[string]*dm.Provider + lastList *pb.ListProvidersRequest + lastUpdate *pb.UpdateProviderRequest + createErr error + getErr error + listErr error + updateErr error + deleteErr error } func newMockProviderServer() *mockProviderServer { @@ -71,6 +73,7 @@ func (s *mockProviderServer) ListProviders(_ context.Context, req *pb.ListProvid } func (s *mockProviderServer) UpdateProvider(_ context.Context, req *pb.UpdateProviderRequest) (*pb.ProviderResponse, error) { + s.lastUpdate = req if s.updateErr != nil { return nil, s.updateErr } @@ -249,6 +252,27 @@ func TestProviderUpdate(t *testing.T) { assert.Equal(t, "updatable", result.Name) } +func TestProviderUpdate_ClearsZeroCredentialExpiry(t *testing.T) { + mock := newMockProviderServer() + mock.providers["updatable"] = &dm.Provider{Metadata: &dm.ObjectMeta{Name: "updatable"}} + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Update(context.Background(), "default", &Provider{ + Name: "updatable", + Spec: ProviderSpec{CredentialExpiresAt: map[string]time.Time{ + "clear_me": {}, + "keep_me": time.Unix(1_700_000_000, 0), + }}, + }) + + require.NoError(t, err) + require.NotNil(t, mock.lastUpdate) + assert.Equal(t, []string{"clear_me"}, mock.lastUpdate.GetClearCredentialExpirationKeys()) + assert.Contains(t, mock.lastUpdate.GetCredentialExpirationTimes(), "keep_me") + assert.NotContains(t, mock.lastUpdate.GetCredentialExpirationTimes(), "clear_me") +} + func TestProviderUpdate_NotFound(t *testing.T) { mock := newMockProviderServer() client, cleanup := setupProviderTest(t, mock) diff --git a/sdk/go/openshell/v1/refresh_client_test.go b/sdk/go/openshell/v1/refresh_client_test.go index 7c69cb093a..013de70b85 100644 --- a/sdk/go/openshell/v1/refresh_client_test.go +++ b/sdk/go/openshell/v1/refresh_client_test.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/timestamppb" ) // --- Mock server for credential refresh --- @@ -75,12 +76,12 @@ func (s *mockRefreshServer) ConfigureProviderRefresh(_ context.Context, req *pb. } st := &pb.ProviderCredentialRefreshStatus{ - ProviderName: req.GetProvider(), - ProviderId: "prov-id-" + req.GetProvider(), - CredentialKey: req.GetCredentialKey(), - Strategy: req.GetStrategy(), - Status: "active", - ExpiresAtMs: req.GetExpiresAtMs(), + ProviderName: req.GetProvider(), + ProviderId: "prov-id-" + req.GetProvider(), + CredentialKey: req.GetCredentialKey(), + Strategy: req.GetStrategy(), + Status: "active", + ExpirationTime: req.GetExpirationTime(), } s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] = st return &pb.ConfigureProviderRefreshResponse{Status: st}, nil @@ -99,7 +100,7 @@ func (s *mockRefreshServer) RotateProviderCredential(_ context.Context, req *pb. return nil, status.Errorf(codes.NotFound, "refresh config %q not found", key) } st.Status = "rotated" - st.LastRefreshAtMs = time.Now().UnixMilli() + st.LastRefreshTime = timestamppb.Now() return &pb.RotateProviderCredentialResponse{Status: st}, nil } diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index 0760d4469b..2ea59dc4a2 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -382,7 +382,7 @@ func (s *sandboxClient) GetLogs(ctx context.Context, workspace, sandboxName stri WorkspaceScope: namedWorkspaceScope(workspace), } if !cfg.Since().IsZero() { - req.SinceMs = converter.MillisFromTime(cfg.Since()) + req.SinceTime = converter.TimestampFromTime(cfg.Since()) } resp, err := s.client.GetSandboxLogs(ctx, req) diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index 0494249775..18674ea76c 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -20,6 +20,7 @@ import ( "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" ) type mockSandboxServer struct { @@ -67,7 +68,7 @@ func (s *mockSandboxServer) CreateSandbox(_ context.Context, req *pb.CreateSandb Metadata: &dm.ObjectMeta{ Id: "sb-" + req.GetName(), Name: req.GetName(), - CreatedAtMs: 1700000000000, + CreatedTime: timestamppb.New(time.UnixMilli(1700000000000)), Labels: req.GetLabels(), ResourceVersion: 1, }, @@ -1199,8 +1200,8 @@ func TestSandboxGetLogs(t *testing.T) { } mock.getLogsResp = &pb.GetSandboxLogsResponse{ Logs: []*pb.SandboxLogLine{ - {TimestampMs: 1700000000000, Level: "INFO", Target: "gateway", Message: "connected", Source: "gateway"}, - {TimestampMs: 1700000001000, Level: "DEBUG", Target: "sandbox", Message: "init done", Source: "sandbox"}, + {EventTime: timestamppb.New(time.UnixMilli(1700000000000)), Level: "INFO", Target: "gateway", Message: "connected", Source: "gateway"}, + {EventTime: timestamppb.New(time.UnixMilli(1700000001000)), Level: "DEBUG", Target: "sandbox", Message: "init done", Source: "sandbox"}, }, BufferTotal: 42, } @@ -1232,7 +1233,7 @@ func TestSandboxGetLogs_WithOptions(t *testing.T) { Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_READY}, } mock.getLogsResp = &pb.GetSandboxLogsResponse{ - Logs: []*pb.SandboxLogLine{{TimestampMs: 1700000000000, Level: "WARN", Message: "high cpu"}}, + Logs: []*pb.SandboxLogLine{{EventTime: timestamppb.New(time.UnixMilli(1700000000000)), Level: "WARN", Message: "high cpu"}}, BufferTotal: 100, } client, cleanup := setupSandboxTest(t, mock) @@ -1257,7 +1258,7 @@ func TestSandboxGetLogs_WithOptions(t *testing.T) { mock.mu.Unlock() assert.Equal(t, "sb-id-opts", req.GetSandboxId()) assert.Equal(t, uint32(50), req.GetLines()) - assert.Equal(t, since.UnixMilli(), req.GetSinceMs()) + assert.Equal(t, since, req.GetSinceTime().AsTime()) assert.Equal(t, []string{"gateway", "sandbox"}, req.GetSources()) assert.Equal(t, "WARN", req.GetMinLevel()) } @@ -1319,11 +1320,11 @@ func TestSandboxGetLogs_SinceZeroNotSent(t *testing.T) { client, cleanup := setupSandboxTest(t, mock) defer cleanup() - // Call without WithLogSince — SinceMs should be 0 (not set) + // Call without WithLogSince — SinceTime should be unset. _, err := client.GetLogs(context.Background(), "default", "zero-sb") require.NoError(t, err) mock.mu.Lock() - assert.Equal(t, int64(0), mock.getLogsRequest.GetSinceMs()) + assert.Nil(t, mock.getLogsRequest.GetSinceTime()) mock.mu.Unlock() } diff --git a/sdk/go/openshell/v1/ssh_client_test.go b/sdk/go/openshell/v1/ssh_client_test.go index d06edb115b..72ead9c829 100644 --- a/sdk/go/openshell/v1/ssh_client_test.go +++ b/sdk/go/openshell/v1/ssh_client_test.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/timestamppb" ) // --- Mock server for SSH sessions --- @@ -63,7 +64,7 @@ func (s *mockSSHServer) CreateSshSession(_ context.Context, req *pb.CreateSshSes GatewayPort: 2222, GatewayScheme: "https", HostKeyFingerprint: "SHA256:abc123", - ExpiresAtMs: 1700000000000, + ExpirationTime: timestamppb.New(time.UnixMilli(1700000000000)), } s.sessions[req.GetSandboxId()] = resp s.tokens[token] = true diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go index e519df634d..b7834d2ab0 100644 --- a/sdk/go/openshell/v1/types/profile.go +++ b/sdk/go/openshell/v1/types/profile.go @@ -52,13 +52,15 @@ type ProfileCredential struct { // ProfileCredentialRefresh declares how a profile credential is refreshed. type ProfileCredentialRefresh struct { - Strategy RefreshStrategy - TokenURL string - Scopes []string - RefreshBeforeSeconds int64 - MaxLifetimeSeconds int64 - Material []ProfileCredentialRefreshMaterial - AdditionalOutputs []ProfileCredentialRefreshOutput + Strategy RefreshStrategy + TokenURL string + Scopes []string + // RefreshBefore retains the exact protobuf duration, including presence and nanoseconds. + RefreshBefore *ProfileDuration + // MaxLifetime retains the exact protobuf duration, including presence and nanoseconds. + MaxLifetime *ProfileDuration + Material []ProfileCredentialRefreshMaterial + AdditionalOutputs []ProfileCredentialRefreshOutput } // ProfileCredentialRefreshMaterial declares one input required by a refresh strategy. @@ -86,11 +88,12 @@ const ( // CredentialTokenGrant configures dynamic credential acquisition via OAuth2 grant. type CredentialTokenGrant struct { - TokenEndpoint string - Audience string - JWTSVIDAudience string - Scopes []string - CacheTTLSeconds int64 + TokenEndpoint string + Audience string + JWTSVIDAudience string + Scopes []string + // CacheTTL retains the exact protobuf duration, including presence and nanoseconds. + CacheTTL *ProfileDuration AudienceOverrides []TokenGrantAudienceOverride ClientAssertionType string GrantType CredentialTokenGrantType @@ -98,6 +101,14 @@ type CredentialTokenGrant struct { RequestedTokenType string } +// ProfileDuration represents a protobuf duration without importing generated +// protobuf packages into the curated SDK types. A nil pointer means absent; a +// non-nil zero value means an explicitly present zero duration. +type ProfileDuration struct { + Seconds int64 + Nanos int32 +} + // TokenGrantSubjectToken configures the subject token for token exchange grants. type TokenGrantSubjectToken struct { Source string diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index f67f0ef9e4..47d4a71b18 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -132,7 +132,7 @@ type EndpointStatus struct { Ports []uint32 Path string LastResult EndpointResult - // LastReportedAt is the RFC 3339 UTC time when the gateway accepted the + // LastReportedAt is the RFC 3339 UTC rendering of the time when the gateway accepted the // observation, not the request time. Retained evidence can be accepted after // a reset. NoObservedExchange has no report timestamp. LastReportedAt string diff --git a/sdk/go/openshell/v1/workspace_test.go b/sdk/go/openshell/v1/workspace_test.go index 37dde6eba4..9f79c79ae7 100644 --- a/sdk/go/openshell/v1/workspace_test.go +++ b/sdk/go/openshell/v1/workspace_test.go @@ -7,6 +7,7 @@ import ( "context" "net" "testing" + "time" dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" @@ -17,6 +18,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/timestamppb" ) type mockWorkspaceServer struct { @@ -118,7 +120,7 @@ func testWorkspace() *dm.Workspace { Metadata: &dm.ObjectMeta{ Id: "ws-1", Name: "test-ws", - CreatedAtMs: 1700000000000, + CreatedTime: timestamppb.New(time.UnixMilli(1700000000000)), Labels: map[string]string{"team": "platform"}, ResourceVersion: 1, }, @@ -307,7 +309,7 @@ func testMember() *pb.WorkspaceMember { Metadata: &dm.ObjectMeta{ Id: "mem-1", Name: "member-auto", - CreatedAtMs: 1700000000000, + CreatedTime: timestamppb.New(time.UnixMilli(1700000000000)), ResourceVersion: 1, }, PrincipalSubject: "user@example.com", diff --git a/sdk/go/proto/datamodelv1/datamodel.pb.go b/sdk/go/proto/datamodelv1/datamodel.pb.go index 1b9eec16b4..b1566376c8 100644 --- a/sdk/go/proto/datamodelv1/datamodel.pb.go +++ b/sdk/go/proto/datamodelv1/datamodel.pb.go @@ -13,6 +13,7 @@ import ( _ "github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -215,8 +216,8 @@ type ObjectMeta struct { Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // Human-readable object name (unique per object type). Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - // Milliseconds since Unix epoch when the object was created. - CreatedAtMs int64 `protobuf:"varint,3,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + // Time when the object was created. + CreatedTime *timestamppb.Timestamp `protobuf:"bytes,103,opt,name=created_time,json=createdTime,proto3" json:"created_time,omitempty"` // Key-value labels for filtering and organization. // Labels must follow Kubernetes conventions: alphanumeric + `-._/`, max 63 chars per segment. Labels map[string]string `protobuf:"bytes,4,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` @@ -229,12 +230,12 @@ type ObjectMeta struct { // Workspace that owns this resource. Empty is normalized to "default" by the // gateway. Immutable after creation. Workspace string `protobuf:"bytes,7,opt,name=workspace,proto3" json:"workspace,omitempty"` - // Milliseconds since Unix epoch when graceful deletion was initiated. - // Zero means the object is not being deleted. Once set, this field is + // Time when graceful deletion was initiated. Absence means the object is + // not being deleted. Once set, this field is // immutable — the only path forward is completing deletion. - DeletionTimestampMs int64 `protobuf:"varint,8,opt,name=deletion_timestamp_ms,json=deletionTimestampMs,proto3" json:"deletion_timestamp_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DeletionTime *timestamppb.Timestamp `protobuf:"bytes,108,opt,name=deletion_time,json=deletionTime,proto3" json:"deletion_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ObjectMeta) Reset() { @@ -281,11 +282,11 @@ func (x *ObjectMeta) GetName() string { return "" } -func (x *ObjectMeta) GetCreatedAtMs() int64 { +func (x *ObjectMeta) GetCreatedTime() *timestamppb.Timestamp { if x != nil { - return x.CreatedAtMs + return x.CreatedTime } - return 0 + return nil } func (x *ObjectMeta) GetLabels() map[string]string { @@ -316,11 +317,11 @@ func (x *ObjectMeta) GetWorkspace() string { return "" } -func (x *ObjectMeta) GetDeletionTimestampMs() int64 { +func (x *ObjectMeta) GetDeletionTime() *timestamppb.Timestamp { if x != nil { - return x.DeletionTimestampMs + return x.DeletionTime } - return 0 + return nil } // Status of a workspace. @@ -502,9 +503,9 @@ type Provider struct { Credentials map[string]string `protobuf:"bytes,3,rep,name=credentials,proto3" json:"credentials,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Non-secret provider configuration. Config map[string]string `protobuf:"bytes,4,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Expiration timestamps for credential values, keyed by credential/env var - // name. A zero or missing value means the credential does not expire. - CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,5,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"` + // Expiration times for credential values, keyed by credential/env var name. + // A missing key means the credential does not expire. + CredentialExpirationTimes map[string]*timestamppb.Timestamp `protobuf:"bytes,105,rep,name=credential_expiration_times,json=credentialExpirationTimes,proto3" json:"credential_expiration_times,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Workspace where this provider's type profile is stored. // Empty string = platform/global scope. Must be empty or match // metadata.workspace; cross-workspace references are rejected. @@ -574,9 +575,9 @@ func (x *Provider) GetConfig() map[string]string { return nil } -func (x *Provider) GetCredentialExpiresAtMs() map[string]int64 { +func (x *Provider) GetCredentialExpirationTimes() map[string]*timestamppb.Timestamp { if x != nil { - return x.CredentialExpiresAtMs + return x.CredentialExpirationTimes } return nil } @@ -599,28 +600,28 @@ var File_datamodel_proto protoreflect.FileDescriptor const file_datamodel_proto_rawDesc = "" + "\n" + - "\x0fdatamodel.proto\x12\x16openshell.datamodel.v1\x1a\roptions.proto\"\x90\x01\n" + + "\x0fdatamodel.proto\x12\x16openshell.datamodel.v1\x1a\roptions.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x90\x01\n" + "\x11WorkspaceSelector\x12\x1e\n" + "\tworkspace\x18\x01 \x01(\tH\x00R\tworkspace\x12N\n" + "\x0eall_workspaces\x18\x02 \x01(\v2%.openshell.datamodel.v1.AllWorkspacesH\x00R\rallWorkspacesB\v\n" + "\tselection\"\x0f\n" + - "\rAllWorkspaces\"\xeb\x03\n" + + "\rAllWorkspaces\"\xc5\x04\n" + "\n" + "ObjectMeta\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12\"\n" + - "\rcreated_at_ms\x18\x03 \x01(\x03R\vcreatedAtMs\x12F\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12=\n" + + "\fcreated_time\x18g \x01(\v2\x1a.google.protobuf.TimestampR\vcreatedTime\x12F\n" + "\x06labels\x18\x04 \x03(\v2..openshell.datamodel.v1.ObjectMeta.LabelsEntryR\x06labels\x12)\n" + "\x10resource_version\x18\x05 \x01(\x04R\x0fresourceVersion\x12U\n" + "\vannotations\x18\x06 \x03(\v23.openshell.datamodel.v1.ObjectMeta.AnnotationsEntryR\vannotations\x12\x1c\n" + - "\tworkspace\x18\a \x01(\tR\tworkspace\x122\n" + - "\x15deletion_timestamp_ms\x18\b \x01(\x03R\x13deletionTimestampMs\x1a9\n" + + "\tworkspace\x18\a \x01(\tR\tworkspace\x12?\n" + + "\rdeletion_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\fdeletionTime\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" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"O\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x03\x10\x04J\x04\b\b\x10\tR\rcreated_at_msR\x15deletion_timestamp_ms\"O\n" + "\x0fWorkspaceStatus\x12<\n" + "\x05phase\x18\x01 \x01(\x0e2&.openshell.datamodel.v1.WorkspacePhaseR\x05phase\"\x8c\x01\n" + "\tWorkspace\x12>\n" + @@ -632,13 +633,13 @@ const file_datamodel_proto_rawDesc = "" + "\bmetadata\x18\x03 \x03(\v26.openshell.datamodel.v1.CredentialHandle.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\"\xbf\x06\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8a\a\n" + "\bProvider\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x12\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12Y\n" + "\vcredentials\x18\x03 \x03(\v21.openshell.datamodel.v1.Provider.CredentialsEntryB\x04\x88\xb5\x18\x01R\vcredentials\x12D\n" + - "\x06config\x18\x04 \x03(\v2,.openshell.datamodel.v1.Provider.ConfigEntryR\x06config\x12t\n" + - "\x18credential_expires_at_ms\x18\x05 \x03(\v2;.openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12+\n" + + "\x06config\x18\x04 \x03(\v2,.openshell.datamodel.v1.Provider.ConfigEntryR\x06config\x12\x7f\n" + + "\x1bcredential_expiration_times\x18i \x03(\v2?.openshell.datamodel.v1.Provider.CredentialExpirationTimesEntryR\x19credentialExpirationTimes\x12+\n" + "\x11profile_workspace\x18\x06 \x01(\tR\x10profileWorkspace\x12f\n" + "\x12credential_handles\x18\a \x03(\v27.openshell.datamodel.v1.Provider.CredentialHandlesEntryR\x11credentialHandles\x1a>\n" + "\x10CredentialsEntry\x12\x10\n" + @@ -646,13 +647,13 @@ const file_datamodel_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a9\n" + "\vConfigEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + - "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\x1an\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1ah\n" + + "\x1eCredentialExpirationTimesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x120\n" + + "\x05value\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x05value:\x028\x01\x1an\n" + "\x16CredentialHandlesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12>\n" + - "\x05value\x18\x02 \x01(\v2(.openshell.datamodel.v1.CredentialHandleR\x05value:\x028\x01*n\n" + + "\x05value\x18\x02 \x01(\v2(.openshell.datamodel.v1.CredentialHandleR\x05value:\x028\x01J\x04\b\x05\x10\x06R\x18credential_expires_at_ms*n\n" + "\x0eWorkspacePhase\x12\x1f\n" + "\x1bWORKSPACE_PHASE_UNSPECIFIED\x10\x00\x12\x1a\n" + "\x16WORKSPACE_PHASE_ACTIVE\x10\x01\x12\x1f\n" + @@ -673,41 +674,45 @@ func file_datamodel_proto_rawDescGZIP() []byte { var file_datamodel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_datamodel_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_datamodel_proto_goTypes = []any{ - (WorkspacePhase)(0), // 0: openshell.datamodel.v1.WorkspacePhase - (*WorkspaceSelector)(nil), // 1: openshell.datamodel.v1.WorkspaceSelector - (*AllWorkspaces)(nil), // 2: openshell.datamodel.v1.AllWorkspaces - (*ObjectMeta)(nil), // 3: openshell.datamodel.v1.ObjectMeta - (*WorkspaceStatus)(nil), // 4: openshell.datamodel.v1.WorkspaceStatus - (*Workspace)(nil), // 5: openshell.datamodel.v1.Workspace - (*CredentialHandle)(nil), // 6: openshell.datamodel.v1.CredentialHandle - (*Provider)(nil), // 7: openshell.datamodel.v1.Provider - nil, // 8: openshell.datamodel.v1.ObjectMeta.LabelsEntry - nil, // 9: openshell.datamodel.v1.ObjectMeta.AnnotationsEntry - nil, // 10: openshell.datamodel.v1.CredentialHandle.MetadataEntry - nil, // 11: openshell.datamodel.v1.Provider.CredentialsEntry - nil, // 12: openshell.datamodel.v1.Provider.ConfigEntry - nil, // 13: openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry - nil, // 14: openshell.datamodel.v1.Provider.CredentialHandlesEntry + (WorkspacePhase)(0), // 0: openshell.datamodel.v1.WorkspacePhase + (*WorkspaceSelector)(nil), // 1: openshell.datamodel.v1.WorkspaceSelector + (*AllWorkspaces)(nil), // 2: openshell.datamodel.v1.AllWorkspaces + (*ObjectMeta)(nil), // 3: openshell.datamodel.v1.ObjectMeta + (*WorkspaceStatus)(nil), // 4: openshell.datamodel.v1.WorkspaceStatus + (*Workspace)(nil), // 5: openshell.datamodel.v1.Workspace + (*CredentialHandle)(nil), // 6: openshell.datamodel.v1.CredentialHandle + (*Provider)(nil), // 7: openshell.datamodel.v1.Provider + nil, // 8: openshell.datamodel.v1.ObjectMeta.LabelsEntry + nil, // 9: openshell.datamodel.v1.ObjectMeta.AnnotationsEntry + nil, // 10: openshell.datamodel.v1.CredentialHandle.MetadataEntry + nil, // 11: openshell.datamodel.v1.Provider.CredentialsEntry + nil, // 12: openshell.datamodel.v1.Provider.ConfigEntry + nil, // 13: openshell.datamodel.v1.Provider.CredentialExpirationTimesEntry + nil, // 14: openshell.datamodel.v1.Provider.CredentialHandlesEntry + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp } var file_datamodel_proto_depIdxs = []int32{ 2, // 0: openshell.datamodel.v1.WorkspaceSelector.all_workspaces:type_name -> openshell.datamodel.v1.AllWorkspaces - 8, // 1: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry - 9, // 2: openshell.datamodel.v1.ObjectMeta.annotations:type_name -> openshell.datamodel.v1.ObjectMeta.AnnotationsEntry - 0, // 3: openshell.datamodel.v1.WorkspaceStatus.phase:type_name -> openshell.datamodel.v1.WorkspacePhase - 3, // 4: openshell.datamodel.v1.Workspace.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 4, // 5: openshell.datamodel.v1.Workspace.status:type_name -> openshell.datamodel.v1.WorkspaceStatus - 10, // 6: openshell.datamodel.v1.CredentialHandle.metadata:type_name -> openshell.datamodel.v1.CredentialHandle.MetadataEntry - 3, // 7: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 11, // 8: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry - 12, // 9: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry - 13, // 10: openshell.datamodel.v1.Provider.credential_expires_at_ms:type_name -> openshell.datamodel.v1.Provider.CredentialExpiresAtMsEntry - 14, // 11: openshell.datamodel.v1.Provider.credential_handles:type_name -> openshell.datamodel.v1.Provider.CredentialHandlesEntry - 6, // 12: openshell.datamodel.v1.Provider.CredentialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 15, // 1: openshell.datamodel.v1.ObjectMeta.created_time:type_name -> google.protobuf.Timestamp + 8, // 2: openshell.datamodel.v1.ObjectMeta.labels:type_name -> openshell.datamodel.v1.ObjectMeta.LabelsEntry + 9, // 3: openshell.datamodel.v1.ObjectMeta.annotations:type_name -> openshell.datamodel.v1.ObjectMeta.AnnotationsEntry + 15, // 4: openshell.datamodel.v1.ObjectMeta.deletion_time:type_name -> google.protobuf.Timestamp + 0, // 5: openshell.datamodel.v1.WorkspaceStatus.phase:type_name -> openshell.datamodel.v1.WorkspacePhase + 3, // 6: openshell.datamodel.v1.Workspace.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 4, // 7: openshell.datamodel.v1.Workspace.status:type_name -> openshell.datamodel.v1.WorkspaceStatus + 10, // 8: openshell.datamodel.v1.CredentialHandle.metadata:type_name -> openshell.datamodel.v1.CredentialHandle.MetadataEntry + 3, // 9: openshell.datamodel.v1.Provider.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 11, // 10: openshell.datamodel.v1.Provider.credentials:type_name -> openshell.datamodel.v1.Provider.CredentialsEntry + 12, // 11: openshell.datamodel.v1.Provider.config:type_name -> openshell.datamodel.v1.Provider.ConfigEntry + 13, // 12: openshell.datamodel.v1.Provider.credential_expiration_times:type_name -> openshell.datamodel.v1.Provider.CredentialExpirationTimesEntry + 14, // 13: openshell.datamodel.v1.Provider.credential_handles:type_name -> openshell.datamodel.v1.Provider.CredentialHandlesEntry + 15, // 14: openshell.datamodel.v1.Provider.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 6, // 15: openshell.datamodel.v1.Provider.CredentialHandlesEntry.value:type_name -> openshell.datamodel.v1.CredentialHandle + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_datamodel_proto_init() } diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index e3200acbc8..63bccf5106 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -17,6 +17,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" durationpb "google.golang.org/protobuf/types/known/durationpb" structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -625,11 +626,10 @@ type IssueSandboxTokenResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Gateway-minted JWT bound to the calling sandbox's UUID. Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` - // Absolute expiry of the issued token, milliseconds since the epoch. 0 means - // the token is non-expiring. - ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Absolute expiry of the issued token. Absence means the token is non-expiring. + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,102,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *IssueSandboxTokenResponse) Reset() { @@ -669,11 +669,11 @@ func (x *IssueSandboxTokenResponse) GetToken() string { return "" } -func (x *IssueSandboxTokenResponse) GetExpiresAtMs() int64 { +func (x *IssueSandboxTokenResponse) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } // RefreshSandboxToken request. The calling principal must already be a @@ -733,16 +733,15 @@ type RefreshSandboxTokenResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Fresh gateway-minted JWT bound to the same sandbox UUID. Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` - // Absolute expiry of the new token, milliseconds since the epoch. 0 means - // the token is non-expiring. - ExpiresAtMs int64 `protobuf:"varint,2,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // Absolute expiry of the new token. Absence means the token is non-expiring. + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,102,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` // Fresh credentials for the requested, policy-authorized extension // services. These remain in supervisor memory and are never persisted. ExtensionCredentials []*ExtensionServiceCredential `protobuf:"bytes,3,rep,name=extension_credentials,json=extensionCredentials,proto3" json:"extension_credentials,omitempty"` // Fresh Sandbox Protocol bearer token from the same atomic refresh. SandboxToken string `protobuf:"bytes,4,opt,name=sandbox_token,json=sandboxToken,proto3" json:"sandbox_token,omitempty"` - // Absolute Sandbox Protocol token expiry, milliseconds since the epoch. - SandboxExpiresAtMs int64 `protobuf:"varint,5,opt,name=sandbox_expires_at_ms,json=sandboxExpiresAtMs,proto3" json:"sandbox_expires_at_ms,omitempty"` + // Absolute Sandbox Protocol token expiry. Required when sandbox_token is set. + SandboxExpirationTime *timestamppb.Timestamp `protobuf:"bytes,105,opt,name=sandbox_expiration_time,json=sandboxExpirationTime,proto3" json:"sandbox_expiration_time,omitempty"` // Launch generation to which both refreshed credentials are bound. SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Durable authorization epoch shared by the gateway and Sandbox Runtime. @@ -788,11 +787,11 @@ func (x *RefreshSandboxTokenResponse) GetToken() string { return "" } -func (x *RefreshSandboxTokenResponse) GetExpiresAtMs() int64 { +func (x *RefreshSandboxTokenResponse) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } func (x *RefreshSandboxTokenResponse) GetExtensionCredentials() []*ExtensionServiceCredential { @@ -809,11 +808,11 @@ func (x *RefreshSandboxTokenResponse) GetSandboxToken() string { return "" } -func (x *RefreshSandboxTokenResponse) GetSandboxExpiresAtMs() int64 { +func (x *RefreshSandboxTokenResponse) GetSandboxExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.SandboxExpiresAtMs + return x.SandboxExpirationTime } - return 0 + return nil } func (x *RefreshSandboxTokenResponse) GetSessionId() string { @@ -2426,10 +2425,10 @@ type SandboxCondition struct { Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` // Human-readable condition message. Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` - // RFC 3339 UTC timestamp supplied by the condition owner for the last transition. - LastTransitionTime string `protobuf:"bytes,5,opt,name=last_transition_time,json=lastTransitionTime,proto3" json:"last_transition_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Timestamp reported by the condition owner for the last transition. + TransitionTime *timestamppb.Timestamp `protobuf:"bytes,105,opt,name=transition_time,json=transitionTime,proto3" json:"transition_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SandboxCondition) Reset() { @@ -2490,18 +2489,18 @@ func (x *SandboxCondition) GetMessage() string { return "" } -func (x *SandboxCondition) GetLastTransitionTime() string { +func (x *SandboxCondition) GetTransitionTime() *timestamppb.Timestamp { if x != nil { - return x.LastTransitionTime + return x.TransitionTime } - return "" + return nil } // Public platform event exposed on the sandbox watch stream. type PlatformEvent struct { state protoimpl.MessageState `protogen:"open.v1"` - // Event timestamp in milliseconds since epoch. - TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + // Time when the event occurred. + EventTime *timestamppb.Timestamp `protobuf:"bytes,101,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"` // Event source (e.g. "kubernetes", "docker", "process"). Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"` // Event type/severity (e.g. "Normal", "Warning"). @@ -2546,11 +2545,11 @@ func (*PlatformEvent) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{30} } -func (x *PlatformEvent) GetTimestampMs() int64 { +func (x *PlatformEvent) GetEventTime() *timestamppb.Timestamp { if x != nil { - return x.TimestampMs + return x.EventTime } - return 0 + return nil } func (x *PlatformEvent) GetSource() string { @@ -3142,9 +3141,9 @@ type BeginRootfsTarStagingResponse struct { // Maximum accepted archive size in bytes, enforced again by the driver. MaxBytes uint64 `protobuf:"varint,3,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` // Wall-clock deadline after which the gateway reclaims the slot. - ExpiresAtMs int64 `protobuf:"varint,4,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,104,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *BeginRootfsTarStagingResponse) Reset() { @@ -3198,11 +3197,11 @@ func (x *BeginRootfsTarStagingResponse) GetMaxBytes() uint64 { return 0 } -func (x *BeginRootfsTarStagingResponse) GetExpiresAtMs() int64 { +func (x *BeginRootfsTarStagingResponse) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } // Get sandbox request. @@ -4075,10 +4074,10 @@ type CreateSshSessionResponse struct { GatewayScheme string `protobuf:"bytes,5,opt,name=gateway_scheme,json=gatewayScheme,proto3" json:"gateway_scheme,omitempty"` // Optional host key fingerprint. If non-empty, [A-Za-z0-9:+/=-] only. HostKeyFingerprint string `protobuf:"bytes,7,opt,name=host_key_fingerprint,json=hostKeyFingerprint,proto3" json:"host_key_fingerprint,omitempty"` - // Expiry timestamp in milliseconds since epoch. 0 means no expiry. - ExpiresAtMs int64 `protobuf:"varint,8,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Absolute expiry. Absence means no expiry. + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,108,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateSshSessionResponse) Reset() { @@ -4153,11 +4152,11 @@ func (x *CreateSshSessionResponse) GetHostKeyFingerprint() string { return "" } -func (x *CreateSshSessionResponse) GetExpiresAtMs() int64 { +func (x *CreateSshSessionResponse) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } // Request to expose an HTTP service running inside a sandbox. @@ -4792,8 +4791,8 @@ type ExecSandboxRequest struct { Workdir string `protobuf:"bytes,3,opt,name=workdir,proto3" json:"workdir,omitempty"` // Optional environment overrides. Environment map[string]string `protobuf:"bytes,4,rep,name=environment,proto3" json:"environment,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Optional timeout in seconds. 0 means no timeout. - TimeoutSeconds uint32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + // Optional execution timeout. Absence means no timeout. + ExecutionTimeout *durationpb.Duration `protobuf:"bytes,105,opt,name=execution_timeout,json=executionTimeout,proto3" json:"execution_timeout,omitempty"` // Optional stdin payload passed to the command. Stdin []byte `protobuf:"bytes,6,opt,name=stdin,proto3" json:"stdin,omitempty"` // Request a pseudo-terminal for the remote command. @@ -4870,11 +4869,11 @@ func (x *ExecSandboxRequest) GetEnvironment() map[string]string { return nil } -func (x *ExecSandboxRequest) GetTimeoutSeconds() uint32 { +func (x *ExecSandboxRequest) GetExecutionTimeout() *durationpb.Duration { if x != nil { - return x.TimeoutSeconds + return x.ExecutionTimeout } - return 0 + return nil } func (x *ExecSandboxRequest) GetStdin() []byte { @@ -5506,9 +5505,8 @@ type SshSession struct { SandboxId string `protobuf:"bytes,2,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` // Session token. Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` - // Expiry timestamp in milliseconds since epoch. 0 means no expiry - // (backward-compatible default for sessions created before this field existed). - ExpiresAtMs int64 `protobuf:"varint,4,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` + // Absolute expiry. Absence means no expiry. + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,104,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` // Revoked flag. Revoked bool `protobuf:"varint,5,opt,name=revoked,proto3" json:"revoked,omitempty"` unknownFields protoimpl.UnknownFields @@ -5566,11 +5564,11 @@ func (x *SshSession) GetToken() string { return "" } -func (x *SshSession) GetExpiresAtMs() int64 { +func (x *SshSession) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } func (x *SshSession) GetRevoked() bool { @@ -5598,9 +5596,9 @@ type WatchSandboxRequest struct { // Stop streaming once the sandbox reaches READY or a terminal result phase // (COMPLETED, STOPPED, or ERROR). StopOnTerminal bool `protobuf:"varint,7,opt,name=stop_on_terminal,json=stopOnTerminal,proto3" json:"stop_on_terminal,omitempty"` - // Only include log lines with timestamp >= this value (milliseconds since epoch). - // 0 means no time filter. Applies to both tail replay and live streaming. - LogSinceMs int64 `protobuf:"varint,8,opt,name=log_since_ms,json=logSinceMs,proto3" json:"log_since_ms,omitempty"` + // Only include log lines at or after this time. Absence means no time filter. + // Applies to both tail replay and live streaming. + SinceTime *timestamppb.Timestamp `protobuf:"bytes,108,opt,name=since_time,json=sinceTime,proto3" json:"since_time,omitempty"` // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. LogSources []string `protobuf:"bytes,9,rep,name=log_sources,json=logSources,proto3" json:"log_sources,omitempty"` // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. @@ -5688,11 +5686,11 @@ func (x *WatchSandboxRequest) GetStopOnTerminal() bool { return false } -func (x *WatchSandboxRequest) GetLogSinceMs() int64 { +func (x *WatchSandboxRequest) GetSinceTime() *timestamppb.Timestamp { if x != nil { - return x.LogSinceMs + return x.SinceTime } - return 0 + return nil } func (x *WatchSandboxRequest) GetLogSources() []string { @@ -5847,12 +5845,12 @@ func (*SandboxStreamEvent_DraftPolicyUpdate) isSandboxStreamEvent_Payload() {} // Log line correlated to a sandbox. type SandboxLogLine struct { - state protoimpl.MessageState `protogen:"open.v1"` - SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` - TimestampMs int64 `protobuf:"varint,2,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` - Level string `protobuf:"bytes,3,opt,name=level,proto3" json:"level,omitempty"` - Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` - Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + EventTime *timestamppb.Timestamp `protobuf:"bytes,102,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"` + Level string `protobuf:"bytes,3,opt,name=level,proto3" json:"level,omitempty"` + Target string `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` // Log source: "gateway" (server-side) or "sandbox" (supervisor). // Empty is treated as "gateway" for backward compatibility. Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` @@ -5899,11 +5897,11 @@ func (x *SandboxLogLine) GetSandboxId() string { return "" } -func (x *SandboxLogLine) GetTimestampMs() int64 { +func (x *SandboxLogLine) GetEventTime() *timestamppb.Timestamp { if x != nil { - return x.TimestampMs + return x.EventTime } - return 0 + return nil } func (x *SandboxLogLine) GetLevel() string { @@ -6164,8 +6162,11 @@ type UpdateProviderRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Provider *datamodelv1.Provider `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` // Optional per-credential expiry timestamps to merge into the provider. - // A zero value removes the expiry for that credential. - 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"` + // Omitted keys are unchanged. Use clear_credential_expiration_keys to remove + // an existing expiry. + CredentialExpirationTimes map[string]*timestamppb.Timestamp `protobuf:"bytes,102,rep,name=credential_expiration_times,json=credentialExpirationTimes,proto3" json:"credential_expiration_times,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Credential keys whose existing expiry should be removed. + ClearCredentialExpirationKeys []string `protobuf:"bytes,103,rep,name=clear_credential_expiration_keys,json=clearCredentialExpirationKeys,proto3" json:"clear_credential_expiration_keys,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 @@ -6209,9 +6210,16 @@ func (x *UpdateProviderRequest) GetProvider() *datamodelv1.Provider { return nil } -func (x *UpdateProviderRequest) GetCredentialExpiresAtMs() map[string]int64 { +func (x *UpdateProviderRequest) GetCredentialExpirationTimes() map[string]*timestamppb.Timestamp { + if x != nil { + return x.CredentialExpirationTimes + } + return nil +} + +func (x *UpdateProviderRequest) GetClearCredentialExpirationKeys() []string { if x != nil { - return x.CredentialExpiresAtMs + return x.ClearCredentialExpirationKeys } return nil } @@ -6787,9 +6795,8 @@ type ProviderCredentialTokenGrant struct { JwtSvidAudience string `protobuf:"bytes,6,opt,name=jwt_svid_audience,json=jwtSvidAudience,proto3" json:"jwt_svid_audience,omitempty"` // Optional: OAuth2 scopes to request Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` - // Optional: override token cache TTL (seconds) - // If 0 or omitted, use expires_in from token response - CacheTtlSeconds int64 `protobuf:"varint,4,opt,name=cache_ttl_seconds,json=cacheTtlSeconds,proto3" json:"cache_ttl_seconds,omitempty"` + // Optional token cache TTL override. If absent, use expires_in from the token response. + CacheTtl *durationpb.Duration `protobuf:"bytes,104,opt,name=cache_ttl,json=cacheTtl,proto3" json:"cache_ttl,omitempty"` // Optional: endpoint-specific resource audience overrides. AudienceOverrides []*ProviderCredentialTokenGrantAudienceOverride `protobuf:"bytes,5,rep,name=audience_overrides,json=audienceOverrides,proto3" json:"audience_overrides,omitempty"` // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses @@ -6865,11 +6872,11 @@ func (x *ProviderCredentialTokenGrant) GetScopes() []string { return nil } -func (x *ProviderCredentialTokenGrant) GetCacheTtlSeconds() int64 { +func (x *ProviderCredentialTokenGrant) GetCacheTtl() *durationpb.Duration { if x != nil { - return x.CacheTtlSeconds + return x.CacheTtl } - return 0 + return nil } func (x *ProviderCredentialTokenGrant) GetAudienceOverrides() []*ProviderCredentialTokenGrantAudienceOverride { @@ -7149,16 +7156,16 @@ func (x *ProviderCredentialRefreshOutput) GetCredential() string { } type ProviderCredentialRefresh struct { - state protoimpl.MessageState `protogen:"open.v1"` - Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,1,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` - TokenUrl string `protobuf:"bytes,2,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` - Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` - RefreshBeforeSeconds int64 `protobuf:"varint,4,opt,name=refresh_before_seconds,json=refreshBeforeSeconds,proto3" json:"refresh_before_seconds,omitempty"` - MaxLifetimeSeconds int64 `protobuf:"varint,5,opt,name=max_lifetime_seconds,json=maxLifetimeSeconds,proto3" json:"max_lifetime_seconds,omitempty"` - Material []*ProviderCredentialRefreshMaterial `protobuf:"bytes,6,rep,name=material,proto3" json:"material,omitempty"` - AdditionalOutputs []*ProviderCredentialRefreshOutput `protobuf:"bytes,7,rep,name=additional_outputs,json=additionalOutputs,proto3" json:"additional_outputs,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,1,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + TokenUrl string `protobuf:"bytes,2,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` + RefreshBefore *durationpb.Duration `protobuf:"bytes,104,opt,name=refresh_before,json=refreshBefore,proto3" json:"refresh_before,omitempty"` + MaxLifetime *durationpb.Duration `protobuf:"bytes,105,opt,name=max_lifetime,json=maxLifetime,proto3" json:"max_lifetime,omitempty"` + Material []*ProviderCredentialRefreshMaterial `protobuf:"bytes,6,rep,name=material,proto3" json:"material,omitempty"` + AdditionalOutputs []*ProviderCredentialRefreshOutput `protobuf:"bytes,7,rep,name=additional_outputs,json=additionalOutputs,proto3" json:"additional_outputs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProviderCredentialRefresh) Reset() { @@ -7212,18 +7219,18 @@ func (x *ProviderCredentialRefresh) GetScopes() []string { return nil } -func (x *ProviderCredentialRefresh) GetRefreshBeforeSeconds() int64 { +func (x *ProviderCredentialRefresh) GetRefreshBefore() *durationpb.Duration { if x != nil { - return x.RefreshBeforeSeconds + return x.RefreshBefore } - return 0 + return nil } -func (x *ProviderCredentialRefresh) GetMaxLifetimeSeconds() int64 { +func (x *ProviderCredentialRefresh) GetMaxLifetime() *durationpb.Duration { if x != nil { - return x.MaxLifetimeSeconds + return x.MaxLifetime } - return 0 + return nil } func (x *ProviderCredentialRefresh) GetMaterial() []*ProviderCredentialRefreshMaterial { @@ -7241,19 +7248,17 @@ func (x *ProviderCredentialRefresh) GetAdditionalOutputs() []*ProviderCredential } type ProviderCredentialRefreshStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` - ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - CredentialKey string `protobuf:"bytes,3,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` - Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,4,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` - Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` - ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - // Next automatic refresh time in Unix epoch milliseconds. A value of - // 9223372036854775807 (int64 max) means no automatic retry is scheduled; - // consumers should render it as unset and use recovery_action to determine - // the required recovery workflow. - NextRefreshAtMs int64 `protobuf:"varint,7,opt,name=next_refresh_at_ms,json=nextRefreshAtMs,proto3" json:"next_refresh_at_ms,omitempty"` - LastRefreshAtMs int64 `protobuf:"varint,8,opt,name=last_refresh_at_ms,json=lastRefreshAtMs,proto3" json:"last_refresh_at_ms,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + CredentialKey string `protobuf:"bytes,3,opt,name=credential_key,json=credentialKey,proto3" json:"credential_key,omitempty"` + Strategy ProviderCredentialRefreshStrategy `protobuf:"varint,4,opt,name=strategy,proto3,enum=openshell.v1.ProviderCredentialRefreshStrategy" json:"strategy,omitempty"` + Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,106,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + // Next automatic refresh time. Absence means no automatic retry is scheduled; + // use recovery_action to determine the required recovery workflow. + NextRefreshTime *timestamppb.Timestamp `protobuf:"bytes,107,opt,name=next_refresh_time,json=nextRefreshTime,proto3" json:"next_refresh_time,omitempty"` + LastRefreshTime *timestamppb.Timestamp `protobuf:"bytes,108,opt,name=last_refresh_time,json=lastRefreshTime,proto3" json:"last_refresh_time,omitempty"` LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` RecoveryAction ProviderCredentialRefreshRecoveryAction `protobuf:"varint,10,opt,name=recovery_action,json=recoveryAction,proto3,enum=openshell.v1.ProviderCredentialRefreshRecoveryAction" json:"recovery_action,omitempty"` // Stable gateway-owned failure identifier, for example @@ -7263,8 +7268,8 @@ type ProviderCredentialRefreshStatus struct { // A bounded, recognized provider subtype that refines failure_code; clients // do not need a separate provider_error field. Unknown provider-controlled // values are not persisted or returned. - ProviderErrorSubtype string `protobuf:"bytes,12,opt,name=provider_error_subtype,json=providerErrorSubtype,proto3" json:"provider_error_subtype,omitempty"` - LastErrorAtMs int64 `protobuf:"varint,13,opt,name=last_error_at_ms,json=lastErrorAtMs,proto3" json:"last_error_at_ms,omitempty"` + ProviderErrorSubtype string `protobuf:"bytes,12,opt,name=provider_error_subtype,json=providerErrorSubtype,proto3" json:"provider_error_subtype,omitempty"` + LastErrorTime *timestamppb.Timestamp `protobuf:"bytes,113,opt,name=last_error_time,json=lastErrorTime,proto3" json:"last_error_time,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -7334,25 +7339,25 @@ func (x *ProviderCredentialRefreshStatus) GetStatus() string { return "" } -func (x *ProviderCredentialRefreshStatus) GetExpiresAtMs() int64 { +func (x *ProviderCredentialRefreshStatus) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } -func (x *ProviderCredentialRefreshStatus) GetNextRefreshAtMs() int64 { +func (x *ProviderCredentialRefreshStatus) GetNextRefreshTime() *timestamppb.Timestamp { if x != nil { - return x.NextRefreshAtMs + return x.NextRefreshTime } - return 0 + return nil } -func (x *ProviderCredentialRefreshStatus) GetLastRefreshAtMs() int64 { +func (x *ProviderCredentialRefreshStatus) GetLastRefreshTime() *timestamppb.Timestamp { if x != nil { - return x.LastRefreshAtMs + return x.LastRefreshTime } - return 0 + return nil } func (x *ProviderCredentialRefreshStatus) GetLastError() string { @@ -7383,11 +7388,11 @@ func (x *ProviderCredentialRefreshStatus) GetProviderErrorSubtype() string { return "" } -func (x *ProviderCredentialRefreshStatus) GetLastErrorAtMs() int64 { +func (x *ProviderCredentialRefreshStatus) GetLastErrorTime() *timestamppb.Timestamp { if x != nil { - return x.LastErrorAtMs + return x.LastErrorTime } - return 0 + return nil } // Provider profile local discovery declaration. @@ -7550,8 +7555,8 @@ type ConfigureProviderRefreshRequest struct { // Additional material names the caller requests be stored as secrets. Every // name must be present in material. The server also classifies secrets from // the authoritative provider profile and refresh strategy. - SecretMaterialKeys []string `protobuf:"bytes,5,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` - ExpiresAtMs *int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3,oneof" json:"expires_at_ms,omitempty"` + SecretMaterialKeys []string `protobuf:"bytes,5,rep,name=secret_material_keys,json=secretMaterialKeys,proto3" json:"secret_material_keys,omitempty"` + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,106,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,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 @@ -7623,11 +7628,11 @@ func (x *ConfigureProviderRefreshRequest) GetSecretMaterialKeys() []string { return nil } -func (x *ConfigureProviderRefreshRequest) GetExpiresAtMs() int64 { - if x != nil && x.ExpiresAtMs != nil { - return *x.ExpiresAtMs +func (x *ConfigureProviderRefreshRequest) GetExpirationTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpirationTime } - return 0 + return nil } func (x *ConfigureProviderRefreshRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelector { @@ -8840,7 +8845,7 @@ type GetSandboxProviderEnvironmentResponse struct { // Fingerprint for the provider credential inputs that produced environment. ProviderEnvRevision uint64 `protobuf:"varint,2,opt,name=provider_env_revision,json=providerEnvRevision,proto3" json:"provider_env_revision,omitempty"` // Expiration timestamps for returned environment variables. - CredentialExpiresAtMs map[string]int64 `protobuf:"bytes,3,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"` + CredentialExpirationTimes map[string]*timestamppb.Timestamp `protobuf:"bytes,103,rep,name=credential_expiration_times,json=credentialExpirationTimes,proto3" json:"credential_expiration_times,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Dynamic credentials that require token grants or other runtime injection. // Maps endpoint-bound provider metadata to credential metadata. // Supervisor uses this to inject Authorization headers for token grant credentials. @@ -8901,9 +8906,9 @@ func (x *GetSandboxProviderEnvironmentResponse) GetProviderEnvRevision() uint64 return 0 } -func (x *GetSandboxProviderEnvironmentResponse) GetCredentialExpiresAtMs() map[string]int64 { +func (x *GetSandboxProviderEnvironmentResponse) GetCredentialExpirationTimes() map[string]*timestamppb.Timestamp { if x != nil { - return x.CredentialExpiresAtMs + return x.CredentialExpirationTimes } return nil } @@ -9005,7 +9010,7 @@ func (x *ExchangeProviderSubjectTokenRequest) GetSupervisorJwtSvid() string { type ExchangeProviderSubjectTokenResponse struct { state protoimpl.MessageState `protogen:"open.v1"` AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` - ExpiresIn int64 `protobuf:"varint,2,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` + ExpiresAfter *durationpb.Duration `protobuf:"bytes,102,opt,name=expires_after,json=expiresAfter,proto3" json:"expires_after,omitempty"` TokenType string `protobuf:"bytes,3,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -9048,11 +9053,11 @@ func (x *ExchangeProviderSubjectTokenResponse) GetAccessToken() string { return "" } -func (x *ExchangeProviderSubjectTokenResponse) GetExpiresIn() int64 { +func (x *ExchangeProviderSubjectTokenResponse) GetExpiresAfter() *durationpb.Duration { if x != nil { - return x.ExpiresIn + return x.ExpiresAfter } - return 0 + return nil } func (x *ExchangeProviderSubjectTokenResponse) GetTokenType() string { @@ -10160,10 +10165,10 @@ type SandboxPolicyRevision struct { // Sandbox load error, or the schema-validation diagnostic for an invalid // historical row returned by ListSandboxPolicies. LoadError string `protobuf:"bytes,4,opt,name=load_error,json=loadError,proto3" json:"load_error,omitempty"` - // Milliseconds since epoch when this revision was created. - CreatedAtMs int64 `protobuf:"varint,5,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` - // Milliseconds since epoch when this revision was loaded by the sandbox. - LoadedAtMs int64 `protobuf:"varint,6,opt,name=loaded_at_ms,json=loadedAtMs,proto3" json:"loaded_at_ms,omitempty"` + // Time when this revision was created. + CreatedTime *timestamppb.Timestamp `protobuf:"bytes,105,opt,name=created_time,json=createdTime,proto3" json:"created_time,omitempty"` + // Time when this revision was loaded by the sandbox. Absent if not loaded. + LoadedTime *timestamppb.Timestamp `protobuf:"bytes,106,opt,name=loaded_time,json=loadedTime,proto3" json:"loaded_time,omitempty"` // The full policy (only populated when explicitly requested). Policy *sandboxv1.SandboxPolicy `protobuf:"bytes,7,opt,name=policy,proto3" json:"policy,omitempty"` // Immutable provenance supplied with this policy revision. @@ -10230,18 +10235,18 @@ func (x *SandboxPolicyRevision) GetLoadError() string { return "" } -func (x *SandboxPolicyRevision) GetCreatedAtMs() int64 { +func (x *SandboxPolicyRevision) GetCreatedTime() *timestamppb.Timestamp { if x != nil { - return x.CreatedAtMs + return x.CreatedTime } - return 0 + return nil } -func (x *SandboxPolicyRevision) GetLoadedAtMs() int64 { +func (x *SandboxPolicyRevision) GetLoadedTime() *timestamppb.Timestamp { if x != nil { - return x.LoadedAtMs + return x.LoadedTime } - return 0 + return nil } func (x *SandboxPolicyRevision) GetPolicy() *sandboxv1.SandboxPolicy { @@ -10265,8 +10270,8 @@ type GetSandboxLogsRequest struct { SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` // Maximum number of log lines to return. 0 means use default (2000). Lines uint32 `protobuf:"varint,2,opt,name=lines,proto3" json:"lines,omitempty"` - // Only include logs with timestamp >= this value (ms since epoch). 0 means no filter. - SinceMs int64 `protobuf:"varint,3,opt,name=since_ms,json=sinceMs,proto3" json:"since_ms,omitempty"` + // Only include logs at or after this time. Absence means no filter. + SinceTime *timestamppb.Timestamp `protobuf:"bytes,103,opt,name=since_time,json=sinceTime,proto3" json:"since_time,omitempty"` // Filter by log source (e.g. "gateway", "sandbox"). Empty means all sources. Sources []string `protobuf:"bytes,4,rep,name=sources,proto3" json:"sources,omitempty"` // Minimum log level to include (e.g. "INFO", "WARN", "ERROR"). Empty means all levels. @@ -10321,11 +10326,11 @@ func (x *GetSandboxLogsRequest) GetLines() uint32 { return 0 } -func (x *GetSandboxLogsRequest) GetSinceMs() int64 { +func (x *GetSandboxLogsRequest) GetSinceTime() *timestamppb.Timestamp { if x != nil { - return x.SinceMs + return x.SinceTime } - return 0 + return nil } func (x *GetSandboxLogsRequest) GetSources() []string { @@ -10802,10 +10807,10 @@ type SessionAccepted struct { state protoimpl.MessageState `protogen:"open.v1"` // Gateway-assigned session ID for this connection. SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - // Recommended heartbeat interval in seconds. - HeartbeatIntervalSecs uint32 `protobuf:"varint,2,opt,name=heartbeat_interval_secs,json=heartbeatIntervalSecs,proto3" json:"heartbeat_interval_secs,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Recommended heartbeat interval. + HeartbeatInterval *durationpb.Duration `protobuf:"bytes,102,opt,name=heartbeat_interval,json=heartbeatInterval,proto3" json:"heartbeat_interval,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SessionAccepted) Reset() { @@ -10845,11 +10850,11 @@ func (x *SessionAccepted) GetSessionId() string { return "" } -func (x *SessionAccepted) GetHeartbeatIntervalSecs() uint32 { +func (x *SessionAccepted) GetHeartbeatInterval() *durationpb.Duration { if x != nil { - return x.HeartbeatIntervalSecs + return x.HeartbeatInterval } - return 0 + return nil } // Gateway rejects the supervisor session. @@ -11701,10 +11706,10 @@ type DenialSummary struct { Ancestors []string `protobuf:"bytes,5,rep,name=ancestors,proto3" json:"ancestors,omitempty"` // Denial reason from OPA evaluation. DenyReason string `protobuf:"bytes,6,opt,name=deny_reason,json=denyReason,proto3" json:"deny_reason,omitempty"` - // First denial timestamp (ms since epoch). - FirstSeenMs int64 `protobuf:"varint,7,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` - // Most recent denial timestamp (ms since epoch). - LastSeenMs int64 `protobuf:"varint,8,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // Time of the first denial. + FirstSeenTime *timestamppb.Timestamp `protobuf:"bytes,107,opt,name=first_seen_time,json=firstSeenTime,proto3" json:"first_seen_time,omitempty"` + // Time of the most recent denial. + LastSeenTime *timestamppb.Timestamp `protobuf:"bytes,108,opt,name=last_seen_time,json=lastSeenTime,proto3" json:"last_seen_time,omitempty"` // Number of denials in the current window. Count uint32 `protobuf:"varint,9,opt,name=count,proto3" json:"count,omitempty"` // Events dropped during aggregator cooldown. @@ -11799,18 +11804,18 @@ func (x *DenialSummary) GetDenyReason() string { return "" } -func (x *DenialSummary) GetFirstSeenMs() int64 { +func (x *DenialSummary) GetFirstSeenTime() *timestamppb.Timestamp { if x != nil { - return x.FirstSeenMs + return x.FirstSeenTime } - return 0 + return nil } -func (x *DenialSummary) GetLastSeenMs() int64 { +func (x *DenialSummary) GetLastSeenTime() *timestamppb.Timestamp { if x != nil { - return x.LastSeenMs + return x.LastSeenTime } - return 0 + return nil } func (x *DenialSummary) GetCount() uint32 { @@ -12015,20 +12020,20 @@ type PolicyChunk struct { Confidence float32 `protobuf:"fixed32,7,opt,name=confidence,proto3" json:"confidence,omitempty"` // IDs of denial summaries that led to this chunk. DenialSummaryIds []string `protobuf:"bytes,8,rep,name=denial_summary_ids,json=denialSummaryIds,proto3" json:"denial_summary_ids,omitempty"` - // Creation timestamp (ms since epoch). - CreatedAtMs int64 `protobuf:"varint,9,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` - // When the user approved/rejected (ms since epoch). 0 if undecided. - DecidedAtMs int64 `protobuf:"varint,10,opt,name=decided_at_ms,json=decidedAtMs,proto3" json:"decided_at_ms,omitempty"` + // Time when this chunk was created. + CreatedTime *timestamppb.Timestamp `protobuf:"bytes,109,opt,name=created_time,json=createdTime,proto3" json:"created_time,omitempty"` + // Time when the user approved or rejected the chunk. Absent if undecided. + DecidedTime *timestamppb.Timestamp `protobuf:"bytes,110,opt,name=decided_time,json=decidedTime,proto3" json:"decided_time,omitempty"` // Recommendation stage: "initial" or "refined" (progressive L7 visibility). Stage string `protobuf:"bytes,11,opt,name=stage,proto3" json:"stage,omitempty"` // For stage="refined": the initial chunk this replaces. SupersedesChunkId string `protobuf:"bytes,12,opt,name=supersedes_chunk_id,json=supersedesChunkId,proto3" json:"supersedes_chunk_id,omitempty"` // How many times this endpoint has been seen across denial flush cycles. HitCount int32 `protobuf:"varint,13,opt,name=hit_count,json=hitCount,proto3" json:"hit_count,omitempty"` - // First time this endpoint was proposed (ms since epoch). - FirstSeenMs int64 `protobuf:"varint,14,opt,name=first_seen_ms,json=firstSeenMs,proto3" json:"first_seen_ms,omitempty"` - // Most recent time this endpoint was re-proposed (ms since epoch). - LastSeenMs int64 `protobuf:"varint,15,opt,name=last_seen_ms,json=lastSeenMs,proto3" json:"last_seen_ms,omitempty"` + // First time this endpoint was proposed. + FirstSeenTime *timestamppb.Timestamp `protobuf:"bytes,114,opt,name=first_seen_time,json=firstSeenTime,proto3" json:"first_seen_time,omitempty"` + // Most recent time this endpoint was proposed again. + LastSeenTime *timestamppb.Timestamp `protobuf:"bytes,115,opt,name=last_seen_time,json=lastSeenTime,proto3" json:"last_seen_time,omitempty"` // Binary path that triggered the denial (denormalized for display convenience). Binary string `protobuf:"bytes,16,opt,name=binary,proto3" json:"binary,omitempty"` // Validation verdict from gateway-side static checks (prover output). @@ -12144,18 +12149,18 @@ func (x *PolicyChunk) GetDenialSummaryIds() []string { return nil } -func (x *PolicyChunk) GetCreatedAtMs() int64 { +func (x *PolicyChunk) GetCreatedTime() *timestamppb.Timestamp { if x != nil { - return x.CreatedAtMs + return x.CreatedTime } - return 0 + return nil } -func (x *PolicyChunk) GetDecidedAtMs() int64 { +func (x *PolicyChunk) GetDecidedTime() *timestamppb.Timestamp { if x != nil { - return x.DecidedAtMs + return x.DecidedTime } - return 0 + return nil } func (x *PolicyChunk) GetStage() string { @@ -12179,18 +12184,18 @@ func (x *PolicyChunk) GetHitCount() int32 { return 0 } -func (x *PolicyChunk) GetFirstSeenMs() int64 { +func (x *PolicyChunk) GetFirstSeenTime() *timestamppb.Timestamp { if x != nil { - return x.FirstSeenMs + return x.FirstSeenTime } - return 0 + return nil } -func (x *PolicyChunk) GetLastSeenMs() int64 { +func (x *PolicyChunk) GetLastSeenTime() *timestamppb.Timestamp { if x != nil { - return x.LastSeenMs + return x.LastSeenTime } - return 0 + return nil } func (x *PolicyChunk) GetBinary() string { @@ -12572,8 +12577,8 @@ type GetDraftPolicyResponse struct { RollingSummary string `protobuf:"bytes,2,opt,name=rolling_summary,json=rollingSummary,proto3" json:"rolling_summary,omitempty"` // Current draft version. DraftVersion uint64 `protobuf:"varint,3,opt,name=draft_version,json=draftVersion,proto3" json:"draft_version,omitempty"` - // When the last analysis completed (ms since epoch). - LastAnalyzedAtMs int64 `protobuf:"varint,4,opt,name=last_analyzed_at_ms,json=lastAnalyzedAtMs,proto3" json:"last_analyzed_at_ms,omitempty"` + // Time when the last analysis completed. + LastAnalyzedTime *timestamppb.Timestamp `protobuf:"bytes,104,opt,name=last_analyzed_time,json=lastAnalyzedTime,proto3" json:"last_analyzed_time,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -12629,11 +12634,11 @@ func (x *GetDraftPolicyResponse) GetDraftVersion() uint64 { return 0 } -func (x *GetDraftPolicyResponse) GetLastAnalyzedAtMs() int64 { +func (x *GetDraftPolicyResponse) GetLastAnalyzedTime() *timestamppb.Timestamp { if x != nil { - return x.LastAnalyzedAtMs + return x.LastAnalyzedTime } - return 0 + return nil } // Approve a single draft chunk. @@ -13456,8 +13461,8 @@ func (x *GetDraftHistoryRequest) GetWorkspaceScope() *datamodelv1.WorkspaceSelec type DraftHistoryEntry struct { state protoimpl.MessageState `protogen:"open.v1"` - // Event timestamp (ms since epoch). - TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` + // Time when the event occurred. + EventTime *timestamppb.Timestamp `protobuf:"bytes,101,opt,name=event_time,json=eventTime,proto3" json:"event_time,omitempty"` // Event type: "denial_detected", "analysis_cycle", "approved", // "rejected", "edited", "undone", "cleared". EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` @@ -13499,11 +13504,11 @@ func (*DraftHistoryEntry) Descriptor() ([]byte, []int) { return file_openshell_proto_rawDescGZIP(), []int{189} } -func (x *DraftHistoryEntry) GetTimestampMs() int64 { +func (x *DraftHistoryEntry) GetEventTime() *timestamppb.Timestamp { if x != nil { - return x.TimestampMs + return x.EventTime } - return 0 + return nil } func (x *DraftHistoryEntry) GetEventType() string { @@ -14376,10 +14381,10 @@ type ExtensionServiceCredential struct { ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` // Gateway-minted JWT with an audience derived from the registration. Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"` - // Absolute expiry of the token, milliseconds since the epoch. - ExpiresAtMs int64 `protobuf:"varint,3,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Absolute expiry of the token. + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,103,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ExtensionServiceCredential) Reset() { @@ -14426,11 +14431,11 @@ func (x *ExtensionServiceCredential) GetToken() string { return "" } -func (x *ExtensionServiceCredential) GetExpiresAtMs() int64 { +func (x *ExtensionServiceCredential) GetExpirationTime() *timestamppb.Timestamp { if x != nil { - return x.ExpiresAtMs + return x.ExpirationTime } - return 0 + return nil } // One redacted endpoint result in a supervisor's complete status report. @@ -14644,12 +14649,12 @@ type EndpointStatus struct { // Last accepted result, aggregated across configured callers and ports. // NoObservedExchange retains the address and has no report timestamp. LastResult EndpointResult `protobuf:"varint,5,opt,name=last_result,json=lastResult,proto3,enum=openshell.v1.EndpointResult" json:"last_result,omitempty"` - // RFC 3339 UTC time when the gateway accepted the observation. This is not - // the request time: still-valid evidence can be reaccepted after a reset. - // Identical same-sequence retries do not advance it. - LastReportedAt string `protobuf:"bytes,6,opt,name=last_reported_at,json=lastReportedAt,proto3" json:"last_reported_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Time when the gateway accepted the observation. This is not the request + // time: still-valid evidence can be reaccepted after a reset. Identical + // same-sequence retries do not advance it. Absent until a result is reported. + LastReportedTime *timestamppb.Timestamp `protobuf:"bytes,106,opt,name=last_reported_time,json=lastReportedTime,proto3" json:"last_reported_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EndpointStatus) Reset() { @@ -14717,33 +14722,33 @@ func (x *EndpointStatus) GetLastResult() EndpointResult { return EndpointResult_ENDPOINT_RESULT_UNSPECIFIED } -func (x *EndpointStatus) GetLastReportedAt() string { +func (x *EndpointStatus) GetLastReportedTime() *timestamppb.Timestamp { if x != nil { - return x.LastReportedAt + return x.LastReportedTime } - return "" + return nil } var File_openshell_proto protoreflect.FileDescriptor const file_openshell_proto_rawDesc = "" + "\n" + - "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\roptions.proto\x1a\rsandbox.proto\"\x1a\n" + - "\x18IssueSandboxTokenRequest\"[\n" + + "\x0fopenshell.proto\x12\fopenshell.v1\x1a\x0fdatamodel.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\roptions.proto\x1a\rsandbox.proto\"\x1a\n" + + "\x18IssueSandboxTokenRequest\"\x91\x01\n" + "\x19IssueSandboxTokenResponse\x12\x1a\n" + - "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\"T\n" + + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12C\n" + + "\x0fexpiration_time\x18f \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTimeJ\x04\b\x02\x10\x03R\rexpires_at_ms\"T\n" + "\x1aRefreshSandboxTokenRequest\x126\n" + - "\x17extension_service_names\x18\x01 \x03(\tR\x15extensionServiceNames\"\xe4\x02\n" + + "\x17extension_service_names\x18\x01 \x03(\tR\x15extensionServiceNames\"\xd8\x03\n" + "\x1bRefreshSandboxTokenResponse\x12\x1a\n" + - "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x02 \x01(\x03R\vexpiresAtMs\x12]\n" + + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12C\n" + + "\x0fexpiration_time\x18f \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\x12]\n" + "\x15extension_credentials\x18\x03 \x03(\v2(.openshell.v1.ExtensionServiceCredentialR\x14extensionCredentials\x12)\n" + - "\rsandbox_token\x18\x04 \x01(\tB\x04\x88\xb5\x18\x01R\fsandboxToken\x121\n" + - "\x15sandbox_expires_at_ms\x18\x05 \x01(\x03R\x12sandboxExpiresAtMs\x12\x1d\n" + + "\rsandbox_token\x18\x04 \x01(\tB\x04\x88\xb5\x18\x01R\fsandboxToken\x12R\n" + + "\x17sandbox_expiration_time\x18i \x01(\v2\x1a.google.protobuf.TimestampR\x15sandboxExpirationTime\x12\x1d\n" + "\n" + "session_id\x18\x06 \x01(\tR\tsessionId\x12)\n" + - "\x10credential_epoch\x18\a \x01(\x04R\x0fcredentialEpoch\"\x0f\n" + + "\x10credential_epoch\x18\a \x01(\x04R\x0fcredentialEpochJ\x04\b\x02\x10\x03J\x04\b\x05\x10\x06R\rexpires_at_msR\x15sandbox_expires_at_ms\"\x0f\n" + "\rHealthRequest\"_\n" + "\x0eHealthResponse\x123\n" + "\x06status\x18\x01 \x01(\x0e2\x1b.openshell.v1.ServiceStatusR\x06status\x12\x18\n" + @@ -14867,15 +14872,16 @@ const file_openshell_proto_rawDesc = "" + "\x11endpoint_statuses\x18\n" + " \x03(\v2\x1c.openshell.v1.EndpointStatusR\x10endpointStatusesB\f\n" + "\n" + - "_exit_code\"\xa2\x01\n" + + "_exit_code\"\xd1\x01\n" + "\x10SandboxCondition\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x16\n" + "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x18\n" + - "\amessage\x18\x04 \x01(\tR\amessage\x120\n" + - "\x14last_transition_time\x18\x05 \x01(\tR\x12lastTransitionTime\"\x94\x02\n" + - "\rPlatformEvent\x12!\n" + - "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x16\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12C\n" + + "\x0ftransition_time\x18i \x01(\v2\x1a.google.protobuf.TimestampR\x0etransitionTimeJ\x04\b\x05\x10\x06R\x14last_transition_time\"\xc0\x02\n" + + "\rPlatformEvent\x129\n" + + "\n" + + "event_time\x18e \x01(\v2\x1a.google.protobuf.TimestampR\teventTime\x12\x16\n" + "\x06source\x18\x02 \x01(\tR\x06source\x12\x12\n" + "\x04type\x18\x03 \x01(\tR\x04type\x12\x16\n" + "\x06reason\x18\x04 \x01(\tR\x06reason\x12\x18\n" + @@ -14883,7 +14889,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\x01J\x04\b\x01\x10\x02R\ftimestamp_ms\"\xd1\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" + @@ -14924,13 +14930,13 @@ const file_openshell_proto_rawDesc = "" + "\tfile_name\x18\x02 \x01(\tR\bfileName\x12\x1d\n" + "\n" + "size_bytes\x18\x03 \x01(\x04R\tsizeBytes\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02R\tworkspace\"\xa6\x01\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x01\x10\x02R\tworkspace\"\xdc\x01\n" + "\x1dBeginRootfsTarStagingResponse\x12#\n" + "\rstaging_token\x18\x01 \x01(\tR\fstagingToken\x12\x1f\n" + "\vupload_path\x18\x02 \x01(\tR\n" + "uploadPath\x12\x1b\n" + - "\tmax_bytes\x18\x03 \x01(\x04R\bmaxBytes\x12\"\n" + - "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\"\x8c\x01\n" + + "\tmax_bytes\x18\x03 \x01(\x04R\bmaxBytes\x12C\n" + + "\x0fexpiration_time\x18h \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTimeJ\x04\b\x04\x10\x05R\rexpires_at_ms\"\x8c\x01\n" + "\x11GetSandboxRequest\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\"\xf4\x01\n" + @@ -14979,7 +14985,7 @@ const file_openshell_proto_rawDesc = "" + "\adeleted\x18\x01 \x01(\bR\adeleted\"8\n" + "\x17CreateSshSessionRequest\x12\x1d\n" + "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x98\x02\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\xce\x02\n" + "\x18CreateSshSessionResponse\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1a\n" + @@ -14987,8 +14993,8 @@ const file_openshell_proto_rawDesc = "" + "\fgateway_host\x18\x03 \x01(\tR\vgatewayHost\x12!\n" + "\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" + + "\x14host_key_fingerprint\x18\a \x01(\tR\x12hostKeyFingerprint\x12C\n" + + "\x0fexpiration_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTimeJ\x04\b\b\x10\tR\rexpires_at_ms\"\xe8\x01\n" + "\x14ExposeServiceRequest\x12\x18\n" + "\asandbox\x18\x01 \x01(\tR\asandbox\x12\x18\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12\x1f\n" + @@ -15030,14 +15036,14 @@ const file_openshell_proto_rawDesc = "" + "\x17RevokeSshSessionRequest\x12\x1a\n" + "\x05token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\"4\n" + "\x18RevokeSshSessionResponse\x12\x18\n" + - "\arevoked\x18\x01 \x01(\bR\arevoked\"\x9b\x03\n" + + "\arevoked\x18\x01 \x01(\bR\arevoked\"\xd1\x03\n" + "\x12ExecSandboxRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x18\n" + "\acommand\x18\x02 \x03(\tR\acommand\x12\x18\n" + "\aworkdir\x18\x03 \x01(\tR\aworkdir\x12S\n" + - "\venvironment\x18\x04 \x03(\v21.openshell.v1.ExecSandboxRequest.EnvironmentEntryR\venvironment\x12'\n" + - "\x0ftimeout_seconds\x18\x05 \x01(\rR\x0etimeoutSeconds\x12\x14\n" + + "\venvironment\x18\x04 \x03(\v21.openshell.v1.ExecSandboxRequest.EnvironmentEntryR\venvironment\x12F\n" + + "\x11execution_timeout\x18i \x01(\v2\x19.google.protobuf.DurationR\x10executionTimeout\x12\x14\n" + "\x05stdin\x18\x06 \x01(\fR\x05stdin\x12\x10\n" + "\x03tty\x18\a \x01(\bR\x03tty\x12\x12\n" + "\x04cols\x18\b \x01(\rR\x04cols\x12\x12\n" + @@ -15046,7 +15052,7 @@ const file_openshell_proto_rawDesc = "" + " \x01(\bR\fnoLoginShell\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"'\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x05\x10\x06R\x0ftimeout_seconds\"'\n" + "\x11ExecSandboxStdout\x12\x12\n" + "\x04data\x18\x01 \x01(\fR\x04data\"'\n" + "\x11ExecSandboxStderr\x12\x12\n" + @@ -15078,15 +15084,15 @@ const file_openshell_proto_rawDesc = "" + "\apayload\"A\n" + "\x17ExecSandboxWindowResize\x12\x12\n" + "\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" + - "\x04rows\x18\x02 \x01(\rR\x04rows\"\xc5\x01\n" + + "\x04rows\x18\x02 \x01(\rR\x04rows\"\xfb\x01\n" + "\n" + "SshSession\x12>\n" + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12\x1d\n" + "\n" + "sandbox_id\x18\x02 \x01(\tR\tsandboxId\x12\x1a\n" + - "\x05token\x18\x03 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\x12\x18\n" + - "\arevoked\x18\x05 \x01(\bR\arevoked\"\xe6\x02\n" + + "\x05token\x18\x03 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12C\n" + + "\x0fexpiration_time\x18h \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\x12\x18\n" + + "\arevoked\x18\x05 \x01(\bR\arevokedJ\x04\b\x04\x10\x05R\rexpires_at_ms\"\x93\x03\n" + "\x13WatchSandboxRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12#\n" + "\rfollow_status\x18\x02 \x01(\bR\ffollowStatus\x12\x1f\n" + @@ -15096,24 +15102,25 @@ const file_openshell_proto_rawDesc = "" + "\x0elog_tail_lines\x18\x05 \x01(\rR\flogTailLines\x12\x1d\n" + "\n" + "event_tail\x18\x06 \x01(\rR\teventTail\x12(\n" + - "\x10stop_on_terminal\x18\a \x01(\bR\x0estopOnTerminal\x12 \n" + - "\flog_since_ms\x18\b \x01(\x03R\n" + - "logSinceMs\x12\x1f\n" + + "\x10stop_on_terminal\x18\a \x01(\bR\x0estopOnTerminal\x129\n" + + "\n" + + "since_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\tsinceTime\x12\x1f\n" + "\vlog_sources\x18\t \x03(\tR\n" + "logSources\x12\"\n" + "\rlog_min_level\x18\n" + - " \x01(\tR\vlogMinLevel\"\xcc\x02\n" + + " \x01(\tR\vlogMinLevelJ\x04\b\b\x10\tR\flog_since_ms\"\xcc\x02\n" + "\x12SandboxStreamEvent\x121\n" + "\asandbox\x18\x01 \x01(\v2\x15.openshell.v1.SandboxH\x00R\asandbox\x120\n" + "\x03log\x18\x02 \x01(\v2\x1c.openshell.v1.SandboxLogLineH\x00R\x03log\x123\n" + "\x05event\x18\x03 \x01(\v2\x1b.openshell.v1.PlatformEventH\x00R\x05event\x12>\n" + "\awarning\x18\x04 \x01(\v2\".openshell.v1.SandboxStreamWarningH\x00R\awarning\x12Q\n" + "\x13draft_policy_update\x18\x05 \x01(\v2\x1f.openshell.v1.DraftPolicyUpdateH\x00R\x11draftPolicyUpdateB\t\n" + - "\apayload\"\xaf\x02\n" + + "\apayload\"\xdb\x02\n" + "\x0eSandboxLogLine\x12\x1d\n" + "\n" + - "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12!\n" + - "\ftimestamp_ms\x18\x02 \x01(\x03R\vtimestampMs\x12\x14\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x129\n" + + "\n" + + "event_time\x18f \x01(\v2\x1a.google.protobuf.TimestampR\teventTime\x12\x14\n" + "\x05level\x18\x03 \x01(\tR\x05level\x12\x16\n" + "\x06target\x18\x04 \x01(\tR\x06target\x12\x18\n" + "\amessage\x18\x05 \x01(\tR\amessage\x12\x16\n" + @@ -15121,7 +15128,7 @@ const file_openshell_proto_rawDesc = "" + "\x06fields\x18\a \x03(\v2(.openshell.v1.SandboxLogLine.FieldsEntryR\x06fields\x1a9\n" + "\vFieldsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"0\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x02\x10\x03R\ftimestamp_ms\"0\n" + "\x14SandboxStreamWarning\x12\x18\n" + "\amessage\x18\x01 \x01(\tR\amessage\"\xba\x01\n" + "\x15CreateProviderRequest\x12<\n" + @@ -15134,14 +15141,15 @@ 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\"\x92\x04\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" + - "\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\"\x90\x01\n" + + "\bprovider\x18\x01 \x01(\v2 .openshell.datamodel.v1.ProviderR\bprovider\x12\x82\x01\n" + + "\x1bcredential_expiration_times\x18f \x03(\v2B.openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntryR\x19credentialExpirationTimes\x12G\n" + + " clear_credential_expiration_keys\x18g \x03(\tR\x1dclearCredentialExpirationKeys\x12R\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x1ah\n" + + "\x1eCredentialExpirationTimesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x120\n" + + "\x05value\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x05value:\x028\x01J\x04\b\x03\x10\x04J\x04\b\x02\x10\x03R\tworkspaceR\x18credential_expires_at_ms\"\x90\x01\n" + "\x15DeleteProviderRequest\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\"P\n" + @@ -15179,20 +15187,20 @@ const file_openshell_proto_rawDesc = "" + "\n" + "credential\x18\x02 \x01(\tR\n" + "credential\x12,\n" + - "\x12subject_token_type\x18\x03 \x01(\tR\x10subjectTokenType\"\xce\x04\n" + + "\x12subject_token_type\x18\x03 \x01(\tR\x10subjectTokenType\"\xf3\x04\n" + "\x1cProviderCredentialTokenGrant\x12%\n" + "\x0etoken_endpoint\x18\x01 \x01(\tR\rtokenEndpoint\x12\x1a\n" + "\baudience\x18\x02 \x01(\tR\baudience\x12*\n" + "\x11jwt_svid_audience\x18\x06 \x01(\tR\x0fjwtSvidAudience\x12\x16\n" + - "\x06scopes\x18\x03 \x03(\tR\x06scopes\x12*\n" + - "\x11cache_ttl_seconds\x18\x04 \x01(\x03R\x0fcacheTtlSeconds\x12i\n" + + "\x06scopes\x18\x03 \x03(\tR\x06scopes\x126\n" + + "\tcache_ttl\x18h \x01(\v2\x19.google.protobuf.DurationR\bcacheTtl\x12i\n" + "\x12audience_overrides\x18\x05 \x03(\v2:.openshell.v1.ProviderCredentialTokenGrantAudienceOverrideR\x11audienceOverrides\x122\n" + "\x15client_assertion_type\x18\a \x01(\tR\x13clientAssertionType\x12M\n" + "\n" + "grant_type\x18\b \x01(\x0e2..openshell.v1.ProviderCredentialTokenGrantTypeR\tgrantType\x12[\n" + "\rsubject_token\x18\t \x01(\v26.openshell.v1.ProviderCredentialTokenGrantSubjectTokenR\fsubjectToken\x120\n" + "\x14requested_token_type\x18\n" + - " \x01(\tR\x12requestedTokenType\"\x9e\x03\n" + + " \x01(\tR\x12requestedTokenTypeJ\x04\b\x04\x10\x05R\x11cache_ttl_seconds\"\x9e\x03\n" + "\x19ProviderProfileCredential\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + @@ -15218,32 +15226,32 @@ const file_openshell_proto_rawDesc = "" + "\x06output\x18\x01 \x01(\tR\x06output\x12\x1e\n" + "\n" + "credential\x18\x02 \x01(\tR\n" + - "credential\"\xb0\x03\n" + + "credential\"\x82\x04\n" + "\x19ProviderCredentialRefresh\x12K\n" + "\bstrategy\x18\x01 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\x1b\n" + "\ttoken_url\x18\x02 \x01(\tR\btokenUrl\x12\x16\n" + - "\x06scopes\x18\x03 \x03(\tR\x06scopes\x124\n" + - "\x16refresh_before_seconds\x18\x04 \x01(\x03R\x14refreshBeforeSeconds\x120\n" + - "\x14max_lifetime_seconds\x18\x05 \x01(\x03R\x12maxLifetimeSeconds\x12K\n" + + "\x06scopes\x18\x03 \x03(\tR\x06scopes\x12@\n" + + "\x0erefresh_before\x18h \x01(\v2\x19.google.protobuf.DurationR\rrefreshBefore\x12<\n" + + "\fmax_lifetime\x18i \x01(\v2\x19.google.protobuf.DurationR\vmaxLifetime\x12K\n" + "\bmaterial\x18\x06 \x03(\v2/.openshell.v1.ProviderCredentialRefreshMaterialR\bmaterial\x12\\\n" + - "\x12additional_outputs\x18\a \x03(\v2-.openshell.v1.ProviderCredentialRefreshOutputR\x11additionalOutputs\"\xf2\x04\n" + + "\x12additional_outputs\x18\a \x03(\v2-.openshell.v1.ProviderCredentialRefreshOutputR\x11additionalOutputsJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x16refresh_before_secondsR\x14max_lifetime_seconds\"\xc5\x06\n" + "\x1fProviderCredentialRefreshStatus\x12#\n" + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x1f\n" + "\vprovider_id\x18\x02 \x01(\tR\n" + "providerId\x12%\n" + "\x0ecredential_key\x18\x03 \x01(\tR\rcredentialKey\x12K\n" + "\bstrategy\x18\x04 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12\x16\n" + - "\x06status\x18\x05 \x01(\tR\x06status\x12\"\n" + - "\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\x12+\n" + - "\x12next_refresh_at_ms\x18\a \x01(\x03R\x0fnextRefreshAtMs\x12+\n" + - "\x12last_refresh_at_ms\x18\b \x01(\x03R\x0flastRefreshAtMs\x12\x1d\n" + + "\x06status\x18\x05 \x01(\tR\x06status\x12C\n" + + "\x0fexpiration_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\x12F\n" + + "\x11next_refresh_time\x18k \x01(\v2\x1a.google.protobuf.TimestampR\x0fnextRefreshTime\x12F\n" + + "\x11last_refresh_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\x0flastRefreshTime\x12\x1d\n" + "\n" + "last_error\x18\t \x01(\tR\tlastError\x12^\n" + "\x0frecovery_action\x18\n" + " \x01(\x0e25.openshell.v1.ProviderCredentialRefreshRecoveryActionR\x0erecoveryAction\x12!\n" + "\ffailure_code\x18\v \x01(\tR\vfailureCode\x124\n" + - "\x16provider_error_subtype\x18\f \x01(\tR\x14providerErrorSubtype\x12'\n" + - "\x10last_error_at_ms\x18\r \x01(\x03R\rlastErrorAtMs\"<\n" + + "\x16provider_error_subtype\x18\f \x01(\tR\x14providerErrorSubtype\x12B\n" + + "\x0flast_error_time\x18q \x01(\v2\x1a.google.protobuf.TimestampR\rlastErrorTimeJ\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\b\x10\tJ\x04\b\r\x10\x0eR\rexpires_at_msR\x12next_refresh_at_msR\x12last_refresh_at_msR\x10last_error_at_ms\"<\n" + "\x18ProviderProfileDiscovery\x12 \n" + "\vcredentials\x18\x01 \x03(\tR\vcredentials\"\xc9\x01\n" + "\x1fGetProviderRefreshStatusRequest\x12\x1a\n" + @@ -15251,19 +15259,18 @@ 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" + "\bstrategy\x18\x03 \x01(\x0e2/.openshell.v1.ProviderCredentialRefreshStrategyR\bstrategy\x12]\n" + "\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" + + "\x14secret_material_keys\x18\x05 \x03(\tR\x12secretMaterialKeys\x12C\n" + + "\x0fexpiration_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\x12R\n" + "\x0fworkspace_scope\x18\b \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\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" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\a\x10\bJ\x04\b\x06\x10\aR\tworkspaceR\rexpires_at_ms\"i\n" + " ConfigureProviderRefreshResponse\x12E\n" + "\x06status\x18\x01 \x01(\v2-.openshell.v1.ProviderCredentialRefreshStatusR\x06status\"\xc9\x01\n" + "\x1fRotateProviderCredentialRequest\x12\x1a\n" + @@ -15341,38 +15348,38 @@ const file_openshell_proto_rawDesc = "" + "\x17StaticCredentialBinding\x12K\n" + "\tendpoints\x18\x01 \x03(\v2-.openshell.v1.StaticCredentialEndpointBindingR\tendpoints\x12/\n" + "\x13credential_identity\x18\x02 \x01(\tR\x12credentialIdentity\x12<\n" + - "\x1aworkload_credential_handle\x18\x03 \x01(\tR\x18workloadCredentialHandle\"\x90\b\n" + + "\x1aworkload_credential_handle\x18\x03 \x01(\tR\x18workloadCredentialHandle\"\xdb\b\n" + "%GetSandboxProviderEnvironmentResponse\x12l\n" + "\venvironment\x18\x01 \x03(\v2D.openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntryB\x04\x88\xb5\x18\x01R\venvironment\x122\n" + - "\x15provider_env_revision\x18\x02 \x01(\x04R\x13providerEnvRevision\x12\x87\x01\n" + - "\x18credential_expires_at_ms\x18\x03 \x03(\v2N.openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntryR\x15credentialExpiresAtMs\x12|\n" + + "\x15provider_env_revision\x18\x02 \x01(\x04R\x13providerEnvRevision\x12\x92\x01\n" + + "\x1bcredential_expiration_times\x18g \x03(\v2R.openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntryR\x19credentialExpirationTimes\x12|\n" + "\x13dynamic_credentials\x18\x04 \x03(\v2K.openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntryR\x12dynamicCredentials\x12\x8f\x01\n" + "\x1astatic_credential_bindings\x18\x05 \x03(\v2Q.openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntryR\x18staticCredentialBindings\x12=\n" + "\x1bnon_secret_environment_keys\x18\x06 \x03(\tR\x18nonSecretEnvironmentKeys\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aH\n" + - "\x1aCredentialExpiresAtMsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\x1an\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1ah\n" + + "\x1eCredentialExpirationTimesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x120\n" + + "\x05value\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x05value:\x028\x01\x1an\n" + "\x17DynamicCredentialsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12=\n" + "\x05value\x18\x02 \x01(\v2'.openshell.v1.ProviderProfileCredentialR\x05value:\x028\x01\x1ar\n" + "\x1dStaticCredentialBindingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12;\n" + - "\x05value\x18\x02 \x01(\v2%.openshell.v1.StaticCredentialBindingR\x05value:\x028\x01\"\xbd\x01\n" + + "\x05value\x18\x02 \x01(\v2%.openshell.v1.StaticCredentialBindingR\x05value:\x028\x01J\x04\b\x03\x10\x04R\x18credential_expires_at_ms\"\xbd\x01\n" + "#ExchangeProviderSubjectTokenRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1a\n" + "\bprovider\x18\x02 \x01(\tR\bprovider\x12%\n" + "\x0ecredential_key\x18\x03 \x01(\tR\rcredentialKey\x124\n" + - "\x13supervisor_jwt_svid\x18\x04 \x01(\tB\x04\x88\xb5\x18\x01R\x11supervisorJwtSvid\"\x8d\x01\n" + + "\x13supervisor_jwt_svid\x18\x04 \x01(\tB\x04\x88\xb5\x18\x01R\x11supervisorJwtSvid\"\xc0\x01\n" + "$ExchangeProviderSubjectTokenResponse\x12'\n" + - "\faccess_token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\vaccessToken\x12\x1d\n" + + "\faccess_token\x18\x01 \x01(\tB\x04\x88\xb5\x18\x01R\vaccessToken\x12>\n" + + "\rexpires_after\x18f \x01(\v2\x19.google.protobuf.DurationR\fexpiresAfter\x12\x1d\n" + "\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\ttokenTypeJ\x04\b\x02\x10\x03R\n" + + "expires_in\"\x95\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" + @@ -15455,32 +15462,33 @@ const file_openshell_proto_rawDesc = "" + "\x06status\x18\x03 \x01(\x0e2\x1a.openshell.v1.PolicyStatusR\x06status\x12\x1d\n" + "\n" + "load_error\x18\x04 \x01(\tR\tloadError\"\x1c\n" + - "\x1aReportPolicyStatusResponse\"\xbc\x03\n" + + "\x1aReportPolicyStatusResponse\"\x9b\x04\n" + "\x15SandboxPolicyRevision\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12\x1f\n" + "\vpolicy_hash\x18\x02 \x01(\tR\n" + "policyHash\x122\n" + "\x06status\x18\x03 \x01(\x0e2\x1a.openshell.v1.PolicyStatusR\x06status\x12\x1d\n" + "\n" + - "load_error\x18\x04 \x01(\tR\tloadError\x12\"\n" + - "\rcreated_at_ms\x18\x05 \x01(\x03R\vcreatedAtMs\x12 \n" + - "\floaded_at_ms\x18\x06 \x01(\x03R\n" + - "loadedAtMs\x12;\n" + + "load_error\x18\x04 \x01(\tR\tloadError\x12=\n" + + "\fcreated_time\x18i \x01(\v2\x1a.google.protobuf.TimestampR\vcreatedTime\x12;\n" + + "\vloaded_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "loadedTime\x12;\n" + "\x06policy\x18\a \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12S\n" + "\n" + "provenance\x18\b \x03(\v23.openshell.v1.SandboxPolicyRevision.ProvenanceEntryR\n" + "provenance\x1a=\n" + "\x0fProvenanceEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x83\x02\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x05\x10\x06J\x04\b\x06\x10\aR\rcreated_at_msR\floaded_at_ms\"\xb3\x02\n" + "\x15GetSandboxLogsRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x14\n" + - "\x05lines\x18\x02 \x01(\rR\x05lines\x12\x19\n" + - "\bsince_ms\x18\x03 \x01(\x03R\asinceMs\x12\x18\n" + + "\x05lines\x18\x02 \x01(\rR\x05lines\x129\n" + + "\n" + + "since_time\x18g \x01(\v2\x1a.google.protobuf.TimestampR\tsinceTime\x12\x18\n" + "\asources\x18\x04 \x03(\tR\asources\x12\x1b\n" + "\tmin_level\x18\x05 \x01(\tR\bminLevel\x12R\n" + - "\x0fworkspace_scope\x18\a \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x06\x10\aR\tworkspace\"i\n" + + "\x0fworkspace_scope\x18\a \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x06\x10\aJ\x04\b\x03\x10\x04R\tworkspaceR\bsince_ms\"i\n" + "\x16PushSandboxLogsRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x120\n" + @@ -15509,11 +15517,11 @@ const file_openshell_proto_rawDesc = "" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + "\vinstance_id\x18\x02 \x01(\tR\n" + - "instanceId\"h\n" + + "instanceId\"\x99\x01\n" + "\x0fSessionAccepted\x12\x1d\n" + "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x126\n" + - "\x17heartbeat_interval_secs\x18\x02 \x01(\rR\x15heartbeatIntervalSecs\")\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12H\n" + + "\x12heartbeat_interval\x18f \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatIntervalJ\x04\b\x02\x10\x03R\x17heartbeat_interval_secs\")\n" + "\x0fSessionRejected\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\"\x15\n" + "\x13SupervisorHeartbeat\"\x12\n" + @@ -15565,7 +15573,7 @@ const file_openshell_proto_rawDesc = "" + "\x06method\x18\x01 \x01(\tR\x06method\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1a\n" + "\bdecision\x18\x03 \x01(\tR\bdecision\x12\x14\n" + - "\x05count\x18\x04 \x01(\rR\x05count\"\xe5\x04\n" + + "\x05count\x18\x04 \x01(\rR\x05count\"\xce\x05\n" + "\rDenialSummary\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + @@ -15574,10 +15582,9 @@ const file_openshell_proto_rawDesc = "" + "\x06binary\x18\x04 \x01(\tR\x06binary\x12\x1c\n" + "\tancestors\x18\x05 \x03(\tR\tancestors\x12\x1f\n" + "\vdeny_reason\x18\x06 \x01(\tR\n" + - "denyReason\x12\"\n" + - "\rfirst_seen_ms\x18\a \x01(\x03R\vfirstSeenMs\x12 \n" + - "\flast_seen_ms\x18\b \x01(\x03R\n" + - "lastSeenMs\x12\x14\n" + + "denyReason\x12B\n" + + "\x0ffirst_seen_time\x18k \x01(\v2\x1a.google.protobuf.TimestampR\rfirstSeenTime\x12@\n" + + "\x0elast_seen_time\x18l \x01(\v2\x1a.google.protobuf.TimestampR\flastSeenTime\x12\x14\n" + "\x05count\x18\t \x01(\rR\x05count\x12)\n" + "\x10suppressed_count\x18\n" + " \x01(\rR\x0fsuppressedCount\x12\x1f\n" + @@ -15590,7 +15597,7 @@ const file_openshell_proto_rawDesc = "" + "persistent\x12!\n" + "\fdenial_stage\x18\x0f \x01(\tR\vdenialStage\x12K\n" + "\x12l7_request_samples\x18\x10 \x03(\v2\x1d.openshell.v1.L7RequestSampleR\x10l7RequestSamples\x120\n" + - "\x14l7_inspection_active\x18\x11 \x01(\bR\x12l7InspectionActive\"T\n" + + "\x14l7_inspection_active\x18\x11 \x01(\bR\x12l7InspectionActiveJ\x04\b\a\x10\bJ\x04\b\b\x10\tR\rfirst_seen_msR\flast_seen_ms\"T\n" + "\x10DenialGroupCount\x12\x1d\n" + "\n" + "deny_group\x18\x01 \x01(\tR\tdenyGroup\x12!\n" + @@ -15598,7 +15605,7 @@ const file_openshell_proto_rawDesc = "" + "\x16NetworkActivitySummary\x124\n" + "\x16network_activity_count\x18\x01 \x01(\rR\x14networkActivityCount\x12.\n" + "\x13denied_action_count\x18\x02 \x01(\rR\x11deniedActionCount\x12H\n" + - "\x10denials_by_group\x18\x03 \x03(\v2\x1e.openshell.v1.DenialGroupCountR\x0edenialsByGroup\"\xb0\b\n" + + "\x10denials_by_group\x18\x03 \x03(\v2\x1e.openshell.v1.DenialGroupCountR\x0edenialsByGroup\"\xf9\t\n" + "\vPolicyChunk\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1b\n" + @@ -15609,16 +15616,14 @@ const file_openshell_proto_rawDesc = "" + "\n" + "confidence\x18\a \x01(\x02R\n" + "confidence\x12,\n" + - "\x12denial_summary_ids\x18\b \x03(\tR\x10denialSummaryIds\x12\"\n" + - "\rcreated_at_ms\x18\t \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rdecided_at_ms\x18\n" + - " \x01(\x03R\vdecidedAtMs\x12\x14\n" + + "\x12denial_summary_ids\x18\b \x03(\tR\x10denialSummaryIds\x12=\n" + + "\fcreated_time\x18m \x01(\v2\x1a.google.protobuf.TimestampR\vcreatedTime\x12=\n" + + "\fdecided_time\x18n \x01(\v2\x1a.google.protobuf.TimestampR\vdecidedTime\x12\x14\n" + "\x05stage\x18\v \x01(\tR\x05stage\x12.\n" + "\x13supersedes_chunk_id\x18\f \x01(\tR\x11supersedesChunkId\x12\x1b\n" + - "\thit_count\x18\r \x01(\x05R\bhitCount\x12\"\n" + - "\rfirst_seen_ms\x18\x0e \x01(\x03R\vfirstSeenMs\x12 \n" + - "\flast_seen_ms\x18\x0f \x01(\x03R\n" + - "lastSeenMs\x12\x16\n" + + "\thit_count\x18\r \x01(\x05R\bhitCount\x12B\n" + + "\x0ffirst_seen_time\x18r \x01(\v2\x1a.google.protobuf.TimestampR\rfirstSeenTime\x12@\n" + + "\x0elast_seen_time\x18s \x01(\v2\x1a.google.protobuf.TimestampR\flastSeenTime\x12\x16\n" + "\x06binary\x18\x10 \x01(\tR\x06binary\x12+\n" + "\x11validation_result\x18\x11 \x01(\tR\x10validationResult\x12)\n" + "\x10rejection_reason\x18\x12 \x01(\tR\x0frejectionReason\x12+\n" + @@ -15627,7 +15632,9 @@ const file_openshell_proto_rawDesc = "" + "\x1dcurrent_effective_policy_hash\x18\x15 \x01(\tR\x1acurrentEffectivePolicyHash\x12E\n" + "\x1fcandidate_effective_policy_hash\x18\x16 \x01(\tR\x1ccandidateEffectivePolicyHash\x12]\n" + "\x18current_effective_policy\x18\x17 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x16currentEffectivePolicy\x12a\n" + - "\x1acandidate_effective_policy\x18\x18 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x18candidateEffectivePolicy\"\x96\x01\n" + + "\x1acandidate_effective_policy\x18\x18 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x18candidateEffectivePolicyJ\x04\b\t\x10\n" + + "J\x04\b\n" + + "\x10\vJ\x04\b\x0e\x10\x0fJ\x04\b\x0f\x10\x10R\rcreated_at_msR\rdecided_at_msR\rfirst_seen_msR\flast_seen_ms\"\x96\x01\n" + "\x11DraftPolicyUpdate\x12#\n" + "\rdraft_version\x18\x01 \x01(\x04R\fdraftVersion\x12\x1d\n" + "\n" + @@ -15649,12 +15656,12 @@ const file_openshell_proto_rawDesc = "" + "\x15GetDraftPolicyRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + "\rstatus_filter\x18\x02 \x01(\tR\fstatusFilter\x12R\n" + - "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"\xc8\x01\n" + + "\x0fworkspace_scope\x18\x04 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x03\x10\x04R\tworkspace\"\xfe\x01\n" + "\x16GetDraftPolicyResponse\x121\n" + "\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" + + "\rdraft_version\x18\x03 \x01(\x04R\fdraftVersion\x12H\n" + + "\x12last_analyzed_time\x18h \x01(\v2\x1a.google.protobuf.TimestampR\x10lastAnalyzedTimeJ\x04\b\x04\x10\x05R\x13last_analyzed_at_ms\"\xd1\x01\n" + "\x18ApproveDraftChunkRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bchunk_id\x18\x02 \x01(\tR\achunkId\x12!\n" + @@ -15705,13 +15712,14 @@ const file_openshell_proto_rawDesc = "" + "\x0echunks_cleared\x18\x01 \x01(\rR\rchunksCleared\"\x91\x01\n" + "\x16GetDraftHistoryRequest\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\"\x92\x01\n" + - "\x11DraftHistoryEntry\x12!\n" + - "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x1d\n" + + "\x0fworkspace_scope\x18\x03 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScopeJ\x04\b\x02\x10\x03R\tworkspace\"\xbe\x01\n" + + "\x11DraftHistoryEntry\x129\n" + + "\n" + + "event_time\x18e \x01(\v2\x1a.google.protobuf.TimestampR\teventTime\x12\x1d\n" + "\n" + "event_type\x18\x02 \x01(\tR\teventType\x12 \n" + "\vdescription\x18\x03 \x01(\tR\vdescription\x12\x19\n" + - "\bchunk_id\x18\x04 \x01(\tR\achunkId\"T\n" + + "\bchunk_id\x18\x04 \x01(\tR\achunkIdJ\x04\b\x01\x10\x02R\ftimestamp_ms\"T\n" + "\x17GetDraftHistoryResponse\x129\n" + "\aentries\x18\x01 \x03(\v2\x1f.openshell.v1.DraftHistoryEntryR\aentries\"\xb1\x01\n" + "\x16CreateWorkspaceRequest\x12\x12\n" + @@ -15762,11 +15770,11 @@ const file_openshell_proto_rawDesc = "" + "page_token\x18\x03 \x01(\tR\tpageToken\"\x7f\n" + "\x1cListWorkspaceMembersResponse\x127\n" + "\amembers\x18\x01 \x03(\v2\x1d.openshell.v1.WorkspaceMemberR\amembers\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x7f\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xb5\x01\n" + "\x1aExtensionServiceCredential\x12!\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1a\n" + - "\x05token\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12\"\n" + - "\rexpires_at_ms\x18\x03 \x01(\x03R\vexpiresAtMs\"l\n" + + "\x05token\x18\x02 \x01(\tB\x04\x88\xb5\x18\x01R\x05token\x12C\n" + + "\x0fexpiration_time\x18g \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTimeJ\x04\b\x03\x10\x04R\rexpires_at_ms\"l\n" + "\x13EndpointObservation\x12\x1f\n" + "\vendpoint_id\x18\x01 \x01(\tR\n" + "endpointId\x124\n" + @@ -15781,7 +15789,7 @@ const file_openshell_proto_rawDesc = "" + "\x15observed_endpoint_ids\x18\x05 \x03(\tR\x13observedEndpointIds\x122\n" + "\x15supervisor_session_id\x18\x06 \x01(\tR\x13supervisorSessionId\x12'\n" + "\x0freport_sequence\x18\a \x01(\x04R\x0ereportSequence\"\x1e\n" + - "\x1cReportEndpointStatusResponse\"\xd8\x01\n" + + "\x1cReportEndpointStatusResponse\"\x90\x02\n" + "\x0eEndpointStatus\x12\x1f\n" + "\vendpoint_id\x18\x01 \x01(\tR\n" + "endpointId\x12\x12\n" + @@ -15789,8 +15797,8 @@ const file_openshell_proto_rawDesc = "" + "\x05ports\x18\x03 \x03(\rR\x05ports\x12\x12\n" + "\x04path\x18\x04 \x01(\tR\x04path\x12=\n" + "\vlast_result\x18\x05 \x01(\x0e2\x1c.openshell.v1.EndpointResultR\n" + - "lastResult\x12(\n" + - "\x10last_reported_at\x18\x06 \x01(\tR\x0elastReportedAt*\xa6\x02\n" + + "lastResult\x12H\n" + + "\x12last_reported_time\x18j \x01(\v2\x1a.google.protobuf.TimestampR\x10lastReportedTimeJ\x04\b\x06\x10\aR\x10last_reported_at*\xa6\x02\n" + "\fSandboxPhase\x12\x1d\n" + "\x19SANDBOX_PHASE_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSANDBOX_PHASE_PROVISIONING\x10\x01\x12\x17\n" + @@ -16253,399 +16261,436 @@ var file_openshell_proto_goTypes = []any{ nil, // 227: openshell.v1.CreateSandboxRequest.AnnotationsEntry nil, // 228: openshell.v1.ExecSandboxRequest.EnvironmentEntry nil, // 229: openshell.v1.SandboxLogLine.FieldsEntry - nil, // 230: openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry + nil, // 230: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry nil, // 231: openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry nil, // 232: openshell.v1.ProviderProfile.AnnotationsEntry nil, // 233: openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - nil, // 234: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry + nil, // 234: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry nil, // 235: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry nil, // 236: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry nil, // 237: openshell.v1.UpdateConfigRequest.AnnotationsEntry nil, // 238: openshell.v1.UpdateConfigResponse.AnnotationsEntry nil, // 239: openshell.v1.SandboxPolicyRevision.ProvenanceEntry nil, // 240: openshell.v1.CreateWorkspaceRequest.LabelsEntry - (*datamodelv1.ObjectMeta)(nil), // 241: openshell.datamodel.v1.ObjectMeta - (*sandboxv1.SandboxPolicy)(nil), // 242: openshell.sandbox.v1.SandboxPolicy - (*structpb.Struct)(nil), // 243: google.protobuf.Struct - (*durationpb.Duration)(nil), // 244: google.protobuf.Duration - (*datamodelv1.WorkspaceSelector)(nil), // 245: openshell.datamodel.v1.WorkspaceSelector - (*datamodelv1.Provider)(nil), // 246: openshell.datamodel.v1.Provider - (*sandboxv1.NetworkEndpoint)(nil), // 247: openshell.sandbox.v1.NetworkEndpoint - (*sandboxv1.NetworkBinary)(nil), // 248: openshell.sandbox.v1.NetworkBinary - (*sandboxv1.SettingValue)(nil), // 249: openshell.sandbox.v1.SettingValue - (*sandboxv1.NetworkPolicyRule)(nil), // 250: openshell.sandbox.v1.NetworkPolicyRule - (*sandboxv1.L7DenyRule)(nil), // 251: openshell.sandbox.v1.L7DenyRule - (*sandboxv1.L7Rule)(nil), // 252: openshell.sandbox.v1.L7Rule - (*datamodelv1.Workspace)(nil), // 253: openshell.datamodel.v1.Workspace - (*sandboxv1.GetSandboxConfigRequest)(nil), // 254: openshell.sandbox.v1.GetSandboxConfigRequest - (*sandboxv1.GetGatewayConfigRequest)(nil), // 255: openshell.sandbox.v1.GetGatewayConfigRequest - (*sandboxv1.GetSandboxConfigResponse)(nil), // 256: openshell.sandbox.v1.GetSandboxConfigResponse - (*sandboxv1.GetGatewayConfigResponse)(nil), // 257: openshell.sandbox.v1.GetGatewayConfigResponse + (*timestamppb.Timestamp)(nil), // 241: google.protobuf.Timestamp + (*datamodelv1.ObjectMeta)(nil), // 242: openshell.datamodel.v1.ObjectMeta + (*sandboxv1.SandboxPolicy)(nil), // 243: openshell.sandbox.v1.SandboxPolicy + (*structpb.Struct)(nil), // 244: google.protobuf.Struct + (*durationpb.Duration)(nil), // 245: google.protobuf.Duration + (*datamodelv1.WorkspaceSelector)(nil), // 246: openshell.datamodel.v1.WorkspaceSelector + (*datamodelv1.Provider)(nil), // 247: openshell.datamodel.v1.Provider + (*sandboxv1.NetworkEndpoint)(nil), // 248: openshell.sandbox.v1.NetworkEndpoint + (*sandboxv1.NetworkBinary)(nil), // 249: openshell.sandbox.v1.NetworkBinary + (*sandboxv1.SettingValue)(nil), // 250: openshell.sandbox.v1.SettingValue + (*sandboxv1.NetworkPolicyRule)(nil), // 251: openshell.sandbox.v1.NetworkPolicyRule + (*sandboxv1.L7DenyRule)(nil), // 252: openshell.sandbox.v1.L7DenyRule + (*sandboxv1.L7Rule)(nil), // 253: openshell.sandbox.v1.L7Rule + (*datamodelv1.Workspace)(nil), // 254: openshell.datamodel.v1.Workspace + (*sandboxv1.GetSandboxConfigRequest)(nil), // 255: openshell.sandbox.v1.GetSandboxConfigRequest + (*sandboxv1.GetGatewayConfigRequest)(nil), // 256: openshell.sandbox.v1.GetGatewayConfigRequest + (*sandboxv1.GetSandboxConfigResponse)(nil), // 257: openshell.sandbox.v1.GetSandboxConfigResponse + (*sandboxv1.GetGatewayConfigResponse)(nil), // 258: openshell.sandbox.v1.GetGatewayConfigResponse } var file_openshell_proto_depIdxs = []int32{ - 215, // 0: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential - 5, // 1: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus - 5, // 2: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus - 19, // 3: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo - 20, // 4: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities - 21, // 5: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities - 22, // 6: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities - 23, // 7: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities - 24, // 8: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities - 241, // 9: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 26, // 10: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec - 37, // 11: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus - 36, // 12: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance - 220, // 13: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry - 29, // 14: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate - 242, // 15: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 27, // 16: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements - 28, // 17: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements - 221, // 18: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry - 222, // 19: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry - 223, // 20: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry - 243, // 21: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct - 243, // 22: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct - 241, // 23: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 31, // 24: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec - 32, // 25: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig - 243, // 26: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct - 34, // 27: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel - 224, // 28: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry - 33, // 29: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources - 28, // 30: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements - 35, // 31: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup - 244, // 32: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration - 38, // 33: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition - 0, // 34: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase - 219, // 35: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus - 225, // 36: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry - 26, // 37: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec - 226, // 38: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry - 227, // 39: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry - 245, // 40: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 30, // 41: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 245, // 42: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 43: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 44: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 45: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 30, // 46: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate - 30, // 47: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate - 245, // 48: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 49: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 50: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 51: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 52: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 53: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 54: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 55: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 56: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 25, // 57: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox - 25, // 58: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox - 246, // 59: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 25, // 60: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 25, // 61: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox - 245, // 62: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 63: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 64: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 73, // 65: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse - 245, // 66: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 241, // 67: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 72, // 68: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint - 228, // 69: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry - 77, // 70: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout - 78, // 71: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr - 79, // 72: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit - 168, // 73: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget - 169, // 74: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget - 81, // 75: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit - 76, // 76: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest - 84, // 77: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize - 241, // 78: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 25, // 79: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox - 88, // 80: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine - 39, // 81: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent - 89, // 82: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning - 179, // 83: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate - 229, // 84: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry - 246, // 85: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 245, // 86: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 87: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 88: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 246, // 89: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider - 230, // 90: openshell.v1.UpdateProviderRequest.credential_expires_at_ms:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpiresAtMsEntry - 245, // 91: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 92: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 246, // 93: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider - 246, // 94: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider - 118, // 95: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile - 101, // 96: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride - 1, // 97: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType - 102, // 98: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken - 107, // 99: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh - 103, // 100: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant - 2, // 101: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 105, // 102: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial - 106, // 103: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput - 2, // 104: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 7, // 105: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction - 245, // 106: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 108, // 107: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 2, // 108: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy - 231, // 109: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry - 245, // 110: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 108, // 111: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 245, // 112: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 108, // 113: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus - 245, // 114: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 3, // 115: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory - 104, // 116: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential - 247, // 117: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint - 248, // 118: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary - 109, // 119: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery - 232, // 120: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry - 118, // 121: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile - 118, // 122: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 99, // 123: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 100, // 124: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 118, // 125: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile - 99, // 126: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem - 100, // 127: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 118, // 128: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile - 99, // 129: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem - 100, // 130: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic - 131, // 131: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding - 233, // 132: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry - 234, // 133: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expires_at_ms:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpiresAtMsEntry - 235, // 134: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry - 236, // 135: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry - 242, // 136: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 249, // 137: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue - 137, // 138: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation - 237, // 139: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry - 245, // 140: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 138, // 141: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule - 139, // 142: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint - 140, // 143: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule - 141, // 144: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules - 142, // 145: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules - 143, // 146: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary - 250, // 147: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 251, // 148: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule - 252, // 149: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule - 238, // 150: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry - 245, // 151: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 151, // 152: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision - 245, // 153: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 151, // 154: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision - 4, // 155: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus - 4, // 156: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus - 242, // 157: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 239, // 158: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry - 245, // 159: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 88, // 160: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine - 88, // 161: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine - 158, // 162: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello - 161, // 163: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat - 172, // 164: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult - 173, // 165: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose - 159, // 166: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted - 160, // 167: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected - 162, // 168: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat - 167, // 169: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen - 173, // 170: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose - 168, // 171: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget - 169, // 172: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget - 170, // 173: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit - 174, // 174: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample - 176, // 175: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount - 250, // 176: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 242, // 177: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 242, // 178: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy - 175, // 179: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary - 178, // 180: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk - 177, // 181: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary - 245, // 182: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 178, // 183: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk - 245, // 184: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 185: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 188, // 186: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval - 245, // 187: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 250, // 188: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 245, // 189: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 190: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 191: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 245, // 192: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector - 198, // 193: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry - 240, // 194: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry - 253, // 195: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 253, // 196: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace - 253, // 197: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace - 241, // 198: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta - 6, // 199: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole - 6, // 200: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole - 208, // 201: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember - 208, // 202: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember - 8, // 203: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult - 216, // 204: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation - 8, // 205: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult - 104, // 206: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential - 132, // 207: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding - 13, // 208: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest - 15, // 209: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest - 17, // 210: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest - 40, // 211: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest - 48, // 212: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest - 50, // 213: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest - 51, // 214: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest - 41, // 215: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest - 42, // 216: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest - 43, // 217: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest - 44, // 218: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest - 52, // 219: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest - 53, // 220: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest - 54, // 221: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest - 55, // 222: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest - 56, // 223: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest - 57, // 224: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest - 64, // 225: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest - 66, // 226: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest - 67, // 227: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest - 68, // 228: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest - 70, // 229: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest - 74, // 230: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest - 76, // 231: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest - 82, // 232: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame - 83, // 233: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput - 90, // 234: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest - 91, // 235: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest - 92, // 236: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest - 97, // 237: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest - 98, // 238: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest - 121, // 239: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest - 123, // 240: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest - 125, // 241: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest - 93, // 242: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest - 110, // 243: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest - 112, // 244: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest - 114, // 245: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest - 116, // 246: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest - 94, // 247: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest - 128, // 248: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest - 254, // 249: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest - 255, // 250: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest - 136, // 251: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest - 145, // 252: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest - 147, // 253: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest - 149, // 254: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest - 217, // 255: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest - 130, // 256: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest - 134, // 257: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest - 152, // 258: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest - 153, // 259: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest - 156, // 260: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage - 163, // 261: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest - 165, // 262: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest - 171, // 263: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame - 86, // 264: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest - 180, // 265: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest - 182, // 266: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest - 184, // 267: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest - 186, // 268: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest - 189, // 269: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest - 191, // 270: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest - 193, // 271: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest - 195, // 272: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest - 197, // 273: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest - 9, // 274: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest - 11, // 275: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest - 200, // 276: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest - 202, // 277: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest - 204, // 278: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest - 206, // 279: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest - 209, // 280: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest - 211, // 281: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest - 213, // 282: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest - 14, // 283: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse - 16, // 284: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse - 18, // 285: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse - 58, // 286: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse - 49, // 287: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse - 58, // 288: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse - 59, // 289: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse - 45, // 290: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 45, // 291: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse - 46, // 292: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse - 47, // 293: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse - 60, // 294: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse - 61, // 295: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse - 62, // 296: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse - 63, // 297: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse - 58, // 298: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse - 58, // 299: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse - 65, // 300: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse - 73, // 301: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse - 73, // 302: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse - 69, // 303: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse - 71, // 304: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse - 75, // 305: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse - 80, // 306: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent - 82, // 307: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame - 80, // 308: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent - 95, // 309: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse - 95, // 310: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse - 96, // 311: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse - 120, // 312: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse - 119, // 313: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse - 122, // 314: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse - 124, // 315: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse - 126, // 316: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse - 95, // 317: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse - 111, // 318: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse - 113, // 319: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse - 115, // 320: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse - 117, // 321: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse - 127, // 322: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse - 129, // 323: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse - 256, // 324: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse - 257, // 325: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse - 144, // 326: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse - 146, // 327: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse - 148, // 328: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse - 150, // 329: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse - 218, // 330: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse - 133, // 331: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse - 135, // 332: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse - 155, // 333: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse - 154, // 334: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse - 157, // 335: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage - 164, // 336: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse - 166, // 337: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse - 171, // 338: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame - 87, // 339: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent - 181, // 340: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse - 183, // 341: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse - 185, // 342: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse - 187, // 343: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse - 190, // 344: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse - 192, // 345: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse - 194, // 346: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse - 196, // 347: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse - 199, // 348: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse - 10, // 349: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse - 12, // 350: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse - 201, // 351: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse - 203, // 352: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse - 205, // 353: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse - 207, // 354: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse - 210, // 355: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse - 212, // 356: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse - 214, // 357: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse - 283, // [283:358] is the sub-list for method output_type - 208, // [208:283] is the sub-list for method input_type - 208, // [208:208] is the sub-list for extension type_name - 208, // [208:208] is the sub-list for extension extendee - 0, // [0:208] is the sub-list for field type_name + 241, // 0: openshell.v1.IssueSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 241, // 1: openshell.v1.RefreshSandboxTokenResponse.expiration_time:type_name -> google.protobuf.Timestamp + 215, // 2: openshell.v1.RefreshSandboxTokenResponse.extension_credentials:type_name -> openshell.v1.ExtensionServiceCredential + 241, // 3: openshell.v1.RefreshSandboxTokenResponse.sandbox_expiration_time:type_name -> google.protobuf.Timestamp + 5, // 4: openshell.v1.HealthResponse.status:type_name -> openshell.v1.ServiceStatus + 5, // 5: openshell.v1.GetGatewayInfoResponse.status:type_name -> openshell.v1.ServiceStatus + 19, // 6: openshell.v1.GetGatewayInfoResponse.compute_drivers:type_name -> openshell.v1.ComputeDriverInfo + 20, // 7: openshell.v1.ComputeDriverInfo.capabilities:type_name -> openshell.v1.ComputeDriverCapabilities + 21, // 8: openshell.v1.ComputeDriverCapabilities.resource_capabilities:type_name -> openshell.v1.ResourceCapabilities + 22, // 9: openshell.v1.ResourceCapabilities.cpu:type_name -> openshell.v1.CpuResourceCapabilities + 23, // 10: openshell.v1.ResourceCapabilities.memory:type_name -> openshell.v1.MemoryResourceCapabilities + 24, // 11: openshell.v1.ResourceCapabilities.gpu:type_name -> openshell.v1.GpuResourceCapabilities + 242, // 12: openshell.v1.Sandbox.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 26, // 13: openshell.v1.Sandbox.spec:type_name -> openshell.v1.SandboxSpec + 37, // 14: openshell.v1.Sandbox.status:type_name -> openshell.v1.SandboxStatus + 36, // 15: openshell.v1.Sandbox.created_from_workload_template:type_name -> openshell.v1.SandboxWorkloadTemplateProvenance + 220, // 16: openshell.v1.SandboxSpec.environment:type_name -> openshell.v1.SandboxSpec.EnvironmentEntry + 29, // 17: openshell.v1.SandboxSpec.template:type_name -> openshell.v1.SandboxTemplate + 243, // 18: openshell.v1.SandboxSpec.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 27, // 19: openshell.v1.SandboxSpec.resource_requirements:type_name -> openshell.v1.ResourceRequirements + 28, // 20: openshell.v1.ResourceRequirements.gpu:type_name -> openshell.v1.GpuResourceRequirements + 221, // 21: openshell.v1.SandboxTemplate.labels:type_name -> openshell.v1.SandboxTemplate.LabelsEntry + 222, // 22: openshell.v1.SandboxTemplate.annotations:type_name -> openshell.v1.SandboxTemplate.AnnotationsEntry + 223, // 23: openshell.v1.SandboxTemplate.environment:type_name -> openshell.v1.SandboxTemplate.EnvironmentEntry + 244, // 24: openshell.v1.SandboxTemplate.resources:type_name -> google.protobuf.Struct + 244, // 25: openshell.v1.SandboxTemplate.driver_config:type_name -> google.protobuf.Struct + 242, // 26: openshell.v1.SandboxWorkloadTemplate.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 31, // 27: openshell.v1.SandboxWorkloadTemplate.spec:type_name -> openshell.v1.SandboxWorkloadTemplateSpec + 32, // 28: openshell.v1.SandboxWorkloadTemplateSpec.workload:type_name -> openshell.v1.SandboxWorkloadConfig + 244, // 29: openshell.v1.SandboxWorkloadTemplateSpec.driver_config:type_name -> google.protobuf.Struct + 34, // 30: openshell.v1.SandboxWorkloadTemplateSpec.desired_service_level:type_name -> openshell.v1.SandboxServiceLevel + 224, // 31: openshell.v1.SandboxWorkloadConfig.environment:type_name -> openshell.v1.SandboxWorkloadConfig.EnvironmentEntry + 33, // 32: openshell.v1.SandboxWorkloadConfig.resources:type_name -> openshell.v1.SandboxResources + 28, // 33: openshell.v1.SandboxResources.gpu:type_name -> openshell.v1.GpuResourceRequirements + 35, // 34: openshell.v1.SandboxServiceLevel.startup:type_name -> openshell.v1.SandboxStartup + 245, // 35: openshell.v1.SandboxStartup.ready_within:type_name -> google.protobuf.Duration + 38, // 36: openshell.v1.SandboxStatus.conditions:type_name -> openshell.v1.SandboxCondition + 0, // 37: openshell.v1.SandboxStatus.phase:type_name -> openshell.v1.SandboxPhase + 219, // 38: openshell.v1.SandboxStatus.endpoint_statuses:type_name -> openshell.v1.EndpointStatus + 241, // 39: openshell.v1.SandboxCondition.transition_time:type_name -> google.protobuf.Timestamp + 241, // 40: openshell.v1.PlatformEvent.event_time:type_name -> google.protobuf.Timestamp + 225, // 41: openshell.v1.PlatformEvent.metadata:type_name -> openshell.v1.PlatformEvent.MetadataEntry + 26, // 42: openshell.v1.CreateSandboxRequest.spec:type_name -> openshell.v1.SandboxSpec + 226, // 43: openshell.v1.CreateSandboxRequest.labels:type_name -> openshell.v1.CreateSandboxRequest.LabelsEntry + 227, // 44: openshell.v1.CreateSandboxRequest.annotations:type_name -> openshell.v1.CreateSandboxRequest.AnnotationsEntry + 246, // 45: openshell.v1.CreateSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 30, // 46: openshell.v1.CreateSandboxTemplateRequest.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 246, // 47: openshell.v1.CreateSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 48: openshell.v1.GetSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 49: openshell.v1.ListSandboxTemplatesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 50: openshell.v1.DeleteSandboxTemplateRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 30, // 51: openshell.v1.SandboxTemplateResponse.template:type_name -> openshell.v1.SandboxWorkloadTemplate + 30, // 52: openshell.v1.ListSandboxTemplatesResponse.templates:type_name -> openshell.v1.SandboxWorkloadTemplate + 246, // 53: openshell.v1.BeginRootfsTarStagingRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 241, // 54: openshell.v1.BeginRootfsTarStagingResponse.expiration_time:type_name -> google.protobuf.Timestamp + 246, // 55: openshell.v1.GetSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 56: openshell.v1.ListSandboxesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 57: openshell.v1.ListSandboxProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 58: openshell.v1.AttachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 59: openshell.v1.DetachSandboxProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 60: openshell.v1.DeleteSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 61: openshell.v1.StopSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 62: openshell.v1.StartSandboxRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 25, // 63: openshell.v1.SandboxResponse.sandbox:type_name -> openshell.v1.Sandbox + 25, // 64: openshell.v1.ListSandboxesResponse.sandboxes:type_name -> openshell.v1.Sandbox + 247, // 65: openshell.v1.ListSandboxProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 25, // 66: openshell.v1.AttachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 25, // 67: openshell.v1.DetachSandboxProviderResponse.sandbox:type_name -> openshell.v1.Sandbox + 241, // 68: openshell.v1.CreateSshSessionResponse.expiration_time:type_name -> google.protobuf.Timestamp + 246, // 69: openshell.v1.ExposeServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 70: openshell.v1.GetServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 71: openshell.v1.ListServicesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 73, // 72: openshell.v1.ListServicesResponse.services:type_name -> openshell.v1.ServiceEndpointResponse + 246, // 73: openshell.v1.DeleteServiceRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 242, // 74: openshell.v1.ServiceEndpoint.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 72, // 75: openshell.v1.ServiceEndpointResponse.endpoint:type_name -> openshell.v1.ServiceEndpoint + 228, // 76: openshell.v1.ExecSandboxRequest.environment:type_name -> openshell.v1.ExecSandboxRequest.EnvironmentEntry + 245, // 77: openshell.v1.ExecSandboxRequest.execution_timeout:type_name -> google.protobuf.Duration + 77, // 78: openshell.v1.ExecSandboxEvent.stdout:type_name -> openshell.v1.ExecSandboxStdout + 78, // 79: openshell.v1.ExecSandboxEvent.stderr:type_name -> openshell.v1.ExecSandboxStderr + 79, // 80: openshell.v1.ExecSandboxEvent.exit:type_name -> openshell.v1.ExecSandboxExit + 168, // 81: openshell.v1.TcpForwardInit.ssh:type_name -> openshell.v1.SshRelayTarget + 169, // 82: openshell.v1.TcpForwardInit.tcp:type_name -> openshell.v1.TcpRelayTarget + 81, // 83: openshell.v1.TcpForwardFrame.init:type_name -> openshell.v1.TcpForwardInit + 76, // 84: openshell.v1.ExecSandboxInput.start:type_name -> openshell.v1.ExecSandboxRequest + 84, // 85: openshell.v1.ExecSandboxInput.resize:type_name -> openshell.v1.ExecSandboxWindowResize + 242, // 86: openshell.v1.SshSession.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 241, // 87: openshell.v1.SshSession.expiration_time:type_name -> google.protobuf.Timestamp + 241, // 88: openshell.v1.WatchSandboxRequest.since_time:type_name -> google.protobuf.Timestamp + 25, // 89: openshell.v1.SandboxStreamEvent.sandbox:type_name -> openshell.v1.Sandbox + 88, // 90: openshell.v1.SandboxStreamEvent.log:type_name -> openshell.v1.SandboxLogLine + 39, // 91: openshell.v1.SandboxStreamEvent.event:type_name -> openshell.v1.PlatformEvent + 89, // 92: openshell.v1.SandboxStreamEvent.warning:type_name -> openshell.v1.SandboxStreamWarning + 179, // 93: openshell.v1.SandboxStreamEvent.draft_policy_update:type_name -> openshell.v1.DraftPolicyUpdate + 241, // 94: openshell.v1.SandboxLogLine.event_time:type_name -> google.protobuf.Timestamp + 229, // 95: openshell.v1.SandboxLogLine.fields:type_name -> openshell.v1.SandboxLogLine.FieldsEntry + 247, // 96: openshell.v1.CreateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 246, // 97: openshell.v1.CreateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 98: openshell.v1.GetProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 99: openshell.v1.ListProvidersRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 247, // 100: openshell.v1.UpdateProviderRequest.provider:type_name -> openshell.datamodel.v1.Provider + 230, // 101: openshell.v1.UpdateProviderRequest.credential_expiration_times:type_name -> openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry + 246, // 102: openshell.v1.UpdateProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 103: openshell.v1.DeleteProviderRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 247, // 104: openshell.v1.ProviderResponse.provider:type_name -> openshell.datamodel.v1.Provider + 247, // 105: openshell.v1.ListProvidersResponse.providers:type_name -> openshell.datamodel.v1.Provider + 118, // 106: openshell.v1.ProviderProfileImportItem.profile:type_name -> openshell.v1.ProviderProfile + 245, // 107: openshell.v1.ProviderCredentialTokenGrant.cache_ttl:type_name -> google.protobuf.Duration + 101, // 108: openshell.v1.ProviderCredentialTokenGrant.audience_overrides:type_name -> openshell.v1.ProviderCredentialTokenGrantAudienceOverride + 1, // 109: openshell.v1.ProviderCredentialTokenGrant.grant_type:type_name -> openshell.v1.ProviderCredentialTokenGrantType + 102, // 110: openshell.v1.ProviderCredentialTokenGrant.subject_token:type_name -> openshell.v1.ProviderCredentialTokenGrantSubjectToken + 107, // 111: openshell.v1.ProviderProfileCredential.refresh:type_name -> openshell.v1.ProviderCredentialRefresh + 103, // 112: openshell.v1.ProviderProfileCredential.token_grant:type_name -> openshell.v1.ProviderCredentialTokenGrant + 2, // 113: openshell.v1.ProviderCredentialRefresh.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 245, // 114: openshell.v1.ProviderCredentialRefresh.refresh_before:type_name -> google.protobuf.Duration + 245, // 115: openshell.v1.ProviderCredentialRefresh.max_lifetime:type_name -> google.protobuf.Duration + 105, // 116: openshell.v1.ProviderCredentialRefresh.material:type_name -> openshell.v1.ProviderCredentialRefreshMaterial + 106, // 117: openshell.v1.ProviderCredentialRefresh.additional_outputs:type_name -> openshell.v1.ProviderCredentialRefreshOutput + 2, // 118: openshell.v1.ProviderCredentialRefreshStatus.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 241, // 119: openshell.v1.ProviderCredentialRefreshStatus.expiration_time:type_name -> google.protobuf.Timestamp + 241, // 120: openshell.v1.ProviderCredentialRefreshStatus.next_refresh_time:type_name -> google.protobuf.Timestamp + 241, // 121: openshell.v1.ProviderCredentialRefreshStatus.last_refresh_time:type_name -> google.protobuf.Timestamp + 7, // 122: openshell.v1.ProviderCredentialRefreshStatus.recovery_action:type_name -> openshell.v1.ProviderCredentialRefreshRecoveryAction + 241, // 123: openshell.v1.ProviderCredentialRefreshStatus.last_error_time:type_name -> google.protobuf.Timestamp + 246, // 124: openshell.v1.GetProviderRefreshStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 108, // 125: openshell.v1.GetProviderRefreshStatusResponse.credentials:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 2, // 126: openshell.v1.ConfigureProviderRefreshRequest.strategy:type_name -> openshell.v1.ProviderCredentialRefreshStrategy + 231, // 127: openshell.v1.ConfigureProviderRefreshRequest.material:type_name -> openshell.v1.ConfigureProviderRefreshRequest.MaterialEntry + 241, // 128: openshell.v1.ConfigureProviderRefreshRequest.expiration_time:type_name -> google.protobuf.Timestamp + 246, // 129: openshell.v1.ConfigureProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 108, // 130: openshell.v1.ConfigureProviderRefreshResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 246, // 131: openshell.v1.RotateProviderCredentialRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 108, // 132: openshell.v1.RotateProviderCredentialResponse.status:type_name -> openshell.v1.ProviderCredentialRefreshStatus + 246, // 133: openshell.v1.DeleteProviderRefreshRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 3, // 134: openshell.v1.ProviderProfile.category:type_name -> openshell.v1.ProviderProfileCategory + 104, // 135: openshell.v1.ProviderProfile.credentials:type_name -> openshell.v1.ProviderProfileCredential + 248, // 136: openshell.v1.ProviderProfile.endpoints:type_name -> openshell.sandbox.v1.NetworkEndpoint + 249, // 137: openshell.v1.ProviderProfile.binaries:type_name -> openshell.sandbox.v1.NetworkBinary + 109, // 138: openshell.v1.ProviderProfile.discovery:type_name -> openshell.v1.ProviderProfileDiscovery + 232, // 139: openshell.v1.ProviderProfile.annotations:type_name -> openshell.v1.ProviderProfile.AnnotationsEntry + 118, // 140: openshell.v1.ProviderProfileResponse.profile:type_name -> openshell.v1.ProviderProfile + 118, // 141: openshell.v1.ListProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 99, // 142: openshell.v1.ImportProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 100, // 143: openshell.v1.ImportProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 118, // 144: openshell.v1.ImportProviderProfilesResponse.profiles:type_name -> openshell.v1.ProviderProfile + 99, // 145: openshell.v1.UpdateProviderProfilesRequest.profile:type_name -> openshell.v1.ProviderProfileImportItem + 100, // 146: openshell.v1.UpdateProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 118, // 147: openshell.v1.UpdateProviderProfilesResponse.profile:type_name -> openshell.v1.ProviderProfile + 99, // 148: openshell.v1.LintProviderProfilesRequest.profiles:type_name -> openshell.v1.ProviderProfileImportItem + 100, // 149: openshell.v1.LintProviderProfilesResponse.diagnostics:type_name -> openshell.v1.ProviderProfileDiagnostic + 131, // 150: openshell.v1.StaticCredentialBinding.endpoints:type_name -> openshell.v1.StaticCredentialEndpointBinding + 233, // 151: openshell.v1.GetSandboxProviderEnvironmentResponse.environment:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntry + 234, // 152: openshell.v1.GetSandboxProviderEnvironmentResponse.credential_expiration_times:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry + 235, // 153: openshell.v1.GetSandboxProviderEnvironmentResponse.dynamic_credentials:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry + 236, // 154: openshell.v1.GetSandboxProviderEnvironmentResponse.static_credential_bindings:type_name -> openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry + 245, // 155: openshell.v1.ExchangeProviderSubjectTokenResponse.expires_after:type_name -> google.protobuf.Duration + 243, // 156: openshell.v1.UpdateConfigRequest.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 250, // 157: openshell.v1.UpdateConfigRequest.setting_value:type_name -> openshell.sandbox.v1.SettingValue + 137, // 158: openshell.v1.UpdateConfigRequest.merge_operations:type_name -> openshell.v1.PolicyMergeOperation + 237, // 159: openshell.v1.UpdateConfigRequest.annotations:type_name -> openshell.v1.UpdateConfigRequest.AnnotationsEntry + 246, // 160: openshell.v1.UpdateConfigRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 138, // 161: openshell.v1.PolicyMergeOperation.add_rule:type_name -> openshell.v1.AddNetworkRule + 139, // 162: openshell.v1.PolicyMergeOperation.remove_endpoint:type_name -> openshell.v1.RemoveNetworkEndpoint + 140, // 163: openshell.v1.PolicyMergeOperation.remove_rule:type_name -> openshell.v1.RemoveNetworkRule + 141, // 164: openshell.v1.PolicyMergeOperation.add_deny_rules:type_name -> openshell.v1.AddDenyRules + 142, // 165: openshell.v1.PolicyMergeOperation.add_allow_rules:type_name -> openshell.v1.AddAllowRules + 143, // 166: openshell.v1.PolicyMergeOperation.remove_binary:type_name -> openshell.v1.RemoveNetworkBinary + 251, // 167: openshell.v1.AddNetworkRule.rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 252, // 168: openshell.v1.AddDenyRules.deny_rules:type_name -> openshell.sandbox.v1.L7DenyRule + 253, // 169: openshell.v1.AddAllowRules.rules:type_name -> openshell.sandbox.v1.L7Rule + 238, // 170: openshell.v1.UpdateConfigResponse.annotations:type_name -> openshell.v1.UpdateConfigResponse.AnnotationsEntry + 246, // 171: openshell.v1.GetSandboxPolicyStatusRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 151, // 172: openshell.v1.GetSandboxPolicyStatusResponse.revision:type_name -> openshell.v1.SandboxPolicyRevision + 246, // 173: openshell.v1.ListSandboxPoliciesRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 151, // 174: openshell.v1.ListSandboxPoliciesResponse.revisions:type_name -> openshell.v1.SandboxPolicyRevision + 4, // 175: openshell.v1.ReportPolicyStatusRequest.status:type_name -> openshell.v1.PolicyStatus + 4, // 176: openshell.v1.SandboxPolicyRevision.status:type_name -> openshell.v1.PolicyStatus + 241, // 177: openshell.v1.SandboxPolicyRevision.created_time:type_name -> google.protobuf.Timestamp + 241, // 178: openshell.v1.SandboxPolicyRevision.loaded_time:type_name -> google.protobuf.Timestamp + 243, // 179: openshell.v1.SandboxPolicyRevision.policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 239, // 180: openshell.v1.SandboxPolicyRevision.provenance:type_name -> openshell.v1.SandboxPolicyRevision.ProvenanceEntry + 241, // 181: openshell.v1.GetSandboxLogsRequest.since_time:type_name -> google.protobuf.Timestamp + 246, // 182: openshell.v1.GetSandboxLogsRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 88, // 183: openshell.v1.PushSandboxLogsRequest.logs:type_name -> openshell.v1.SandboxLogLine + 88, // 184: openshell.v1.GetSandboxLogsResponse.logs:type_name -> openshell.v1.SandboxLogLine + 158, // 185: openshell.v1.SupervisorMessage.hello:type_name -> openshell.v1.SupervisorHello + 161, // 186: openshell.v1.SupervisorMessage.heartbeat:type_name -> openshell.v1.SupervisorHeartbeat + 172, // 187: openshell.v1.SupervisorMessage.relay_open_result:type_name -> openshell.v1.RelayOpenResult + 173, // 188: openshell.v1.SupervisorMessage.relay_close:type_name -> openshell.v1.RelayClose + 159, // 189: openshell.v1.GatewayMessage.session_accepted:type_name -> openshell.v1.SessionAccepted + 160, // 190: openshell.v1.GatewayMessage.session_rejected:type_name -> openshell.v1.SessionRejected + 162, // 191: openshell.v1.GatewayMessage.heartbeat:type_name -> openshell.v1.GatewayHeartbeat + 167, // 192: openshell.v1.GatewayMessage.relay_open:type_name -> openshell.v1.RelayOpen + 173, // 193: openshell.v1.GatewayMessage.relay_close:type_name -> openshell.v1.RelayClose + 245, // 194: openshell.v1.SessionAccepted.heartbeat_interval:type_name -> google.protobuf.Duration + 168, // 195: openshell.v1.RelayOpen.ssh:type_name -> openshell.v1.SshRelayTarget + 169, // 196: openshell.v1.RelayOpen.tcp:type_name -> openshell.v1.TcpRelayTarget + 170, // 197: openshell.v1.RelayFrame.init:type_name -> openshell.v1.RelayInit + 241, // 198: openshell.v1.DenialSummary.first_seen_time:type_name -> google.protobuf.Timestamp + 241, // 199: openshell.v1.DenialSummary.last_seen_time:type_name -> google.protobuf.Timestamp + 174, // 200: openshell.v1.DenialSummary.l7_request_samples:type_name -> openshell.v1.L7RequestSample + 176, // 201: openshell.v1.NetworkActivitySummary.denials_by_group:type_name -> openshell.v1.DenialGroupCount + 251, // 202: openshell.v1.PolicyChunk.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 241, // 203: openshell.v1.PolicyChunk.created_time:type_name -> google.protobuf.Timestamp + 241, // 204: openshell.v1.PolicyChunk.decided_time:type_name -> google.protobuf.Timestamp + 241, // 205: openshell.v1.PolicyChunk.first_seen_time:type_name -> google.protobuf.Timestamp + 241, // 206: openshell.v1.PolicyChunk.last_seen_time:type_name -> google.protobuf.Timestamp + 243, // 207: openshell.v1.PolicyChunk.current_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 243, // 208: openshell.v1.PolicyChunk.candidate_effective_policy:type_name -> openshell.sandbox.v1.SandboxPolicy + 175, // 209: openshell.v1.SubmitPolicyAnalysisRequest.summaries:type_name -> openshell.v1.DenialSummary + 178, // 210: openshell.v1.SubmitPolicyAnalysisRequest.proposed_chunks:type_name -> openshell.v1.PolicyChunk + 177, // 211: openshell.v1.SubmitPolicyAnalysisRequest.network_activity_summaries:type_name -> openshell.v1.NetworkActivitySummary + 246, // 212: openshell.v1.GetDraftPolicyRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 178, // 213: openshell.v1.GetDraftPolicyResponse.chunks:type_name -> openshell.v1.PolicyChunk + 241, // 214: openshell.v1.GetDraftPolicyResponse.last_analyzed_time:type_name -> google.protobuf.Timestamp + 246, // 215: openshell.v1.ApproveDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 216: openshell.v1.RejectDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 188, // 217: openshell.v1.ApproveAllDraftChunksRequest.approvals:type_name -> openshell.v1.DraftChunkApproval + 246, // 218: openshell.v1.ApproveAllDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 251, // 219: openshell.v1.EditDraftChunkRequest.proposed_rule:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 246, // 220: openshell.v1.EditDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 221: openshell.v1.UndoDraftChunkRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 222: openshell.v1.ClearDraftChunksRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 246, // 223: openshell.v1.GetDraftHistoryRequest.workspace_scope:type_name -> openshell.datamodel.v1.WorkspaceSelector + 241, // 224: openshell.v1.DraftHistoryEntry.event_time:type_name -> google.protobuf.Timestamp + 198, // 225: openshell.v1.GetDraftHistoryResponse.entries:type_name -> openshell.v1.DraftHistoryEntry + 240, // 226: openshell.v1.CreateWorkspaceRequest.labels:type_name -> openshell.v1.CreateWorkspaceRequest.LabelsEntry + 254, // 227: openshell.v1.CreateWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 254, // 228: openshell.v1.GetWorkspaceResponse.workspace:type_name -> openshell.datamodel.v1.Workspace + 254, // 229: openshell.v1.ListWorkspacesResponse.workspaces:type_name -> openshell.datamodel.v1.Workspace + 242, // 230: openshell.v1.WorkspaceMember.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 6, // 231: openshell.v1.WorkspaceMember.role:type_name -> openshell.v1.WorkspaceRole + 6, // 232: openshell.v1.AddWorkspaceMemberRequest.role:type_name -> openshell.v1.WorkspaceRole + 208, // 233: openshell.v1.AddWorkspaceMemberResponse.member:type_name -> openshell.v1.WorkspaceMember + 208, // 234: openshell.v1.ListWorkspaceMembersResponse.members:type_name -> openshell.v1.WorkspaceMember + 241, // 235: openshell.v1.ExtensionServiceCredential.expiration_time:type_name -> google.protobuf.Timestamp + 8, // 236: openshell.v1.EndpointObservation.result:type_name -> openshell.v1.EndpointResult + 216, // 237: openshell.v1.ReportEndpointStatusRequest.observations:type_name -> openshell.v1.EndpointObservation + 8, // 238: openshell.v1.EndpointStatus.last_result:type_name -> openshell.v1.EndpointResult + 241, // 239: openshell.v1.EndpointStatus.last_reported_time:type_name -> google.protobuf.Timestamp + 241, // 240: openshell.v1.UpdateProviderRequest.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 241, // 241: openshell.v1.GetSandboxProviderEnvironmentResponse.CredentialExpirationTimesEntry.value:type_name -> google.protobuf.Timestamp + 104, // 242: openshell.v1.GetSandboxProviderEnvironmentResponse.DynamicCredentialsEntry.value:type_name -> openshell.v1.ProviderProfileCredential + 132, // 243: openshell.v1.GetSandboxProviderEnvironmentResponse.StaticCredentialBindingsEntry.value:type_name -> openshell.v1.StaticCredentialBinding + 13, // 244: openshell.v1.OpenShell.Health:input_type -> openshell.v1.HealthRequest + 15, // 245: openshell.v1.OpenShell.GetCurrentUser:input_type -> openshell.v1.GetCurrentUserRequest + 17, // 246: openshell.v1.OpenShell.GetGatewayInfo:input_type -> openshell.v1.GetGatewayInfoRequest + 40, // 247: openshell.v1.OpenShell.CreateSandbox:input_type -> openshell.v1.CreateSandboxRequest + 48, // 248: openshell.v1.OpenShell.BeginRootfsTarStaging:input_type -> openshell.v1.BeginRootfsTarStagingRequest + 50, // 249: openshell.v1.OpenShell.GetSandbox:input_type -> openshell.v1.GetSandboxRequest + 51, // 250: openshell.v1.OpenShell.ListSandboxes:input_type -> openshell.v1.ListSandboxesRequest + 41, // 251: openshell.v1.OpenShell.CreateSandboxTemplate:input_type -> openshell.v1.CreateSandboxTemplateRequest + 42, // 252: openshell.v1.OpenShell.GetSandboxTemplate:input_type -> openshell.v1.GetSandboxTemplateRequest + 43, // 253: openshell.v1.OpenShell.ListSandboxTemplates:input_type -> openshell.v1.ListSandboxTemplatesRequest + 44, // 254: openshell.v1.OpenShell.DeleteSandboxTemplate:input_type -> openshell.v1.DeleteSandboxTemplateRequest + 52, // 255: openshell.v1.OpenShell.ListSandboxProviders:input_type -> openshell.v1.ListSandboxProvidersRequest + 53, // 256: openshell.v1.OpenShell.AttachSandboxProvider:input_type -> openshell.v1.AttachSandboxProviderRequest + 54, // 257: openshell.v1.OpenShell.DetachSandboxProvider:input_type -> openshell.v1.DetachSandboxProviderRequest + 55, // 258: openshell.v1.OpenShell.DeleteSandbox:input_type -> openshell.v1.DeleteSandboxRequest + 56, // 259: openshell.v1.OpenShell.StopSandbox:input_type -> openshell.v1.StopSandboxRequest + 57, // 260: openshell.v1.OpenShell.StartSandbox:input_type -> openshell.v1.StartSandboxRequest + 64, // 261: openshell.v1.OpenShell.CreateSshSession:input_type -> openshell.v1.CreateSshSessionRequest + 66, // 262: openshell.v1.OpenShell.ExposeService:input_type -> openshell.v1.ExposeServiceRequest + 67, // 263: openshell.v1.OpenShell.GetService:input_type -> openshell.v1.GetServiceRequest + 68, // 264: openshell.v1.OpenShell.ListServices:input_type -> openshell.v1.ListServicesRequest + 70, // 265: openshell.v1.OpenShell.DeleteService:input_type -> openshell.v1.DeleteServiceRequest + 74, // 266: openshell.v1.OpenShell.RevokeSshSession:input_type -> openshell.v1.RevokeSshSessionRequest + 76, // 267: openshell.v1.OpenShell.ExecSandbox:input_type -> openshell.v1.ExecSandboxRequest + 82, // 268: openshell.v1.OpenShell.ForwardTcp:input_type -> openshell.v1.TcpForwardFrame + 83, // 269: openshell.v1.OpenShell.ExecSandboxInteractive:input_type -> openshell.v1.ExecSandboxInput + 90, // 270: openshell.v1.OpenShell.CreateProvider:input_type -> openshell.v1.CreateProviderRequest + 91, // 271: openshell.v1.OpenShell.GetProvider:input_type -> openshell.v1.GetProviderRequest + 92, // 272: openshell.v1.OpenShell.ListProviders:input_type -> openshell.v1.ListProvidersRequest + 97, // 273: openshell.v1.OpenShell.ListProviderProfiles:input_type -> openshell.v1.ListProviderProfilesRequest + 98, // 274: openshell.v1.OpenShell.GetProviderProfile:input_type -> openshell.v1.GetProviderProfileRequest + 121, // 275: openshell.v1.OpenShell.ImportProviderProfiles:input_type -> openshell.v1.ImportProviderProfilesRequest + 123, // 276: openshell.v1.OpenShell.UpdateProviderProfiles:input_type -> openshell.v1.UpdateProviderProfilesRequest + 125, // 277: openshell.v1.OpenShell.LintProviderProfiles:input_type -> openshell.v1.LintProviderProfilesRequest + 93, // 278: openshell.v1.OpenShell.UpdateProvider:input_type -> openshell.v1.UpdateProviderRequest + 110, // 279: openshell.v1.OpenShell.GetProviderRefreshStatus:input_type -> openshell.v1.GetProviderRefreshStatusRequest + 112, // 280: openshell.v1.OpenShell.ConfigureProviderRefresh:input_type -> openshell.v1.ConfigureProviderRefreshRequest + 114, // 281: openshell.v1.OpenShell.RotateProviderCredential:input_type -> openshell.v1.RotateProviderCredentialRequest + 116, // 282: openshell.v1.OpenShell.DeleteProviderRefresh:input_type -> openshell.v1.DeleteProviderRefreshRequest + 94, // 283: openshell.v1.OpenShell.DeleteProvider:input_type -> openshell.v1.DeleteProviderRequest + 128, // 284: openshell.v1.OpenShell.DeleteProviderProfile:input_type -> openshell.v1.DeleteProviderProfileRequest + 255, // 285: openshell.v1.OpenShell.GetSandboxConfig:input_type -> openshell.sandbox.v1.GetSandboxConfigRequest + 256, // 286: openshell.v1.OpenShell.GetGatewayConfig:input_type -> openshell.sandbox.v1.GetGatewayConfigRequest + 136, // 287: openshell.v1.OpenShell.UpdateConfig:input_type -> openshell.v1.UpdateConfigRequest + 145, // 288: openshell.v1.OpenShell.GetSandboxPolicyStatus:input_type -> openshell.v1.GetSandboxPolicyStatusRequest + 147, // 289: openshell.v1.OpenShell.ListSandboxPolicies:input_type -> openshell.v1.ListSandboxPoliciesRequest + 149, // 290: openshell.v1.OpenShell.ReportPolicyStatus:input_type -> openshell.v1.ReportPolicyStatusRequest + 217, // 291: openshell.v1.OpenShell.ReportEndpointStatus:input_type -> openshell.v1.ReportEndpointStatusRequest + 130, // 292: openshell.v1.OpenShell.GetSandboxProviderEnvironment:input_type -> openshell.v1.GetSandboxProviderEnvironmentRequest + 134, // 293: openshell.v1.OpenShell.ExchangeProviderSubjectToken:input_type -> openshell.v1.ExchangeProviderSubjectTokenRequest + 152, // 294: openshell.v1.OpenShell.GetSandboxLogs:input_type -> openshell.v1.GetSandboxLogsRequest + 153, // 295: openshell.v1.OpenShell.PushSandboxLogs:input_type -> openshell.v1.PushSandboxLogsRequest + 156, // 296: openshell.v1.OpenShell.ConnectSupervisor:input_type -> openshell.v1.SupervisorMessage + 163, // 297: openshell.v1.OpenShell.ReportMainProcessExit:input_type -> openshell.v1.ReportMainProcessExitRequest + 165, // 298: openshell.v1.OpenShell.FinalizeMainProcessExit:input_type -> openshell.v1.FinalizeMainProcessExitRequest + 171, // 299: openshell.v1.OpenShell.RelayStream:input_type -> openshell.v1.RelayFrame + 86, // 300: openshell.v1.OpenShell.WatchSandbox:input_type -> openshell.v1.WatchSandboxRequest + 180, // 301: openshell.v1.OpenShell.SubmitPolicyAnalysis:input_type -> openshell.v1.SubmitPolicyAnalysisRequest + 182, // 302: openshell.v1.OpenShell.GetDraftPolicy:input_type -> openshell.v1.GetDraftPolicyRequest + 184, // 303: openshell.v1.OpenShell.ApproveDraftChunk:input_type -> openshell.v1.ApproveDraftChunkRequest + 186, // 304: openshell.v1.OpenShell.RejectDraftChunk:input_type -> openshell.v1.RejectDraftChunkRequest + 189, // 305: openshell.v1.OpenShell.ApproveAllDraftChunks:input_type -> openshell.v1.ApproveAllDraftChunksRequest + 191, // 306: openshell.v1.OpenShell.EditDraftChunk:input_type -> openshell.v1.EditDraftChunkRequest + 193, // 307: openshell.v1.OpenShell.UndoDraftChunk:input_type -> openshell.v1.UndoDraftChunkRequest + 195, // 308: openshell.v1.OpenShell.ClearDraftChunks:input_type -> openshell.v1.ClearDraftChunksRequest + 197, // 309: openshell.v1.OpenShell.GetDraftHistory:input_type -> openshell.v1.GetDraftHistoryRequest + 9, // 310: openshell.v1.OpenShell.IssueSandboxToken:input_type -> openshell.v1.IssueSandboxTokenRequest + 11, // 311: openshell.v1.OpenShell.RefreshSandboxToken:input_type -> openshell.v1.RefreshSandboxTokenRequest + 200, // 312: openshell.v1.OpenShell.CreateWorkspace:input_type -> openshell.v1.CreateWorkspaceRequest + 202, // 313: openshell.v1.OpenShell.GetWorkspace:input_type -> openshell.v1.GetWorkspaceRequest + 204, // 314: openshell.v1.OpenShell.ListWorkspaces:input_type -> openshell.v1.ListWorkspacesRequest + 206, // 315: openshell.v1.OpenShell.DeleteWorkspace:input_type -> openshell.v1.DeleteWorkspaceRequest + 209, // 316: openshell.v1.OpenShell.AddWorkspaceMember:input_type -> openshell.v1.AddWorkspaceMemberRequest + 211, // 317: openshell.v1.OpenShell.RemoveWorkspaceMember:input_type -> openshell.v1.RemoveWorkspaceMemberRequest + 213, // 318: openshell.v1.OpenShell.ListWorkspaceMembers:input_type -> openshell.v1.ListWorkspaceMembersRequest + 14, // 319: openshell.v1.OpenShell.Health:output_type -> openshell.v1.HealthResponse + 16, // 320: openshell.v1.OpenShell.GetCurrentUser:output_type -> openshell.v1.GetCurrentUserResponse + 18, // 321: openshell.v1.OpenShell.GetGatewayInfo:output_type -> openshell.v1.GetGatewayInfoResponse + 58, // 322: openshell.v1.OpenShell.CreateSandbox:output_type -> openshell.v1.SandboxResponse + 49, // 323: openshell.v1.OpenShell.BeginRootfsTarStaging:output_type -> openshell.v1.BeginRootfsTarStagingResponse + 58, // 324: openshell.v1.OpenShell.GetSandbox:output_type -> openshell.v1.SandboxResponse + 59, // 325: openshell.v1.OpenShell.ListSandboxes:output_type -> openshell.v1.ListSandboxesResponse + 45, // 326: openshell.v1.OpenShell.CreateSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 45, // 327: openshell.v1.OpenShell.GetSandboxTemplate:output_type -> openshell.v1.SandboxTemplateResponse + 46, // 328: openshell.v1.OpenShell.ListSandboxTemplates:output_type -> openshell.v1.ListSandboxTemplatesResponse + 47, // 329: openshell.v1.OpenShell.DeleteSandboxTemplate:output_type -> openshell.v1.DeleteSandboxTemplateResponse + 60, // 330: openshell.v1.OpenShell.ListSandboxProviders:output_type -> openshell.v1.ListSandboxProvidersResponse + 61, // 331: openshell.v1.OpenShell.AttachSandboxProvider:output_type -> openshell.v1.AttachSandboxProviderResponse + 62, // 332: openshell.v1.OpenShell.DetachSandboxProvider:output_type -> openshell.v1.DetachSandboxProviderResponse + 63, // 333: openshell.v1.OpenShell.DeleteSandbox:output_type -> openshell.v1.DeleteSandboxResponse + 58, // 334: openshell.v1.OpenShell.StopSandbox:output_type -> openshell.v1.SandboxResponse + 58, // 335: openshell.v1.OpenShell.StartSandbox:output_type -> openshell.v1.SandboxResponse + 65, // 336: openshell.v1.OpenShell.CreateSshSession:output_type -> openshell.v1.CreateSshSessionResponse + 73, // 337: openshell.v1.OpenShell.ExposeService:output_type -> openshell.v1.ServiceEndpointResponse + 73, // 338: openshell.v1.OpenShell.GetService:output_type -> openshell.v1.ServiceEndpointResponse + 69, // 339: openshell.v1.OpenShell.ListServices:output_type -> openshell.v1.ListServicesResponse + 71, // 340: openshell.v1.OpenShell.DeleteService:output_type -> openshell.v1.DeleteServiceResponse + 75, // 341: openshell.v1.OpenShell.RevokeSshSession:output_type -> openshell.v1.RevokeSshSessionResponse + 80, // 342: openshell.v1.OpenShell.ExecSandbox:output_type -> openshell.v1.ExecSandboxEvent + 82, // 343: openshell.v1.OpenShell.ForwardTcp:output_type -> openshell.v1.TcpForwardFrame + 80, // 344: openshell.v1.OpenShell.ExecSandboxInteractive:output_type -> openshell.v1.ExecSandboxEvent + 95, // 345: openshell.v1.OpenShell.CreateProvider:output_type -> openshell.v1.ProviderResponse + 95, // 346: openshell.v1.OpenShell.GetProvider:output_type -> openshell.v1.ProviderResponse + 96, // 347: openshell.v1.OpenShell.ListProviders:output_type -> openshell.v1.ListProvidersResponse + 120, // 348: openshell.v1.OpenShell.ListProviderProfiles:output_type -> openshell.v1.ListProviderProfilesResponse + 119, // 349: openshell.v1.OpenShell.GetProviderProfile:output_type -> openshell.v1.ProviderProfileResponse + 122, // 350: openshell.v1.OpenShell.ImportProviderProfiles:output_type -> openshell.v1.ImportProviderProfilesResponse + 124, // 351: openshell.v1.OpenShell.UpdateProviderProfiles:output_type -> openshell.v1.UpdateProviderProfilesResponse + 126, // 352: openshell.v1.OpenShell.LintProviderProfiles:output_type -> openshell.v1.LintProviderProfilesResponse + 95, // 353: openshell.v1.OpenShell.UpdateProvider:output_type -> openshell.v1.ProviderResponse + 111, // 354: openshell.v1.OpenShell.GetProviderRefreshStatus:output_type -> openshell.v1.GetProviderRefreshStatusResponse + 113, // 355: openshell.v1.OpenShell.ConfigureProviderRefresh:output_type -> openshell.v1.ConfigureProviderRefreshResponse + 115, // 356: openshell.v1.OpenShell.RotateProviderCredential:output_type -> openshell.v1.RotateProviderCredentialResponse + 117, // 357: openshell.v1.OpenShell.DeleteProviderRefresh:output_type -> openshell.v1.DeleteProviderRefreshResponse + 127, // 358: openshell.v1.OpenShell.DeleteProvider:output_type -> openshell.v1.DeleteProviderResponse + 129, // 359: openshell.v1.OpenShell.DeleteProviderProfile:output_type -> openshell.v1.DeleteProviderProfileResponse + 257, // 360: openshell.v1.OpenShell.GetSandboxConfig:output_type -> openshell.sandbox.v1.GetSandboxConfigResponse + 258, // 361: openshell.v1.OpenShell.GetGatewayConfig:output_type -> openshell.sandbox.v1.GetGatewayConfigResponse + 144, // 362: openshell.v1.OpenShell.UpdateConfig:output_type -> openshell.v1.UpdateConfigResponse + 146, // 363: openshell.v1.OpenShell.GetSandboxPolicyStatus:output_type -> openshell.v1.GetSandboxPolicyStatusResponse + 148, // 364: openshell.v1.OpenShell.ListSandboxPolicies:output_type -> openshell.v1.ListSandboxPoliciesResponse + 150, // 365: openshell.v1.OpenShell.ReportPolicyStatus:output_type -> openshell.v1.ReportPolicyStatusResponse + 218, // 366: openshell.v1.OpenShell.ReportEndpointStatus:output_type -> openshell.v1.ReportEndpointStatusResponse + 133, // 367: openshell.v1.OpenShell.GetSandboxProviderEnvironment:output_type -> openshell.v1.GetSandboxProviderEnvironmentResponse + 135, // 368: openshell.v1.OpenShell.ExchangeProviderSubjectToken:output_type -> openshell.v1.ExchangeProviderSubjectTokenResponse + 155, // 369: openshell.v1.OpenShell.GetSandboxLogs:output_type -> openshell.v1.GetSandboxLogsResponse + 154, // 370: openshell.v1.OpenShell.PushSandboxLogs:output_type -> openshell.v1.PushSandboxLogsResponse + 157, // 371: openshell.v1.OpenShell.ConnectSupervisor:output_type -> openshell.v1.GatewayMessage + 164, // 372: openshell.v1.OpenShell.ReportMainProcessExit:output_type -> openshell.v1.ReportMainProcessExitResponse + 166, // 373: openshell.v1.OpenShell.FinalizeMainProcessExit:output_type -> openshell.v1.FinalizeMainProcessExitResponse + 171, // 374: openshell.v1.OpenShell.RelayStream:output_type -> openshell.v1.RelayFrame + 87, // 375: openshell.v1.OpenShell.WatchSandbox:output_type -> openshell.v1.SandboxStreamEvent + 181, // 376: openshell.v1.OpenShell.SubmitPolicyAnalysis:output_type -> openshell.v1.SubmitPolicyAnalysisResponse + 183, // 377: openshell.v1.OpenShell.GetDraftPolicy:output_type -> openshell.v1.GetDraftPolicyResponse + 185, // 378: openshell.v1.OpenShell.ApproveDraftChunk:output_type -> openshell.v1.ApproveDraftChunkResponse + 187, // 379: openshell.v1.OpenShell.RejectDraftChunk:output_type -> openshell.v1.RejectDraftChunkResponse + 190, // 380: openshell.v1.OpenShell.ApproveAllDraftChunks:output_type -> openshell.v1.ApproveAllDraftChunksResponse + 192, // 381: openshell.v1.OpenShell.EditDraftChunk:output_type -> openshell.v1.EditDraftChunkResponse + 194, // 382: openshell.v1.OpenShell.UndoDraftChunk:output_type -> openshell.v1.UndoDraftChunkResponse + 196, // 383: openshell.v1.OpenShell.ClearDraftChunks:output_type -> openshell.v1.ClearDraftChunksResponse + 199, // 384: openshell.v1.OpenShell.GetDraftHistory:output_type -> openshell.v1.GetDraftHistoryResponse + 10, // 385: openshell.v1.OpenShell.IssueSandboxToken:output_type -> openshell.v1.IssueSandboxTokenResponse + 12, // 386: openshell.v1.OpenShell.RefreshSandboxToken:output_type -> openshell.v1.RefreshSandboxTokenResponse + 201, // 387: openshell.v1.OpenShell.CreateWorkspace:output_type -> openshell.v1.CreateWorkspaceResponse + 203, // 388: openshell.v1.OpenShell.GetWorkspace:output_type -> openshell.v1.GetWorkspaceResponse + 205, // 389: openshell.v1.OpenShell.ListWorkspaces:output_type -> openshell.v1.ListWorkspacesResponse + 207, // 390: openshell.v1.OpenShell.DeleteWorkspace:output_type -> openshell.v1.DeleteWorkspaceResponse + 210, // 391: openshell.v1.OpenShell.AddWorkspaceMember:output_type -> openshell.v1.AddWorkspaceMemberResponse + 212, // 392: openshell.v1.OpenShell.RemoveWorkspaceMember:output_type -> openshell.v1.RemoveWorkspaceMemberResponse + 214, // 393: openshell.v1.OpenShell.ListWorkspaceMembers:output_type -> openshell.v1.ListWorkspaceMembersResponse + 319, // [319:394] is the sub-list for method output_type + 244, // [244:319] is the sub-list for method input_type + 244, // [244:244] is the sub-list for extension type_name + 244, // [244:244] is the sub-list for extension extendee + 0, // [0:244] is the sub-list for field type_name } func init() { file_openshell_proto_init() } @@ -16681,7 +16726,6 @@ func file_openshell_proto_init() { (*SandboxStreamEvent_Warning)(nil), (*SandboxStreamEvent_DraftPolicyUpdate)(nil), } - file_openshell_proto_msgTypes[103].OneofWrappers = []any{} file_openshell_proto_msgTypes[128].OneofWrappers = []any{ (*PolicyMergeOperation_AddRule)(nil), (*PolicyMergeOperation_RemoveEndpoint)(nil), diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 17d8909f83..6cbbd5fbda 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -12,6 +12,7 @@ package sandboxv1 import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" structpb "google.golang.org/protobuf/types/known/structpb" reflect "reflect" sync "sync" @@ -1954,10 +1955,9 @@ type SupervisorMiddlewareService struct { // Operator-owned logical payload limit applied to every binding exposed by // the service. This caps HTTP bodies and complete WebSocket messages. MaxPayloadBytes uint64 `protobuf:"varint,3,opt,name=max_payload_bytes,json=maxPayloadBytes,proto3" json:"max_payload_bytes,omitempty"` - // Default RPC timeout for this service. Empty uses the platform default of - // 500ms. Values use an integer with an `ms` or `s` suffix and must be - // between 10ms and 30s. - Timeout string `protobuf:"bytes,4,opt,name=timeout,proto3" json:"timeout,omitempty"` + // Default RPC timeout for this service. Absence uses the platform default of + // 500ms. Values must be between 10ms and 30s. + RequestTimeout *durationpb.Duration `protobuf:"bytes,104,opt,name=request_timeout,json=requestTimeout,proto3" json:"request_timeout,omitempty"` // PEM-encoded trust roots loaded by the gateway from the operator-configured // tls_ca_cert_path. Empty uses the platform trust store. TlsCaCertPem []byte `protobuf:"bytes,5,opt,name=tls_ca_cert_pem,json=tlsCaCertPem,proto3" json:"tls_ca_cert_pem,omitempty"` @@ -2025,11 +2025,11 @@ func (x *SupervisorMiddlewareService) GetMaxPayloadBytes() uint64 { return 0 } -func (x *SupervisorMiddlewareService) GetTimeout() string { +func (x *SupervisorMiddlewareService) GetRequestTimeout() *durationpb.Duration { if x != nil { - return x.Timeout + return x.RequestTimeout } - return "" + return nil } func (x *SupervisorMiddlewareService) GetTlsCaCertPem() []byte { @@ -2057,7 +2057,7 @@ var File_sandbox_proto protoreflect.FileDescriptor const file_sandbox_proto_rawDesc = "" + "\n" + - "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xa8\x05\n" + + "\rsandbox.proto\x12\x14openshell.sandbox.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\"\xa8\x05\n" + "\rSandboxPolicy\x12\x18\n" + "\aversion\x18\x01 \x01(\rR\aversion\x12F\n" + "\n" + @@ -2225,15 +2225,15 @@ const file_sandbox_proto_rawDesc = "" + " extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x1ac\n" + "\rSettingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + - "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\x99\x02\n" + + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\xd2\x02\n" + "\x1bSupervisorMiddlewareService\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12#\n" + "\rgrpc_endpoint\x18\x02 \x01(\tR\fgrpcEndpoint\x12*\n" + - "\x11max_payload_bytes\x18\x03 \x01(\x04R\x0fmaxPayloadBytes\x12\x18\n" + - "\atimeout\x18\x04 \x01(\tR\atimeout\x12%\n" + + "\x11max_payload_bytes\x18\x03 \x01(\x04R\x0fmaxPayloadBytes\x12B\n" + + "\x0frequest_timeout\x18h \x01(\v2\x19.google.protobuf.DurationR\x0erequestTimeout\x12%\n" + "\x0ftls_ca_cert_pem\x18\x05 \x01(\fR\ftlsCaCertPem\x12\x1a\n" + "\baudience\x18\x06 \x01(\tR\baudience\x128\n" + - "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransport*b\n" + + "\x18allow_insecure_transport\x18\a \x01(\bR\x16allowInsecureTransportJ\x04\b\x04\x10\x05R\atimeout*b\n" + "\fSettingScope\x12\x1d\n" + "\x19SETTING_SCOPE_UNSPECIFIED\x10\x00\x12\x19\n" + "\x15SETTING_SCOPE_SANDBOX\x10\x01\x12\x18\n" + @@ -2293,6 +2293,7 @@ var file_sandbox_proto_goTypes = []any{ nil, // 32: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry nil, // 33: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry (*structpb.Struct)(nil), // 34: google.protobuf.Struct + (*durationpb.Duration)(nil), // 35: google.protobuf.Duration } var file_sandbox_proto_depIdxs = []int32{ 3, // 0: openshell.sandbox.v1.SandboxPolicy.filesystem:type_name -> openshell.sandbox.v1.FilesystemPolicy @@ -2321,20 +2322,21 @@ var file_sandbox_proto_depIdxs = []int32{ 33, // 23: openshell.sandbox.v1.GetSandboxConfigResponse.settings:type_name -> openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry 1, // 24: openshell.sandbox.v1.GetSandboxConfigResponse.policy_source:type_name -> openshell.sandbox.v1.PolicySource 24, // 25: openshell.sandbox.v1.GetSandboxConfigResponse.supervisor_middleware_services:type_name -> openshell.sandbox.v1.SupervisorMiddlewareService - 6, // 26: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule - 7, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig - 12, // 28: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation - 16, // 29: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 30: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 31: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 16, // 32: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher - 21, // 33: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue - 22, // 34: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting - 35, // [35:35] is the sub-list for method output_type - 35, // [35:35] is the sub-list for method input_type - 35, // [35:35] is the sub-list for extension type_name - 35, // [35:35] is the sub-list for extension extendee - 0, // [0:35] is the sub-list for field type_name + 35, // 26: openshell.sandbox.v1.SupervisorMiddlewareService.request_timeout:type_name -> google.protobuf.Duration + 6, // 27: openshell.sandbox.v1.SandboxPolicy.NetworkPoliciesEntry.value:type_name -> openshell.sandbox.v1.NetworkPolicyRule + 7, // 28: openshell.sandbox.v1.SandboxPolicy.NetworkMiddlewaresEntry.value:type_name -> openshell.sandbox.v1.NetworkMiddlewareConfig + 12, // 29: openshell.sandbox.v1.NetworkEndpoint.GraphqlPersistedQueriesEntry.value:type_name -> openshell.sandbox.v1.GraphqlOperation + 16, // 30: openshell.sandbox.v1.L7DenyRule.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 16, // 31: openshell.sandbox.v1.L7DenyRule.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 16, // 32: openshell.sandbox.v1.L7Allow.QueryEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 16, // 33: openshell.sandbox.v1.L7Allow.ParamsEntry.value:type_name -> openshell.sandbox.v1.L7QueryMatcher + 21, // 34: openshell.sandbox.v1.GetGatewayConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.SettingValue + 22, // 35: openshell.sandbox.v1.GetSandboxConfigResponse.SettingsEntry.value:type_name -> openshell.sandbox.v1.EffectiveSetting + 36, // [36:36] is the sub-list for method output_type + 36, // [36:36] is the sub-list for method input_type + 36, // [36:36] is the sub-list for extension type_name + 36, // [36:36] is the sub-list for extension extendee + 0, // [0:36] is the sub-list for field type_name } func init() { file_sandbox_proto_init() } diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index d440c75108..48330d0bdb 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -72,7 +72,12 @@ function selectsAllWorkspaces(req: ScopedRequest): boolean { describe('exec / execStream', () => { it('resolves the id via get, frames tty:false, and buffers the result (backward compat)', async () => { - let execReq: { sandboxId?: string; tty?: boolean; command?: string[] } = {}; + let execReq: { + sandboxId?: string; + tty?: boolean; + command?: string[]; + executionTimeout?: { seconds: bigint; nanos: number }; + } = {}; const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id-1'), // eslint-disable-next-line require-yield @@ -89,12 +94,27 @@ describe('exec / execStream', () => { expect(execReq.sandboxId).toBe('sb-id-1'); expect(execReq.tty).toBe(false); expect(execReq.command).toEqual(['/bin/sh', '-c', 'echo hi']); + expect(execReq.executionTimeout).toBeUndefined(); expect(result.exitCode).toBe(3); expect(result.stdout.toString()).toBe('hello world'); expect(result.stderr.toString()).toBe('warn'); expect(Buffer.isBuffer(result.stdout)).toBe(true); }); + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid timeoutSecs %s', async (timeoutSecs) => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + + await expect(sandbox.exec('sb', ['true'], { timeoutSecs })).rejects.toThrow( + 'timeoutSecs must be a finite, non-negative number', + ); + }); + it('execStream yields incremental chunks then a terminal exit event', async () => { const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id-1'), @@ -1129,7 +1149,7 @@ describe('ssh sessions', () => { gatewayPort: 8443, gatewayScheme: 'https', hostKeyFingerprint: 'SHA256:abc', - expiresAtMs: 1730000000000n, + expirationTime: { seconds: 1730000000n, nanos: 0 }, }), }); const session = await withExpiry.createSshSession('sb'); @@ -1152,7 +1172,7 @@ describe('ssh sessions', () => { gatewayPort: 80, gatewayScheme: 'http', hostKeyFingerprint: '', - expiresAtMs: 0n, + expirationTime: undefined, }), }); const bare = await noExpiry.createSshSession('sb'); @@ -1173,7 +1193,7 @@ describe('ssh sessions', () => { gatewayPort: 8443, gatewayScheme: 'https', hostKeyFingerprint: 'SHA256:abc', - expiresAtMs: 0n, + expirationTime: undefined, }; const cases: Array> = [ { ...base, sandboxId: 'different-sandbox' }, @@ -1207,7 +1227,7 @@ describe('ssh sessions', () => { gatewayPort: 443, gatewayScheme: 'https', hostKeyFingerprint: '', - expiresAtMs: 0n, + expirationTime: undefined, }), }); await expect(sandbox.createSshSession('sb')).resolves.toMatchObject({ gatewayHost }); @@ -1231,7 +1251,7 @@ describe('forward', () => { gatewayPort: 443, gatewayScheme: 'https', hostKeyFingerprint: '', - expiresAtMs: 0n, + expirationTime: undefined, }; }, revokeSshSession: (req) => { @@ -1314,7 +1334,7 @@ describe('forward', () => { gatewayPort: 443, gatewayScheme: 'https', hostKeyFingerprint: '', - expiresAtMs: 0n, + expirationTime: undefined, }), revokeSshSession: () => ({ revoked: true }), // Ignore inbound frames; just blast a large, verifiable byte stream back. @@ -1375,7 +1395,7 @@ describe('forward', () => { gatewayPort: 443, gatewayScheme: 'https', hostKeyFingerprint: '', - expiresAtMs: 0n, + expirationTime: undefined, }; }, // biome-ignore lint/correctness/useYield: the socket is reset before any frame is relayed @@ -1445,7 +1465,7 @@ describe('forward', () => { gatewayPort: 443, gatewayScheme: 'https', hostKeyFingerprint: '', - expiresAtMs: 0n, + expirationTime: undefined, }), forwardTcp: async function* (_requests, ctx) { streamStarted(); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 37f145c4c3..144a01e642 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -14,6 +14,7 @@ import type { AddressInfo } from 'node:net'; import * as net from 'node:net'; import type { MessageInitShape } from '@bufbuild/protobuf'; +import { durationFromMs } from '@bufbuild/protobuf/wkt'; import { type CallOptions, type Client, createClient, type Transport } from '@connectrpc/connect'; import { errorCode, fromConnect, SdkError } from './errors.js'; import type { Provider, WorkspaceSelectorSchema } from './gen/datamodel_pb.js'; @@ -32,6 +33,19 @@ import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValue import { validateSshResponse } from './ssh-validate.js'; import { buildTransport, type ConnectOptions } from './transport.js'; +function durationFromSeconds(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0) { + throw new RangeError('timeoutSecs must be a finite, non-negative number'); + } + return seconds === 0 ? undefined : durationFromMs(seconds * 1000); +} + +function timestampMillis(timestamp: { seconds: bigint; nanos: number } | undefined): string | undefined { + if (!timestamp) return undefined; + const millis = timestamp.seconds * 1000n + BigInt(Math.trunc(timestamp.nanos / 1_000_000)); + return millis === 0n ? undefined : millis.toString(); +} + // Generated protobuf message shapes that callers need to populate or round-trip // directly. Re-export these rather than re-curating parallel surfaces. export type { @@ -967,7 +981,7 @@ export class SandboxClient { command, workdir: options?.workdir ?? '', environment: options?.environment ?? {}, - timeoutSeconds: options?.timeoutSecs ?? 0, + executionTimeout: durationFromSeconds(options?.timeoutSecs ?? 0), stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), tty: false, noLoginShell: options?.noLoginShell ?? false, @@ -1050,7 +1064,7 @@ export class SandboxClient { command, workdir: options?.workdir ?? '', environment: options?.environment ?? {}, - timeoutSeconds: options?.timeoutSecs ?? 0, + executionTimeout: durationFromSeconds(options?.timeoutSecs ?? 0), stdin: new Uint8Array(), tty: options?.tty ?? true, cols: options?.cols ?? 0, @@ -1350,7 +1364,7 @@ export class SandboxClient { gatewayPort: resp.gatewayPort, gatewayScheme: resp.gatewayScheme, ...(resp.hostKeyFingerprint ? { hostKeyFingerprint: resp.hostKeyFingerprint } : {}), - ...(resp.expiresAtMs !== 0n ? { expiresAtMs: resp.expiresAtMs.toString() } : {}), + ...(timestampMillis(resp.expirationTime) ? { expiresAtMs: timestampMillis(resp.expirationTime) } : {}), }; } catch (e) { throw e instanceof SdkError ? e : fromConnect(e); diff --git a/sdk/typescript/src/raw.test.ts b/sdk/typescript/src/raw.test.ts index e8ece7711c..0ef0735d0e 100644 --- a/sdk/typescript/src/raw.test.ts +++ b/sdk/typescript/src/raw.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessageInitShape } from '@bufbuild/protobuf'; +import { timestampFromDate } from '@bufbuild/protobuf/wkt'; import { createRouterTransport } from '@connectrpc/connect'; import { describe, expect, it } from 'vitest'; import { SandboxClient } from './index.js'; @@ -17,6 +18,7 @@ describe('raw sandbox endpoint status', () => { ]; let lastResult = EndpointResult.TRANSPORT_FAILED; const reportedAt = '2026-09-11T10:00:01Z'; + const reportedTime = timestampFromDate(new Date(reportedAt)); const readyCondition = { type: 'Ready', status: 'True', reason: 'Ready', message: 'Sandbox is ready' }; const sandbox = new SandboxClient( createRouterTransport( @@ -33,7 +35,9 @@ describe('raw sandbox endpoint status', () => { endpointStatuses: endpoints.map((endpoint) => ({ ...endpoint, lastResult, - lastReportedAt: lastResult === EndpointResult.NO_OBSERVED_EXCHANGE ? '' : reportedAt, + ...(lastResult === EndpointResult.NO_OBSERVED_EXCHANGE + ? {} + : { lastReportedTime: reportedTime }), })), conditions: [readyCondition], }, @@ -65,7 +69,7 @@ describe('raw sandbox endpoint status', () => { endpoints.map((endpoint) => ({ ...endpoint, lastResult: result, - lastReportedAt: result === EndpointResult.NO_OBSERVED_EXCHANGE ? '' : reportedAt, + ...(result === EndpointResult.NO_OBSERVED_EXCHANGE ? {} : { lastReportedTime: reportedTime }), })), ); }