Skip to content
Draft
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
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions architecture/sandbox-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub trait ProviderPlugin: Send + Sync {
| `nvidia.rs` | `NVIDIA_API_KEY` | *(none)* |
| `gitlab.rs` | `GITLAB_TOKEN`, `GLAB_TOKEN`, `CI_JOB_TOKEN` | `~/.config/glab-cli/config.yml` |
| `github.rs` | `GITHUB_TOKEN`, `GH_TOKEN` | `~/.config/gh/hosts.yml` |
| `microsoft_agent_s2s.rs` | `AZURE_TENANT_ID`, `A365_BLUEPRINT_CLIENT_ID`, `A365_BLUEPRINT_CLIENT_SECRET`, `A365_RUNTIME_AGENT_ID`, `A365_ALLOWED_AUDIENCES`, `A365_OBSERVABILITY_RESOURCE`, `A365_REQUIRED_ROLES` | *(none)* |
| `outlook.rs` | *(none)* | *(none)* |

`generic` and `outlook` are stubs — `discover_existing()` always returns `None`.
Expand Down Expand Up @@ -241,16 +242,25 @@ variables (injected into the pod spec by the gateway's Kubernetes sandbox creati

In `run_sandbox()` (`crates/openshell-sandbox/src/lib.rs`):

1. loads the sandbox policy via gRPC (`GetSandboxSettings`),
1. loads the sandbox policy via gRPC (`GetSandboxConfig`),
2. fetches provider credentials via gRPC (`GetSandboxProviderEnvironment`),
3. if the fetch fails, continues with an empty map (graceful degradation with a warning).
3. if the fetch fails, continues with an empty map (graceful degradation with a warning),
4. starts any provider-specific runtime resolvers, such as `microsoft-agent-s2s`.

The returned `provider_env` `HashMap<String, String>` is immediately transformed into:
Most returned provider credentials are transformed into:

- a child-visible env map with placeholder values such as
`openshell:resolve:env:ANTHROPIC_API_KEY`, and
- a supervisor-only in-memory registry mapping each placeholder back to its real secret.

`microsoft-agent-s2s` is handled differently. Its blueprint secret and broker inputs are
removed from the child env path, used only by the sandbox supervisor to start a local
token resolver, and replaced with non-secret resolver metadata:

- `OPENSHELL_MICROSOFT_AGENT_S2S_TOKEN_URL`
- `OPENSHELL_MICROSOFT_AGENT_S2S_DEFAULT_AUDIENCE` when one default audience is known
- `A365_TOKEN_PROVIDER_URL` as a compatibility alias for runtimes that expect A365 naming

The placeholder env map is threaded to the entrypoint process spawner and SSH server.
The registry is threaded to the proxy so it can rewrite outbound headers.

Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ enum CliProviderType {
Opencode,
Codex,
Copilot,
MicrosoftAgentS2s,
Generic,
Openai,
Anthropic,
Expand Down Expand Up @@ -635,6 +636,7 @@ impl CliProviderType {
Self::Opencode => "opencode",
Self::Codex => "codex",
Self::Copilot => "copilot",
Self::MicrosoftAgentS2s => "microsoft-agent-s2s",
Self::Generic => "generic",
Self::Openai => "openai",
Self::Anthropic => "anthropic",
Expand Down
66 changes: 66 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,72 @@ async fn explicit_provider_name_auto_creates_when_valid_type() {
);
}

/// When `--provider microsoft-agent-s2s` is passed, no provider named
/// "microsoft-agent-s2s" exists, and it is a valid provider type, the CLI
/// should auto-create a provider using discovered Agent ID S2S credentials.
#[tokio::test]
async fn explicit_microsoft_agent_s2s_provider_name_auto_creates_when_valid_type() {
let ts = run_server().await;
let _guard = EnvVarGuard::set(&[
("AZURE_TENANT_ID", "tenant-id"),
("A365_BLUEPRINT_CLIENT_ID", "blueprint-client-id"),
("A365_BLUEPRINT_CLIENT_SECRET", "blueprint-secret"),
("A365_RUNTIME_AGENT_ID", "runtime-agent-id"),
("A365_ALLOWED_AUDIENCES", "api://aud-a,api://aud-b"),
("A365_OBSERVABILITY_RESOURCE", "observability-resource"),
("A365_REQUIRED_ROLES", "Agent365.Observability.OtelWrite"),
]);

let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls)
.await
.expect("grpc client");

let result = run::ensure_required_providers(
&mut client,
&["microsoft-agent-s2s".to_string()],
&[],
Some(true),
)
.await
.expect("should auto-create the provider");

assert_eq!(result, vec!["microsoft-agent-s2s".to_string()]);

let providers = ts.openshell.state.providers.lock().await;
let provider = providers
.get("microsoft-agent-s2s")
.expect("microsoft-agent-s2s provider should exist");
assert_eq!(provider.r#type, "microsoft-agent-s2s");
assert_eq!(
provider.credentials.get("AZURE_TENANT_ID"),
Some(&"tenant-id".to_string())
);
assert_eq!(
provider.credentials.get("A365_BLUEPRINT_CLIENT_ID"),
Some(&"blueprint-client-id".to_string())
);
assert_eq!(
provider.credentials.get("A365_BLUEPRINT_CLIENT_SECRET"),
Some(&"blueprint-secret".to_string())
);
assert_eq!(
provider.credentials.get("A365_RUNTIME_AGENT_ID"),
Some(&"runtime-agent-id".to_string())
);
assert_eq!(
provider.credentials.get("A365_ALLOWED_AUDIENCES"),
Some(&"api://aud-a,api://aud-b".to_string())
);
assert_eq!(
provider.credentials.get("A365_OBSERVABILITY_RESOURCE"),
Some(&"observability-resource".to_string())
);
assert_eq!(
provider.credentials.get("A365_REQUIRED_ROLES"),
Some(&"Agent365.Observability.OtelWrite".to_string())
);
}

/// When `--provider my-custom-thing` is passed and "my-custom-thing" is not a
/// known provider type, the CLI should return an error.
#[tokio::test]
Expand Down
115 changes: 99 additions & 16 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,32 +28,48 @@ use tokio_stream::wrappers::TcpListenerStream;
use tonic::transport::{Certificate as TlsCertificate, Identity, Server, ServerTlsConfig};
use tonic::{Response, Status};

struct EnvVarGuard {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

struct SavedVar {
key: &'static str,
original: Option<String>,
}

struct EnvVarGuard {
vars: Vec<SavedVar>,
_lock: std::sync::MutexGuard<'static, ()>,
}

#[allow(unsafe_code)]
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
fn set(pairs: &[(&'static str, &str)]) -> Self {
let lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut vars = Vec::with_capacity(pairs.len());
for &(key, value) in pairs {
let original = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
vars.push(SavedVar { key, original });
}
Self { key, original }
Self { vars, _lock: lock }
}
}

#[allow(unsafe_code)]
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(value) = &self.original {
unsafe {
std::env::set_var(self.key, value);
}
} else {
unsafe {
std::env::remove_var(self.key);
for var in &self.vars {
if let Some(value) = &var.original {
unsafe {
std::env::set_var(var.key, value);
}
} else {
unsafe {
std::env::remove_var(var.key);
}
}
}
}
Expand Down Expand Up @@ -545,7 +561,7 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value()
#[tokio::test]
async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
let ts = run_server().await;
let _guard = EnvVarGuard::set("NAV_GENERIC_TEST_KEY", "generic-value");
let _guard = EnvVarGuard::set(&[("NAV_GENERIC_TEST_KEY", "generic-value")]);

run::provider_create(
&ts.endpoint,
Expand Down Expand Up @@ -577,6 +593,73 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() {
);
}

#[tokio::test]
async fn provider_create_from_existing_supports_microsoft_agent_s2s_type() {
let ts = run_server().await;
let _guard = EnvVarGuard::set(&[
("AZURE_TENANT_ID", "tenant-id"),
("A365_BLUEPRINT_CLIENT_ID", "blueprint-client-id"),
("A365_BLUEPRINT_CLIENT_SECRET", "blueprint-secret"),
("A365_RUNTIME_AGENT_ID", "runtime-agent-id"),
("A365_ALLOWED_AUDIENCES", "api://aud-a,api://aud-b"),
("A365_OBSERVABILITY_RESOURCE", "observability-resource"),
("A365_REQUIRED_ROLES", "Agent365.Observability.OtelWrite"),
]);

run::provider_create(
&ts.endpoint,
"my-microsoft-agent-s2s",
"microsoft-agent-s2s",
true,
&[],
&[],
&ts.tls,
)
.await
.expect("provider create");

let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls)
.await
.expect("grpc client should connect");
let response = client
.get_provider(GetProviderRequest {
name: "my-microsoft-agent-s2s".to_string(),
})
.await
.expect("get provider should succeed")
.into_inner();
let provider = response.provider.expect("provider should exist");
assert_eq!(provider.r#type, "microsoft-agent-s2s");
assert_eq!(
provider.credentials.get("AZURE_TENANT_ID"),
Some(&"tenant-id".to_string())
);
assert_eq!(
provider.credentials.get("A365_BLUEPRINT_CLIENT_ID"),
Some(&"blueprint-client-id".to_string())
);
assert_eq!(
provider.credentials.get("A365_BLUEPRINT_CLIENT_SECRET"),
Some(&"blueprint-secret".to_string())
);
assert_eq!(
provider.credentials.get("A365_RUNTIME_AGENT_ID"),
Some(&"runtime-agent-id".to_string())
);
assert_eq!(
provider.credentials.get("A365_ALLOWED_AUDIENCES"),
Some(&"api://aud-a,api://aud-b".to_string())
);
assert_eq!(
provider.credentials.get("A365_OBSERVABILITY_RESOURCE"),
Some(&"observability-resource".to_string())
);
assert_eq!(
provider.credentials.get("A365_REQUIRED_ROLES"),
Some(&"Agent365.Observability.OtelWrite".to_string())
);
}

#[tokio::test]
async fn provider_create_rejects_combined_from_existing_and_credentials() {
let ts = run_server().await;
Expand All @@ -603,7 +686,7 @@ async fn provider_create_rejects_combined_from_existing_and_credentials() {
#[tokio::test]
async fn provider_create_rejects_empty_env_var_for_key_only_credential() {
let ts = run_server().await;
let _guard = EnvVarGuard::set("NAV_EMPTY_ENV_KEY", "");
let _guard = EnvVarGuard::set(&[("NAV_EMPTY_ENV_KEY", "")]);

let err = run::provider_create(
&ts.endpoint,
Expand All @@ -627,7 +710,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() {
#[tokio::test]
async fn provider_create_supports_nvidia_type_with_nvidia_api_key() {
let ts = run_server().await;
let _guard = EnvVarGuard::set("NVIDIA_API_KEY", "nvapi-live-test");
let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "nvapi-live-test")]);

run::provider_create(
&ts.endpoint,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
let _env = test_env(&fake_ssh_dir, &xdg_dir);
let tls = test_tls(&server);
install_fake_ssh(&fake_ssh_dir);
let forward_port = unused_local_port();

run::sandbox_create(
&server.endpoint,
Expand All @@ -732,7 +733,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
None,
&[],
None,
Some(openshell_core::forward::ForwardSpec::new(8080)),
Some(openshell_core::forward::ForwardSpec::new(forward_port)),
&["echo".to_string(), "OK".to_string()],
Some(false),
Some(false),
Expand All @@ -744,3 +745,8 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {

assert!(deleted_names(&server).await.is_empty());
}

fn unused_local_port() -> u16 {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
listener.local_addr().unwrap().port()
}
23 changes: 23 additions & 0 deletions crates/openshell-provider-auth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[package]
name = "openshell-provider-auth"
description = "Runtime provider authentication brokers for OpenShell"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
base64 = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }

[lints]
workspace = true
6 changes: 6 additions & 0 deletions crates/openshell-provider-auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Runtime authentication brokers for `OpenShell` providers.

pub mod microsoft_s2s;
Loading
Loading