From 00f1bfdabf11d8cbf3ea8a3d79b45480164c29e9 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Fri, 12 Jun 2026 17:17:10 -0400 Subject: [PATCH] feat(providersv2): inject static auth headers from v2 provider profiles Signed-off-by: Calum Murray --- .../src/l7/token_grant_injection.rs | 455 +++++++++++++++--- crates/openshell-server/src/grpc/policy.rs | 19 +- crates/openshell-server/src/grpc/provider.rs | 156 +++++- proto/openshell.proto | 7 +- 4 files changed, 537 insertions(+), 100 deletions(-) diff --git a/crates/openshell-sandbox/src/l7/token_grant_injection.rs b/crates/openshell-sandbox/src/l7/token_grant_injection.rs index fd803ad2f..590b64a88 100644 --- a/crates/openshell-sandbox/src/l7/token_grant_injection.rs +++ b/crates/openshell-sandbox/src/l7/token_grant_injection.rs @@ -65,95 +65,189 @@ pub fn default_resolver() -> Arc { /// Checks for endpoint-bound token grant credentials and injects an /// Authorization header before forwarding the request upstream. pub async fn inject_if_needed(req: L7Request, ctx: &L7EvalContext) -> Result { - let request_path = req.target.split('?').next().unwrap_or(req.target.as_str()); - let token_grant_credential = ctx.dynamic_credentials.as_ref().and_then(|dyn_creds| { + let request_path = req + .target + .split('?') + .next() + .unwrap_or(req.target.as_str()) + .to_string(); + let matched_credential = ctx.dynamic_credentials.as_ref().and_then(|dyn_creds| { dyn_creds.read().map_or(None, |creds_guard| { creds_guard .iter() .filter_map(|(key, cred)| { - let score = - dynamic_credential_key_match_score(key, &ctx.host, ctx.port, request_path)?; - cred.token_grant - .is_some() - .then(|| (score, key.clone(), cred.clone())) + let score = dynamic_credential_key_match_score( + key, + &ctx.host, + ctx.port, + &request_path, + )?; + Some((score, key.clone(), cred.clone())) }) .max_by_key(|(score, key, _)| (*score, key.clone())) .map(|(_, key, cred)| (key, cred)) }) }); - if let Some((provider_key, cred)) = token_grant_credential - && let Some(ref token_grant) = cred.token_grant - { - let resolver = ctx - .token_grant_resolver - .as_ref() - .ok_or_else(|| miette!("token grant resolver unavailable"))?; - let request = token_grant_request(&provider_key, token_grant); - - match resolver.obtain(request).await { - Ok(access_token) => { - let modified_raw_header = - inject_token_grant_header(&req.raw_header, &cred, &access_token)?; - let provider_key = ocsf_message_field(&provider_key); - ocsf_emit!( - HttpActivityBuilder::new(crate::ocsf_ctx()) - .activity(ActivityId::Other) - .action(ActionId::Allowed) - .disposition(DispositionId::Allowed) - .severity(SeverityId::Informational) - .http_request(HttpRequest::new( - &req.action, - OcsfUrl::new("http", &ctx.host, request_path, ctx.port), - )) - .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) - .message(format!( - "Token grant successful for {} to {}:{}", - provider_key, ctx.host, ctx.port - )) - .build() - ); - return Ok(L7Request { - action: req.action, - target: req.target, - query_params: req.query_params, - raw_header: modified_raw_header, - body_length: req.body_length, - }); - } - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - provider = %provider_key, - error = %e, - "Token grant failed" - ); - let provider_key = ocsf_message_field(&provider_key); - ocsf_emit!( - HttpActivityBuilder::new(crate::ocsf_ctx()) - .activity(ActivityId::Fail) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .http_request(HttpRequest::new( - &req.action, - OcsfUrl::new("http", &ctx.host, request_path, ctx.port), - )) - .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) - .message(format!( - "Token grant failed for {} to {}:{}: {}", - provider_key, ctx.host, ctx.port, e - )) - .build() - ); - return Err(miette!("Token grant failed: {}", e)); - } + if let Some((provider_key, cred)) = matched_credential { + if let Some(ref token_grant) = cred.token_grant { + inject_token_grant(req, ctx, &request_path, &provider_key, token_grant, &cred).await + } else { + inject_static_credential(req, ctx, &request_path, &provider_key, &cred) } + } else { + Ok(req) } +} - Ok(req) +async fn inject_token_grant( + req: L7Request, + ctx: &L7EvalContext, + request_path: &str, + provider_key: &str, + token_grant: &ProviderCredentialTokenGrant, + cred: &ProviderProfileCredential, +) -> Result { + let resolver = ctx + .token_grant_resolver + .as_ref() + .ok_or_else(|| miette!("token grant resolver unavailable"))?; + let request = token_grant_request(provider_key, token_grant); + + match resolver.obtain(request).await { + Ok(access_token) => { + let modified_raw_header = + inject_token_grant_header(&req.raw_header, cred, &access_token)?; + let provider_key = ocsf_message_field(provider_key); + ocsf_emit!( + HttpActivityBuilder::new(crate::ocsf_ctx()) + .activity(ActivityId::Other) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .http_request(HttpRequest::new( + &req.action, + OcsfUrl::new("http", &ctx.host, request_path, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .message(format!( + "Token grant successful for {} to {}:{}", + provider_key, ctx.host, ctx.port + )) + .build() + ); + Ok(L7Request { + action: req.action, + target: req.target, + query_params: req.query_params, + raw_header: modified_raw_header, + body_length: req.body_length, + }) + } + Err(e) => { + warn!( + host = %ctx.host, + port = ctx.port, + provider = %provider_key, + error = %e, + "Token grant failed" + ); + let provider_key = ocsf_message_field(provider_key); + ocsf_emit!( + HttpActivityBuilder::new(crate::ocsf_ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + &req.action, + OcsfUrl::new("http", &ctx.host, request_path, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .message(format!( + "Token grant failed for {} to {}:{}: {}", + provider_key, ctx.host, ctx.port, e + )) + .build() + ); + Err(miette!("Token grant failed: {}", e)) + } + } +} + +fn inject_static_credential( + req: L7Request, + ctx: &L7EvalContext, + request_path: &str, + provider_key: &str, + cred: &ProviderProfileCredential, +) -> Result { + let Some(value) = cred.env_vars.iter().find_map(|env_var| { + let placeholder = format!("{}{env_var}", crate::secrets::PLACEHOLDER_PREFIX_PUBLIC); + ctx.secret_resolver + .as_ref() + .and_then(|r| r.resolve_placeholder(&placeholder)) + }) else { + let provider_key = ocsf_message_field(provider_key); + ocsf_emit!( + HttpActivityBuilder::new(crate::ocsf_ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + &req.action, + OcsfUrl::new("http", &ctx.host, request_path, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .message(format!( + "No credential found for {} on provider {}", + cred.name, provider_key + )) + .build() + ); + return Err(miette!( + "no credential value found for credential '{}' on provider {}", + cred.name, + provider_key, + )); + }; + match cred.auth_style.trim().to_ascii_lowercase().as_str() { + "" | "bearer" | "header" => { + let modified_raw_header = inject_token_grant_header(&req.raw_header, cred, value)?; + let provider_key = ocsf_message_field(provider_key); + ocsf_emit!( + HttpActivityBuilder::new(crate::ocsf_ctx()) + .activity(ActivityId::Other) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .http_request(HttpRequest::new( + &req.action, + OcsfUrl::new("http", &ctx.host, request_path, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .message(format!( + "Provider credential injected for {} on {}:{}", + provider_key, ctx.host, ctx.port + )) + .build() + ); + Ok(L7Request { + action: req.action, + target: req.target, + query_params: req.query_params, + raw_header: modified_raw_header, + body_length: req.body_length, + }) + } + other => Err(miette!( + "credential injection for auth_style '{}' is not yet supported", + other + )), + } } fn ocsf_message_field(value: &str) -> String { @@ -791,4 +885,213 @@ mod tests { ); fixture.assert_one_request("api.example.com\t443\t/v1/**\tprovider:access_token"); } + + fn static_credential_ctx( + env_var: &str, + secret_value: &str, + auth_style: &str, + header_name: &str, + ) -> (L7EvalContext, String) { + let (_, resolver) = crate::secrets::SecretResolver::from_provider_env( + std::iter::once((env_var.to_string(), secret_value.to_string())).collect(), + ); + + let key = format!("api.example.com\t443\t\tmy-provider:{env_var}"); + let mut dynamic_credentials = std::collections::HashMap::new(); + dynamic_credentials.insert( + key.clone(), + ProviderProfileCredential { + name: "api_token".to_string(), + env_vars: vec![env_var.to_string()], + auth_style: auth_style.to_string(), + header_name: header_name.to_string(), + token_grant: None, + ..Default::default() + }, + ); + + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: resolver.map(Arc::new), + activity_tx: None, + dynamic_credentials: Some(Arc::new(std::sync::RwLock::new(dynamic_credentials))), + token_grant_resolver: None, + }; + (ctx, key) + } + + #[tokio::test] + async fn inject_static_credential_injects_bearer_token() { + let (ctx, _) = + static_credential_ctx("GITHUB_TOKEN", "ghp_secret123", "bearer", "Authorization"); + let req = L7Request { + action: "GET".to_string(), + target: "/repos/owner/repo".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /repos/owner/repo HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let rewritten = inject_if_needed(req, &ctx) + .await + .expect("static credential injection should succeed"); + let rewritten = + String::from_utf8(rewritten.raw_header).expect("rewritten request should be UTF-8"); + + assert!(rewritten.contains("Authorization: Bearer ghp_secret123\r\n")); + } + + #[tokio::test] + async fn inject_static_credential_injects_custom_header() { + let (ctx, _) = + static_credential_ctx("ANTHROPIC_API_KEY", "sk-ant-secret", "header", "x-api-key"); + let req = L7Request { + action: "GET".to_string(), + target: "/v1/messages".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/messages HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let rewritten = inject_if_needed(req, &ctx) + .await + .expect("static credential injection should succeed"); + let rewritten = + String::from_utf8(rewritten.raw_header).expect("rewritten request should be UTF-8"); + + assert!(rewritten.contains("x-api-key: sk-ant-secret\r\n")); + assert!(!rewritten.contains("Bearer")); + } + + #[tokio::test] + async fn inject_static_credential_fails_when_credential_missing() { + // Set up a credential entry pointing to an env var that is NOT in the resolver. + let mut dynamic_credentials = std::collections::HashMap::new(); + dynamic_credentials.insert( + "api.example.com\t443\t\tmy-provider:MISSING_TOKEN".to_string(), + ProviderProfileCredential { + name: "api_token".to_string(), + env_vars: vec!["MISSING_TOKEN".to_string()], + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: None, + ..Default::default() + }, + ); + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: Some(Arc::new(std::sync::RwLock::new(dynamic_credentials))), + token_grant_resolver: None, + }; + let req = L7Request { + action: "GET".to_string(), + target: "/v1/data".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/data HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let err = inject_if_needed(req, &ctx) + .await + .expect_err("missing credential should fail closed"); + assert!(err.to_string().contains("no credential value found")); + } + + #[tokio::test] + async fn inject_static_credential_resolves_fallback_env_var() { + // Credential declares two env vars, only the second is in the resolver. + let (_, resolver) = crate::secrets::SecretResolver::from_provider_env( + std::iter::once(("GH_TOKEN".to_string(), "ghp_fallback".to_string())).collect(), + ); + let mut dynamic_credentials = std::collections::HashMap::new(); + dynamic_credentials.insert( + "api.example.com\t443\t\tmy-provider:api_token".to_string(), + ProviderProfileCredential { + name: "api_token".to_string(), + env_vars: vec!["GITHUB_TOKEN".to_string(), "GH_TOKEN".to_string()], + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: None, + ..Default::default() + }, + ); + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: resolver.map(Arc::new), + activity_tx: None, + dynamic_credentials: Some(Arc::new(std::sync::RwLock::new(dynamic_credentials))), + token_grant_resolver: None, + }; + let req = L7Request { + action: "GET".to_string(), + target: "/repos".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /repos HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let rewritten = inject_if_needed(req, &ctx) + .await + .expect("should resolve fallback env var"); + let rewritten = + String::from_utf8(rewritten.raw_header).expect("rewritten request should be UTF-8"); + + assert!(rewritten.contains("Authorization: Bearer ghp_fallback\r\n")); + } + + #[tokio::test] + async fn inject_static_credential_rejects_unsupported_auth_style() { + let (ctx, _) = static_credential_ctx("API_KEY", "secret123", "query", ""); + let req = L7Request { + action: "GET".to_string(), + target: "/v1/data".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/data HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let err = inject_if_needed(req, &ctx) + .await + .expect_err("unsupported auth_style should fail"); + assert!(err.to_string().contains("not yet supported")); + } + + #[tokio::test] + async fn inject_no_match_passes_request_through() { + let (ctx, _) = static_credential_ctx("TOKEN", "secret", "bearer", "Authorization"); + let req = L7Request { + action: "GET".to_string(), + target: "/data".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /data HTTP/1.1\r\nHost: other.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + // Host doesn't match, so request should pass through unmodified. + let mut ctx_wrong_host = ctx; + ctx_wrong_host.host = "other.example.com".into(); + + let result = inject_if_needed(req, &ctx_wrong_host) + .await + .expect("unmatched request should pass through"); + let raw = String::from_utf8(result.raw_header).expect("UTF-8"); + assert!(!raw.contains("Authorization")); + } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 2e2210f44..6b05d56cb 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1410,9 +1410,22 @@ pub(super) async fn handle_get_sandbox_provider_environment( let provider_names = spec.providers; let provider_env_revision = compute_provider_env_revision(state.store.as_ref(), &provider_names).await?; - let provider_environment = - super::provider::resolve_provider_environment(state.store.as_ref(), &provider_names) - .await?; + + let global_settings = load_global_settings(state.store.as_ref()).await?; + let providers_v2_enabled = + bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap_or_else( + |e| { + warn!("failed to read providers_v2_enabled setting, defaulting to false: {e}"); + false + }, + ); + + let provider_environment = super::provider::resolve_provider_environment( + state.store.as_ref(), + &provider_names, + providers_v2_enabled, + ) + .await?; info!( sandbox_id = %sandbox_id, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 641118206..02de8c9cc 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -434,6 +434,7 @@ fn merge_i64_map( pub(super) async fn resolve_provider_environment( store: &Store, provider_names: &[String], + include_static_credentials: bool, ) -> Result { if provider_names.is_empty() { return Ok(ProviderEnvironment::default()); @@ -539,7 +540,12 @@ pub(super) async fn resolve_provider_environment( Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials(store, provider_names).await?, + dynamic_credentials: resolve_dynamic_credentials( + store, + provider_names, + include_static_credentials, + ) + .await?, }) } @@ -551,6 +557,7 @@ pub(super) async fn resolve_provider_environment( pub(super) async fn resolve_dynamic_credentials( store: &Store, provider_names: &[String], + include_static_credentials: bool, ) -> Result, Status> { if provider_names.is_empty() { return Ok(std::collections::HashMap::new()); @@ -579,6 +586,7 @@ pub(super) async fn resolve_dynamic_credentials( &mut dynamic_creds, &profile.to_proto(), provider_name, + include_static_credentials, ); } @@ -589,9 +597,10 @@ fn insert_dynamic_credentials_for_profile( dynamic_creds: &mut std::collections::HashMap, profile: &ProviderProfile, provider_name: &str, + include_static_credentials: bool, ) { for credential in &profile.credentials { - if credential.token_grant.is_none() { + if credential.token_grant.is_none() && !include_static_credentials { continue; } for endpoint in &profile.endpoints { @@ -2298,7 +2307,7 @@ mod tests { }; let mut dynamic_creds = HashMap::new(); - insert_dynamic_credentials_for_profile(&mut dynamic_creds, &profile, "keycloak"); + insert_dynamic_credentials_for_profile(&mut dynamic_creds, &profile, "keycloak", false); assert_eq!(dynamic_creds.len(), 4); for (host, audience) in service_audiences { @@ -2310,6 +2319,112 @@ mod tests { } } + #[test] + fn static_credentials_included_when_flag_enabled() { + let credential = ProviderProfileCredential { + name: "api_token".to_string(), + env_vars: vec!["GITHUB_TOKEN".to_string(), "GH_TOKEN".to_string()], + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: None, + ..Default::default() + }; + let profile = ProviderProfile { + id: "github".to_string(), + display_name: "GitHub".to_string(), + description: String::new(), + category: ProviderProfileCategory::SourceControl as i32, + credentials: vec![credential], + endpoints: vec![ + NetworkEndpoint { + host: "api.github.com".to_string(), + port: 443, + ..Default::default() + }, + NetworkEndpoint { + host: "github.com".to_string(), + port: 443, + ..Default::default() + }, + ], + binaries: Vec::new(), + inference_capable: false, + discovery: None, + }; + + // With flag off, no static credentials emitted. + let mut creds_off = HashMap::new(); + insert_dynamic_credentials_for_profile(&mut creds_off, &profile, "my-github", false); + assert!( + creds_off.is_empty(), + "static credentials should be skipped when flag is false" + ); + + // With flag on, static credentials are emitted for each endpoint. + let mut creds_on = HashMap::new(); + insert_dynamic_credentials_for_profile(&mut creds_on, &profile, "my-github", true); + assert_eq!(creds_on.len(), 2, "one entry per endpoint expected"); + + let api_key = dynamic_credential_key("api.github.com", 443, "", "my-github", "api_token"); + let web_key = dynamic_credential_key("github.com", 443, "", "my-github", "api_token"); + let api_cred = &creds_on[&api_key]; + assert_eq!(api_cred.auth_style, "bearer"); + assert_eq!(api_cred.env_vars, vec!["GITHUB_TOKEN", "GH_TOKEN"]); + assert!(api_cred.token_grant.is_none()); + assert!(creds_on.contains_key(&web_key)); + } + + #[test] + fn static_credentials_flag_does_not_affect_token_grants() { + let credential = ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: Vec::new(), + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: "https://auth.example.com/token".to_string(), + audience: "api://default".to_string(), + jwt_svid_audience: "https://auth.example.com".to_string(), + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + .to_string(), + scopes: vec!["read".to_string()], + cache_ttl_seconds: 300, + audience_overrides: Vec::new(), + }), + ..Default::default() + }; + let profile = ProviderProfile { + id: "example".to_string(), + display_name: "Example".to_string(), + description: String::new(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![credential], + endpoints: vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: Vec::new(), + inference_capable: false, + discovery: None, + }; + + let mut creds_off = HashMap::new(); + insert_dynamic_credentials_for_profile(&mut creds_off, &profile, "example", false); + let mut creds_on = HashMap::new(); + insert_dynamic_credentials_for_profile(&mut creds_on, &profile, "example", true); + + assert_eq!( + creds_off.len(), + 1, + "token grant should be emitted regardless of flag" + ); + assert_eq!(creds_off.len(), creds_on.len()); + let key = dynamic_credential_key("api.example.com", 443, "", "example", "access_token"); + assert!(creds_off[&key].token_grant.is_some()); + assert!(creds_on[&key].token_grant.is_some()); + } + async fn import_token_grant_profile( state: &Arc, id: &str, @@ -4436,7 +4551,9 @@ mod tests { #[tokio::test] async fn resolve_provider_env_empty_list_returns_empty() { let store = test_store().await; - let result = resolve_provider_environment(&store, &[]).await.unwrap(); + let result = resolve_provider_environment(&store, &[], false) + .await + .unwrap(); assert!(result.is_empty()); } @@ -4467,7 +4584,7 @@ mod tests { }; create_provider_record(&store, provider).await.unwrap(); - let result = resolve_provider_environment(&store, &["claude-local".to_string()]) + let result = resolve_provider_environment(&store, &["claude-local".to_string()], false) .await .unwrap(); assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); @@ -4485,7 +4602,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["static-provider".to_string()]) + let result = resolve_provider_environment(&store, &["static-provider".to_string()], false) .await .unwrap(); @@ -4522,9 +4639,10 @@ mod tests { }; create_provider_record(&store, provider).await.unwrap(); - let result = resolve_provider_environment(&store, &["expiring-provider".to_string()]) - .await - .unwrap(); + let result = + resolve_provider_environment(&store, &["expiring-provider".to_string()], false) + .await + .unwrap(); assert_eq!(result.get("FRESH_TOKEN"), Some(&"fresh".to_string())); assert!(!result.contains_key("STALE_TOKEN")); assert_eq!( @@ -4536,7 +4654,7 @@ mod tests { #[tokio::test] async fn resolve_provider_env_unknown_name_returns_error() { let store = test_store().await; - let err = resolve_provider_environment(&store, &["nonexistent".to_string()]) + let err = resolve_provider_environment(&store, &["nonexistent".to_string()], false) .await .unwrap_err(); assert_eq!(err.code(), Code::FailedPrecondition); @@ -4567,7 +4685,7 @@ mod tests { }; create_provider_record(&store, provider).await.unwrap(); - let result = resolve_provider_environment(&store, &["test-provider".to_string()]) + let result = resolve_provider_environment(&store, &["test-provider".to_string()], false) .await .unwrap(); assert_eq!(result.get("VALID_KEY"), Some(&"value".to_string())); @@ -4623,6 +4741,7 @@ mod tests { let result = resolve_provider_environment( &store, &["claude-local".to_string(), "gitlab-local".to_string()], + false, ) .await .unwrap(); @@ -4678,6 +4797,7 @@ mod tests { let err = resolve_provider_environment( &store, &["provider-a".to_string(), "provider-b".to_string()], + false, ) .await .unwrap_err(); @@ -4721,7 +4841,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-local".to_string()]) + let result = resolve_provider_environment(&store, &["vertex-local".to_string()], false) .await .unwrap(); @@ -4794,7 +4914,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-bootstrap".to_string()]) + let result = resolve_provider_environment(&store, &["vertex-bootstrap".to_string()], false) .await .unwrap(); @@ -4831,7 +4951,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-no-config".to_string()]) + let result = resolve_provider_environment(&store, &["vertex-no-config".to_string()], false) .await .unwrap(); @@ -4889,7 +5009,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["vertex-collision".to_string()]) + let result = resolve_provider_environment(&store, &["vertex-collision".to_string()], false) .await .unwrap(); @@ -4923,7 +5043,7 @@ mod tests { .await .unwrap(); - let result = resolve_provider_environment(&store, &["openai-local".to_string()]) + let result = resolve_provider_environment(&store, &["openai-local".to_string()], false) .await .unwrap(); @@ -5081,7 +5201,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, &spec.providers, false) .await .unwrap(); @@ -5114,7 +5234,7 @@ mod tests { .unwrap() .unwrap(); let spec = loaded.spec.unwrap(); - let env = resolve_provider_environment(&store, &spec.providers) + let env = resolve_provider_environment(&store, &spec.providers, false) .await .unwrap(); diff --git a/proto/openshell.proto b/proto/openshell.proto index d701956d3..3456393d8 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1145,9 +1145,10 @@ message GetSandboxProviderEnvironmentResponse { uint64 provider_env_revision = 2; // Expiration timestamps for returned environment variables. map credential_expires_at_ms = 3; - // 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. + // Endpoint-bound provider credentials for proxy-side injection. + // Maps endpoint keys to credential metadata. Entries with a token_grant + // use SPIFFE-backed runtime exchange; entries without resolve credential + // values from the environment map above. map dynamic_credentials = 4; }