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
48 changes: 43 additions & 5 deletions crates/openshell-sandbox/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,47 @@ pub async fn sync_policy(endpoint: &str, sandbox: &str, policy: &ProtoSandboxPol
sync_policy_with_client(&mut client, sandbox, policy).await
}

/// Provider environment fetched from the server, indexed by provider type.
pub struct ProviderEnvironment {
/// Env vars indexed by provider type (e.g. `"anthropic"` -> `{"ANTHROPIC_API_KEY": "sk-..."}`).
pub by_type: HashMap<String, HashMap<String, String>>,
}

impl ProviderEnvironment {
/// Flatten all provider env vars into a single map for injection into the
/// child process. When two different provider types set the same env var,
/// one value wins arbitrarily (iteration order over `HashMap` keys is
/// nondeterministic).
pub fn flatten(self) -> HashMap<String, String> {
let mut flat = HashMap::new();
for (_provider_type, env) in self.by_type {
for (key, value) in env {
flat.entry(key).or_insert(value);
}
}
flat
}

/// Returns the set of provider types present.
pub fn provider_types(&self) -> Vec<String> {
self.by_type.keys().cloned().collect()
}

/// Check if a specific provider type is present.
pub fn has_provider_type(&self, provider_type: &str) -> bool {
self.by_type.contains_key(provider_type)
}
}

/// Fetch provider environment variables for a sandbox from OpenShell server via gRPC.
///
/// Returns a map of environment variable names to values derived from provider
/// credentials configured on the sandbox. Returns an empty map if the sandbox
/// has no providers or the call fails.
/// Returns provider credentials indexed by provider type. Use
/// [`ProviderEnvironment::flatten`] to merge into a single env var map for
/// injection into the child process.
pub async fn fetch_provider_environment(
endpoint: &str,
sandbox_id: &str,
) -> Result<HashMap<String, String>> {
) -> Result<ProviderEnvironment> {
debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Fetching provider environment");

let mut client = connect(endpoint).await?;
Expand All @@ -197,7 +229,13 @@ pub async fn fetch_provider_environment(
.await
.into_diagnostic()?;

Ok(response.into_inner().environment)
let inner = response.into_inner();
let by_type = inner
.providers
.into_iter()
.map(|(provider_type, entry)| (provider_type, entry.environment))
.collect();
Ok(ProviderEnvironment { by_type })
}

/// A reusable gRPC client for the OpenShell service.
Expand Down
96 changes: 89 additions & 7 deletions crates/openshell-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,22 +186,32 @@ pub async fn run_sandbox(
// Fetch provider environment variables from the server.
// This is done after loading the policy so the sandbox can still start
// even if provider env fetch fails (graceful degradation).
let provider_env = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) {
let provider_result = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) {
match grpc_client::fetch_provider_environment(endpoint, id).await {
Ok(env) => {
info!(env_count = env.len(), "Fetched provider environment");
env
Ok(result) => {
info!(
provider_types = ?result.provider_types(),
"Fetched provider environment"
);
result
}
Err(e) => {
warn!(error = %e, "Failed to fetch provider environment, continuing without");
std::collections::HashMap::new()
grpc_client::ProviderEnvironment {
by_type: std::collections::HashMap::new(),
}
}
}
} else {
std::collections::HashMap::new()
grpc_client::ProviderEnvironment {
by_type: std::collections::HashMap::new(),
}
};

let (provider_env, secret_resolver) = SecretResolver::from_provider_env(provider_env);
let has_anthropic = provider_result.has_provider_type("anthropic")
|| provider_result.has_provider_type("claude");
let (provider_env, secret_resolver) =
SecretResolver::from_provider_env(provider_result.flatten());
let secret_resolver = secret_resolver.map(Arc::new);

// Create identity cache for SHA256 TOFU when OPA is active
Expand Down Expand Up @@ -502,6 +512,12 @@ pub async fn run_sandbox(
}
}

// Write provider-specific config files (e.g., Claude Code onboarding bypass
// for the anthropic provider). Non-fatal: sandbox still starts on failure.
if let Err(e) = write_provider_configs(has_anthropic, &policy) {
warn!(error = %e, "Failed to write provider config files, continuing without");
}

#[cfg(target_os = "linux")]
let mut handle = ProcessHandle::spawn(
program,
Expand Down Expand Up @@ -1179,6 +1195,72 @@ fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> {
Ok(())
}

/// Write provider-specific configuration files to the sandbox user's home directory.
///
/// Currently handles the `anthropic` provider type by writing a `.claude.json`
/// file that marks onboarding as complete, allowing Claude Code to start
/// without interactive setup when `ANTHROPIC_API_KEY` is present in the
/// environment.
#[cfg(unix)]
fn write_provider_configs(has_anthropic: bool, policy: &SandboxPolicy) -> Result<()> {
use nix::unistd::{User, chown};

if !has_anthropic {
return Ok(());
}

// Resolve sandbox user and home directory (same logic as session_user_and_home in ssh.rs).
let user_name = policy.process.run_as_user.as_deref().unwrap_or("sandbox");
let (uid, gid, home) = {
let user = User::from_name(user_name)
.into_diagnostic()?
.ok_or_else(|| miette::miette!("sandbox user '{user_name}' not found"))?;
let gid = user.gid;
let home = user.dir.to_string_lossy().into_owned();
(user.uid, gid, home)
};

let claude_json_path = std::path::Path::new(&home).join(".claude.json");

// Merge into existing .claude.json if present, so we don't clobber
// user-supplied or BYOC-baked configuration.
let mut config: serde_json::Value = if claude_json_path.exists() {
let existing = std::fs::read_to_string(&claude_json_path).into_diagnostic()?;
serde_json::from_str(&existing).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};

if let Some(obj) = config.as_object_mut() {
obj.entry("hasCompletedOnboarding")
.or_insert(serde_json::Value::Bool(true));
}

if let Some(parent) = claude_json_path.parent() {
std::fs::create_dir_all(parent).into_diagnostic()?;
}

std::fs::write(
&claude_json_path,
serde_json::to_string_pretty(&config).unwrap(),
)
.into_diagnostic()?;

chown(&claude_json_path, Some(uid), Some(gid)).into_diagnostic()?;

info!(
path = %claude_json_path.display(),
"Wrote Claude Code config for anthropic provider"
);

Ok(())
}

#[cfg(not(unix))]
fn write_provider_configs(_has_anthropic: bool, _policy: &SandboxPolicy) -> Result<()> {
Ok(())
}

/// Background loop that polls the server for policy updates.
///
/// When a new version is detected, attempts to reload the OPA engine via
Expand Down
Loading
Loading