Skip to content
Closed
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
1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ tokio-tungstenite = { workspace = true }

# Streams
futures = { workspace = true }
tokio-stream = { workspace = true }
nix = { workspace = true }

# URL parsing
Expand Down
215 changes: 215 additions & 0 deletions crates/openshell-cli/src/credential_authority.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! CLI-side credential authority for deferred provider secrets.
//!
//! When a sandbox uses deferred providers, the CLI opens a bidirectional gRPC
//! stream to the gateway (`RegisterCredentialAuthority`). The gateway relays
//! credential requests from sandbox supervisors; the CLI prompts the user via
//! an OS-native dialog and responds with the secret read from local env vars.

use std::collections::HashMap;

use miette::{IntoDiagnostic, Result};
use openshell_core::proto::{CredentialRequest, CredentialResponse};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tonic::codec::Streaming;
use tracing::{debug, info, warn};

use crate::tls::GrpcClient;

#[derive(Clone, Debug)]
enum ApprovalDecision {
Once(String),
Always(String),
Deny,
}

/// Spawn the credential authority in the background (fire-and-forget).
pub fn spawn_credential_authority(client: GrpcClient) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = run_credential_authority(client).await {
tracing::debug!("Credential authority exited: {e}");
}
})
}

/// Run the credential authority event loop.
///
/// Opens a bidirectional `RegisterCredentialAuthority` stream to the gateway
/// and handles incoming credential requests by prompting the user. This blocks
/// until the stream is closed (Ctrl-C or gateway disconnect).
pub async fn run_credential_authority(mut client: GrpcClient) -> Result<()> {
let (resp_tx, resp_rx) = mpsc::channel::<CredentialResponse>(16);

// Seed the client→server stream so the HTTP/2 body has an initial DATA
// frame. Without this, the empty stream is dropped by intermediaries
// (NodePort, Docker port mapping) before real traffic arrives.
resp_tx
.send(CredentialResponse {
request_id: String::new(),
approved: false,
value: String::new(),
})
.await
.into_diagnostic()?;

let response_stream = ReceiverStream::new(resp_rx);

let mut request_stream: Streaming<CredentialRequest> = client
.register_credential_authority(response_stream)
.await
.into_diagnostic()?
.into_inner();

let mut approval_cache: HashMap<String, ApprovalDecision> = HashMap::new();

info!("Listening for credential requests... (Ctrl-C to detach)");

while let Some(req) = request_stream
.message()
.await
.into_diagnostic()?
{
debug!(
env_key = %req.env_key,
destination_host = %req.destination_host,
sandbox_name = %req.sandbox_name,
"Credential request received"
);

let decision = if let Some(cached) = approval_cache.get(&req.env_key) {
cached.clone()
} else {
prompt_user(&req.sandbox_name, &req.env_key, &req.destination_host)?
};

let response = match &decision {
ApprovalDecision::Always(value) => {
approval_cache
.insert(req.env_key.clone(), ApprovalDecision::Always(value.clone()));
CredentialResponse {
request_id: req.request_id,
approved: true,
value: value.clone(),
}
}
ApprovalDecision::Once(value) => CredentialResponse {
request_id: req.request_id,
approved: true,
value: value.clone(),
},
ApprovalDecision::Deny => {
approval_cache.insert(req.env_key.clone(), ApprovalDecision::Deny);
CredentialResponse {
request_id: req.request_id,
approved: false,
value: String::new(),
}
}
};

if resp_tx.send(response).await.is_err() {
warn!("Gateway stream closed, exiting credential authority");
break;
}
}

Ok(())
}

/// Show an OS-native dialog and read the secret from a local env var.
fn prompt_user(
sandbox_name: &str,
env_key: &str,
destination_host: &str,
) -> Result<ApprovalDecision> {
let choice = show_native_dialog(sandbox_name, env_key, destination_host)?;

match choice.as_str() {
"Once" | "Always" => {
let value = std::env::var(env_key).map_err(|_| {
miette::miette!(
"{env_key} is not set in the local environment. \
Set it and retry, or deny the request."
)
})?;
if choice == "Always" {
Ok(ApprovalDecision::Always(value))
} else {
Ok(ApprovalDecision::Once(value))
}
}
_ => Ok(ApprovalDecision::Deny),
}
}

/// Display an OS-native dialog asking the user to approve credential sharing.
///
/// Returns "Once", "Always", or "Deny".
fn show_native_dialog(
sandbox_name: &str,
env_key: &str,
destination_host: &str,
) -> Result<String> {
#[cfg(target_os = "macos")]
{
let script = format!(
r#"display dialog "Sandbox '{}' requests {}\nDestination: {}" buttons {{"Deny", "Once", "Always"}} default button "Once" with title "OpenShell Credential Request""#,
sandbox_name, env_key, destination_host
);
let output = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.into_diagnostic()?;

let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("Always") {
Ok("Always".to_string())
} else if stdout.contains("Once") {
Ok("Once".to_string())
} else {
Ok("Deny".to_string())
}
}

#[cfg(all(not(target_os = "macos"), target_os = "linux"))]
{
let text = format!(
"Sandbox '{}' requests {}\nDestination: {}",
sandbox_name, env_key, destination_host
);
let output = std::process::Command::new("zenity")
.args([
"--list",
"--radiolist",
"--title=OpenShell Credential Request",
&format!("--text={text}"),
"--column=",
"--column=Choice",
"TRUE",
"Once",
"FALSE",
"Always",
"FALSE",
"Deny",
])
.output()
.into_diagnostic()?;

let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout == "Always" || stdout == "Once" {
Ok(stdout)
} else {
Ok("Deny".to_string())
}
}

#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))]
{
warn!("No native dialog available on this platform, defaulting to Deny");
Ok("Deny".to_string())
}
}
1 change: 1 addition & 0 deletions crates/openshell-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()
pub mod auth;
pub mod bootstrap;
pub mod completers;
pub mod credential_authority;
pub mod edge_tunnel;
pub(crate) mod policy_update;
pub mod run;
Expand Down
17 changes: 17 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2532,11 +2532,28 @@ async fn main() -> Result<()> {
}
SandboxCommands::Connect { name, editor } => {
let name = resolve_sandbox_name(name, &ctx.name)?;
// Spawn credential authority for deferred providers.
let has_cred_authority = {
match openshell_cli::tls::grpc_client(endpoint, &tls).await {
Ok(cred_client) => {
tracing::debug!("Credential authority: connecting to gateway");
openshell_cli::credential_authority::spawn_credential_authority(cred_client);
true
}
Err(e) => {
tracing::debug!("Credential authority: failed to connect: {e}");
false
}
}
};
if let Some(editor) = editor.map(Into::into) {
run::sandbox_connect_editor(
endpoint, &ctx.name, &name, editor, &tls,
)
.await?;
} else if has_cred_authority {
// Avoid exec() so the credential authority task stays alive.
openshell_cli::ssh::sandbox_connect_without_exec(endpoint, &name, &tls).await?;
} else {
run::sandbox_connect(endpoint, &name, &tls).await?;
}
Expand Down
59 changes: 59 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2458,6 +2458,19 @@ pub async fn sandbox_create(
);
}

// Spawn credential authority in the background for deferred providers.
{
match grpc_client(&effective_server, &effective_tls).await {
Ok(cred_client) => {
tracing::debug!("Credential authority: connecting to gateway");
crate::credential_authority::spawn_credential_authority(cred_client);
}
Err(e) => {
tracing::debug!("Credential authority: failed to connect: {e}");
}
}
}

if let Some(editor) = editor {
let ssh_gateway_name = effective_tls.gateway_name().unwrap_or(gateway_name);
sandbox_connect_editor(
Expand Down Expand Up @@ -3354,6 +3367,52 @@ async fn auto_create_provider(
.discover_existing(provider_type)
.map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?;
let Some(discovered) = discovered else {
// No local credentials found — offer deferred mode
let env_keys = registry.credential_env_vars(provider_type);
if !env_keys.is_empty() && std::io::stdin().is_terminal() {
let use_deferred = Confirm::new()
.with_prompt("No local credentials found. Share on demand (deferred)?")
.default(true)
.interact()
.into_diagnostic()?;
if use_deferred {
let deferred_creds: HashMap<String, String> = env_keys
.iter()
.map(|k| (k.to_string(), "openshell:deferred".to_string()))
.collect();
let name = preferred_name
.map(|s| s.to_string())
.unwrap_or_else(|| provider_type.to_string());
let request = CreateProviderRequest {
provider: Some(Provider {
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
id: String::new(),
name: name.clone(),
created_at_ms: 0,
labels: std::collections::HashMap::new(),
}),
r#type: provider_type.to_string(),
credentials: deferred_creds,
config: std::collections::HashMap::new(),
}),
};
client
.create_provider(request)
.await
.map_err(|status| miette::miette!("failed to create deferred provider: {status}"))?;
eprintln!(
"{} Created provider {} ({}) [deferred — credentials will be requested on demand]",
"✓".green().bold(),
name,
provider_type,
);
if seen_names.insert(name.clone()) {
configured_names.push(name);
}
eprintln!();
return Ok(());
}
}
eprintln!(
"{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.",
"!".yellow(),
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ pub async fn sandbox_connect(server: &str, name: &str, tls: &TlsOptions) -> Resu
sandbox_connect_with_mode(server, name, tls, true).await
}

pub(crate) async fn sandbox_connect_without_exec(
pub async fn sandbox_connect_without_exec(
server: &str,
name: &str,
tls: &TlsOptions,
Expand Down
Loading
Loading