Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ override template values so sandbox images cannot spoof identity, callback, or
relay settings.

Credential placeholders in proxied HTTP requests can be resolved by the proxy
when policy allows the target endpoint. Secrets must not be logged in OCSF or
plain tracing output.
when policy allows the target endpoint. For GCP providers, a loopback metadata
server inside the network namespace serves placeholders to SDKs that bypass the
proxy (e.g. Go's `cloud.google.com/go/compute/metadata`). Secrets must not be
logged in OCSF or plain tracing output.

## Connect and Logs

Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ enum ProviderCommands {

/// Configure credentials from gcloud Application Default Credentials
/// (`~/.config/gcloud/application_default_credentials.json`).
/// Only valid for google-vertex-ai providers.
/// Valid for providers whose profile declares an ADC-compatible credential.
#[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials"])]
from_gcloud_adc: bool,

Expand Down
74 changes: 55 additions & 19 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4230,7 +4230,7 @@ fn read_gcloud_adc() -> Result<(String, String, String)> {
Ok((client_id, client_secret, refresh_token))
}

async fn rollback_provider_create_after_vertex_adc_failure(
async fn rollback_provider_create_after_gcloud_adc_failure(
client: &mut crate::tls::GrpcClient,
provider_name: &str,
stage: &str,
Expand All @@ -4243,7 +4243,7 @@ async fn rollback_provider_create_after_vertex_adc_failure(
.await
{
Ok(_) => Err(miette!(
"failed to {stage} Vertex AI credentials from gcloud ADC for provider '{provider_name}': {source}. \
"failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \
The provider was rolled back successfully."
)),
Err(cleanup_err) => {
Expand All @@ -4257,7 +4257,7 @@ async fn rollback_provider_create_after_vertex_adc_failure(
provider_name
);
Err(miette!(
"failed to {stage} Vertex AI credentials from gcloud ADC for provider '{provider_name}': {source}. \
"failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \
Cleanup also failed, so the provider may still exist. \
Run 'openshell provider delete {provider_name}' to remove it manually."
))
Expand Down Expand Up @@ -4366,6 +4366,9 @@ async fn discover_existing_provider_data(
/// Canonical provider type string for Google Vertex AI.
const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai";

/// Canonical provider type string for Google Cloud (GCP APIs).
const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud";

fn missing_credentials_error(provider_type: &str) -> miette::Report {
if provider_type == VERTEX_AI_PROVIDER_TYPE {
return miette::miette!(
Expand All @@ -4376,6 +4379,14 @@ fn missing_credentials_error(provider_type: &str) -> miette::Report {
);
}

if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE {
return miette::miette!(
"no credentials resolved for provider type '{provider_type}'. \
Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \
or use --from-gcloud-adc / --from-existing with those env vars set."
);
}

miette::miette!(
"no credentials resolved for provider type '{provider_type}'. \
Use --credential KEY[=VALUE] or --from-existing with the appropriate env vars set."
Expand Down Expand Up @@ -4434,11 +4445,34 @@ pub async fn provider_create(
}
};

if from_gcloud_adc && provider_type != VERTEX_AI_PROVIDER_TYPE {
return Err(miette::miette!(
"--from-gcloud-adc is only valid for google-vertex-ai providers"
));
}
let adc_credential_key = if from_gcloud_adc {
let profile =
openshell_providers::get_default_profile(&provider_type).ok_or_else(|| {
miette::miette!(
"--from-gcloud-adc requires a built-in provider profile, \
but '{provider_type}' has none"
)
})?;
let adc_cred = profile.adc_credential().ok_or_else(|| {
miette::miette!(
"--from-gcloud-adc is not supported for '{provider_type}' providers \
(no ADC-compatible credential in the provider profile)"
)
})?;
Some(
adc_cred
.env_vars
.first()
.ok_or_else(|| {
miette::miette!(
"ADC credential in '{provider_type}' profile has no env_vars declared"
)
})?
.clone(),
)
} else {
None
};

let mut credential_map = parse_credential_pairs(credentials)?;
let mut config_map = parse_key_value_pairs(config, "--config")?;
Expand Down Expand Up @@ -4473,10 +4507,12 @@ pub async fn provider_create(
}

// Validate and read the ADC file BEFORE creating the provider so that
// a bad/missing ADC does not leave an orphan provider behind.
let gcloud_adc_material = if from_gcloud_adc {
// a bad/missing ADC does not leave an orphan provider behind. Bundle the
// credential key with the material so they stay coupled.
let gcloud_adc_bootstrap = if from_gcloud_adc {
let (client_id, client_secret, refresh_token) = read_gcloud_adc()?;
Some((client_id, client_secret, refresh_token))
let key = adc_credential_key.expect("set when from_gcloud_adc is true");
Some((key, client_id, client_secret, refresh_token))
} else {
None
};
Expand Down Expand Up @@ -4506,7 +4542,9 @@ pub async fn provider_create(
.ok_or_else(|| miette::miette!("provider missing from response"))?;
let provider_name = provider.object_name().to_string();

if let Some((client_id, client_secret, refresh_token)) = gcloud_adc_material {
if let Some((adc_credential_key, client_id, client_secret, refresh_token)) =
gcloud_adc_bootstrap
{
let mut material = HashMap::new();
material.insert("client_id".to_string(), client_id);
material.insert("client_secret".to_string(), client_secret);
Expand All @@ -4515,7 +4553,7 @@ pub async fn provider_create(
if let Err(configure_err) = client
.configure_provider_refresh(ConfigureProviderRefreshRequest {
provider: provider_name.clone(),
credential_key: openshell_core::inference::VERTEX_AI_ADC_TOKEN_KEY.to_string(),
credential_key: adc_credential_key.clone(),
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
material,
secret_material_keys: vec![
Expand All @@ -4526,7 +4564,7 @@ pub async fn provider_create(
})
.await
{
return rollback_provider_create_after_vertex_adc_failure(
return rollback_provider_create_after_gcloud_adc_failure(
&mut client,
&provider_name,
"configure",
Expand All @@ -4538,11 +4576,11 @@ pub async fn provider_create(
if let Err(rotate_err) = client
.rotate_provider_credential(RotateProviderCredentialRequest {
provider: provider_name.clone(),
credential_key: openshell_core::inference::VERTEX_AI_ADC_TOKEN_KEY.to_string(),
credential_key: adc_credential_key,
})
.await
{
return rollback_provider_create_after_vertex_adc_failure(
return rollback_provider_create_after_gcloud_adc_failure(
&mut client,
&provider_name,
"mint the initial access token for",
Expand All @@ -4552,9 +4590,7 @@ pub async fn provider_create(
}

println!("{} Created provider {}", "✓".green().bold(), provider_name);
println!(
"Configured Vertex AI credentials from gcloud ADC and minted the initial access token"
);
println!("Configured GCP credentials from gcloud ADC and minted the initial access token");
return Ok(());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2123,8 +2123,7 @@ async fn provider_create_from_gcloud_adc_rejects_wrong_provider_type_before_cred
.expect_err("wrong provider type should fail before generic credential validation");

assert!(
err.to_string()
.contains("--from-gcloud-adc is only valid for google-vertex-ai providers"),
err.to_string().contains("--from-gcloud-adc"),
"unexpected error: {err}"
);
assert!(ts.state.providers.lock().await.is_empty());
Expand Down
111 changes: 111 additions & 0 deletions crates/openshell-core/src/google_cloud.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared GCP constants for the metadata emulator, provider env injection,
//! and credential resolution.
//!
//! This module is the single source of truth for GCP naming: env var aliases,
//! provider config keys, token search order, and Vertex-specific env vars.
//! `openshell-server`, `openshell-providers`, and `openshell-sandbox`
//! import from here.

// ── Metadata emulator ───────────────────────────────────────────────────────

/// Hostname served by the GCE metadata emulator via proxy interception.
pub const METADATA_HOST: &str = "gcp.metadata.openshell.internal";

/// Loopback address for the GCE metadata server inside sandbox namespaces.
/// Go's metadata client dials this directly (bypasses `HTTP_PROXY`).
pub const METADATA_LOOPBACK_ADDR: &str = "127.0.0.1:8174";

// ── Env var alias arrays ────────────────────────────────────────────────────

/// Env vars that carry the GCP project ID inside sandboxes.
pub const PROJECT_ID_ENV_VARS: &[&str] = &["GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT"];

/// Env vars that carry the GCP region/location inside sandboxes.
pub const REGION_ENV_VARS: &[&str] = &["CLOUD_ML_REGION", "GCP_LOCATION"];

/// Env vars that carry the GCP service account email inside sandboxes.
pub const SERVICE_ACCOUNT_EMAIL_ENV_VARS: &[&str] = &["GCP_SERVICE_ACCOUNT_EMAIL"];

// ── Provider config keys ────────────────────────────────────────────────────

/// Config key for project ID in `gcp` providers.
pub const GCP_PROJECT_ID_CONFIG_KEY: &str = "project_id";

/// Config key for region in `gcp` providers.
pub const GCP_REGION_CONFIG_KEY: &str = "region";

/// Config key for service account email in `gcp` providers.
pub const GCP_SERVICE_ACCOUNT_EMAIL_CONFIG_KEY: &str = "service_account_email";

// ── Token search order ──────────────────────────────────────────────────────

/// GCP token env vars searched in priority order by the metadata emulator.
/// SA token wins over ADC if both are configured, matching GCP's own
/// credential precedence.
pub const TOKEN_ENV_KEYS: &[&str] = &["GCP_SA_ACCESS_TOKEN", "GCP_ADC_ACCESS_TOKEN"];

// ── Vertex-specific env vars ────────────────────────────────────────────────

/// Env var injected to signal Vertex AI usage to Goose.
pub const GOOSE_PROVIDER_ENV_VAR: &str = "GOOSE_PROVIDER";

/// Env var for Anthropic Vertex project ID (consumed by Claude Code SDK).
pub const ANTHROPIC_VERTEX_PROJECT_ID_ENV_VAR: &str = "ANTHROPIC_VERTEX_PROJECT_ID";

/// Env var for Vertex location (consumed by Claude Code SDK).
pub const VERTEX_LOCATION_ENV_VAR: &str = "VERTEX_LOCATION";

/// Non-secret GCP/Vertex config vars that must be resolved to real values
/// in the child environment. Everything else stays as placeholders for
/// proxy-time resolution.
///
/// This list MUST be the union of all alias arrays above plus all
/// Vertex-specific env vars. If you add an alias to `PROJECT_ID_ENV_VARS`,
/// `REGION_ENV_VARS`, or a Vertex constant, add it here too.
pub const STATIC_CONFIG_KEYS: &[&str] = &[
// project_id aliases
"GCP_PROJECT_ID",
"GOOGLE_CLOUD_PROJECT",
// region aliases
"CLOUD_ML_REGION",
"GCP_LOCATION",
// service account email
"GCP_SERVICE_ACCOUNT_EMAIL",
// Vertex-specific non-secret config
GOOSE_PROVIDER_ENV_VAR,
ANTHROPIC_VERTEX_PROJECT_ID_ENV_VAR,
VERTEX_LOCATION_ENV_VAR,
];

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;

#[test]
fn static_config_keys_matches_alias_arrays_and_vertex_vars() {
let expected: HashSet<&str> = PROJECT_ID_ENV_VARS
.iter()
.chain(REGION_ENV_VARS)
.chain(SERVICE_ACCOUNT_EMAIL_ENV_VARS)
.copied()
.chain([
GOOSE_PROVIDER_ENV_VAR,
ANTHROPIC_VERTEX_PROJECT_ID_ENV_VAR,
VERTEX_LOCATION_ENV_VAR,
])
.collect();
let actual: HashSet<&str> = STATIC_CONFIG_KEYS.iter().copied().collect();
assert_eq!(
expected,
actual,
"STATIC_CONFIG_KEYS must be the union of all alias arrays + Vertex vars. \
Missing: {:?}, Extra: {:?}",
expected.difference(&actual).collect::<Vec<_>>(),
actual.difference(&expected).collect::<Vec<_>>(),
);
}
}
6 changes: 0 additions & 6 deletions crates/openshell-core/src/inference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,6 @@ pub const VERTEX_AI_CREDENTIAL_KEY_NAMES: &[&str] = &[
"VERTEX_AI_TOKEN",
];

/// The credential key used for tokens minted from gcloud Application Default Credentials.
///
/// This is the key written by the gateway's `OAuth2` refresh worker when using the
/// `--from-gcloud-adc` CLI flow. It must match `VERTEX_AI_CREDENTIAL_KEY_NAMES[2]`.
pub const VERTEX_AI_ADC_TOKEN_KEY: &str = "GOOGLE_VERTEX_AI_TOKEN";

/// GCP project ID config key for Vertex AI providers.
pub const VERTEX_AI_PROJECT_ID_KEY: &str = "VERTEX_AI_PROJECT_ID";

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod config;
pub mod driver_utils;
pub mod error;
pub mod forward;
pub mod google_cloud;
pub mod gpu;
pub mod image;
pub mod inference;
Expand Down
30 changes: 23 additions & 7 deletions crates/openshell-providers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,12 @@ pub trait ProviderPlugin: Send + Sync {
&[]
}

/// Apply provider data to sandbox runtime context.
/// Inject provider-specific environment variables into the sandbox env.
///
/// Default implementation is a no-op; provider-specific runtime projection
/// can be layered in incrementally.
fn apply_to_sandbox(&self, _provider: &Provider) -> Result<(), ProviderError> {
Ok(())
}
/// Called during sandbox creation to project provider config (project IDs,
/// regions, SDK flags) into env vars the sandbox process will inherit.
/// Default is a no-op; GCP and Vertex providers override this.
fn inject_env(&self, _provider: &Provider, _env: &mut HashMap<String, String>) {}
}

/// Blanket implementation of [`ProviderPlugin`] for [`ProviderDiscoverySpec`].
Expand Down Expand Up @@ -116,9 +115,11 @@ impl ProviderRegistry {
registry.register(providers::openai::SPEC);
registry.register(providers::anthropic::SPEC);
registry.register(providers::nvidia::SPEC);
registry.register(providers::gitlab::SPEC);
registry.register(providers::github::SPEC);
registry.register(providers::gitlab::SPEC);
registry.register(providers::google_cloud::GoogleCloudProvider);
registry.register(providers::outlook::OutlookProvider);
registry.register(providers::vertex::VertexProvider);
registry
}

Expand Down Expand Up @@ -158,6 +159,20 @@ impl ProviderRegistry {
default_profiles().iter().collect()
}

/// Inject provider-specific env vars via the registered plugin.
///
/// Normalizes the provider type and delegates to the plugin's `inject_env`.
/// No-op if the provider type has no registered plugin or the plugin's
/// default implementation is a no-op.
pub fn inject_env(&self, provider: &Provider, env: &mut HashMap<String, String>) {
let normalized = normalize_provider_type(&provider.r#type);
if let Some(id) = normalized
&& let Some(plugin) = self.get(id)
{
plugin.inject_env(provider, env);
}
}

#[must_use]
pub fn known_types(&self) -> Vec<&'static str> {
let mut types = self.plugins.keys().copied().collect::<Vec<_>>();
Expand All @@ -179,6 +194,7 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> {
"codex" => Some("codex"),
"copilot" => Some("copilot"),
"opencode" => Some("opencode"),
"gcp" | "google-cloud" => Some("google-cloud"),
"generic" => Some("generic"),
"gitlab" | "glab" => Some("gitlab"),
"github" | "gh" => Some("github"),
Expand Down
Loading
Loading