diff --git a/crates/openshell-providers/src/context.rs b/crates/openshell-providers/src/context.rs index b35c28d10..743b6b522 100644 --- a/crates/openshell-providers/src/context.rs +++ b/crates/openshell-providers/src/context.rs @@ -3,6 +3,14 @@ pub trait DiscoveryContext { fn env_var(&self, key: &str) -> Option; + + /// Return `true` if the filesystem path exists. + /// + /// The default implementation calls [`std::path::Path::exists`]. + /// Tests override this via [`crate::test_helpers::MockDiscoveryContext`]. + fn path_exists(&self, path: &str) -> bool { + std::path::Path::new(path).exists() + } } pub struct RealDiscoveryContext; diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index 3b28030ca..8011a0753 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -84,6 +84,7 @@ impl ProviderRegistry { registry.register(providers::claude::ClaudeProvider); registry.register(providers::codex::CodexProvider); registry.register(providers::copilot::CopilotProvider); + registry.register(providers::docker_agent::DockerAgentProvider); registry.register(providers::opencode::OpencodeProvider); registry.register(providers::generic::GenericProvider); registry.register(providers::openai::OpenaiProvider); @@ -146,6 +147,7 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> { "claude" => Some("claude"), "codex" => Some("codex"), "copilot" => Some("copilot"), + "docker-agent" | "docker_agent" => Some("docker-agent"), "opencode" => Some("opencode"), "generic" => Some("generic"), "openai" => Some("openai"), @@ -160,6 +162,18 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> { #[must_use] pub fn detect_provider_from_command(command: &[String]) -> Option<&'static str> { + // Special case: `docker agent [...]` maps to the docker-agent provider. + // The binary name alone is just `docker`, which would not match. + if let (Some(first), Some(second)) = (command.first(), command.get(1)) { + let first_base = Path::new(first.as_str()) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(first.as_str()); + if first_base.eq_ignore_ascii_case("docker") && second.eq_ignore_ascii_case("agent") { + return Some("docker-agent"); + } + } + let first = command.first()?; let basename = Path::new(first) .file_name() @@ -214,5 +228,20 @@ mod tests { detect_provider_from_command(&["gh".to_string()]), Some("github") ); + // `docker agent` sub-command maps to docker-agent + assert_eq!( + detect_provider_from_command(&["docker".to_string(), "agent".to_string()]), + Some("docker-agent") + ); + assert_eq!( + detect_provider_from_command(&[ + "/usr/bin/docker".to_string(), + "agent".to_string(), + "run".to_string(), + ]), + Some("docker-agent") + ); + // plain `docker` without `agent` sub-command does not match docker-agent + assert_eq!(detect_provider_from_command(&["docker".to_string()]), None); } } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 588e77702..1be9f3c72 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -19,6 +19,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/claude.yaml"), include_str!("../../../providers/codex.yaml"), include_str!("../../../providers/copilot.yaml"), + include_str!("../../../providers/docker-agent.yaml"), include_str!("../../../providers/github.yaml"), include_str!("../../../providers/gitlab.yaml"), include_str!("../../../providers/nvidia.yaml"), diff --git a/crates/openshell-providers/src/providers/docker_agent.rs b/crates/openshell-providers/src/providers/docker_agent.rs new file mode 100644 index 000000000..2e65fee5d --- /dev/null +++ b/crates/openshell-providers/src/providers/docker_agent.rs @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::{ + DiscoveredProvider, DiscoveryContext, ProviderDiscoverySpec, ProviderError, ProviderPlugin, + RealDiscoveryContext, +}; + +pub struct DockerAgentProvider; + +pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { + id: "docker-agent", + credential_env_vars: &["DOCKER_ACCESS_TOKEN"], +}; + +/// Known locations of the Docker binary. +/// +/// Discovery succeeds when any of these paths exists, even without a token, +/// because `DOCKER_ACCESS_TOKEN` is optional (public Docker Hub and the local +/// Model Runner work without one). +const DOCKER_BINARIES: &[&str] = &[ + "/usr/bin/docker", + "/usr/local/bin/docker", + "/usr/bin/docker-agent", + "/usr/local/bin/docker-agent", +]; + +pub fn discover_docker_agent( + spec: &ProviderDiscoverySpec, + context: &dyn DiscoveryContext, +) -> Option { + let mut discovered = DiscoveredProvider::default(); + + for key in spec.credential_env_vars { + if let Some(value) = context.env_var(key) + && !value.trim().is_empty() + { + discovered + .credentials + .entry((*key).to_string()) + .or_insert(value); + } + } + + // Credentials are optional; treat the provider as discovered whenever a + // docker binary is present so that the policy always gets applied. + if !discovered.is_empty() || DOCKER_BINARIES.iter().any(|p| context.path_exists(p)) { + Some(discovered) + } else { + None + } +} + +impl ProviderPlugin for DockerAgentProvider { + fn id(&self) -> &'static str { + SPEC.id + } + + fn discover_existing(&self) -> Result, ProviderError> { + Ok(discover_docker_agent(&SPEC, &RealDiscoveryContext)) + } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } +} + +#[cfg(test)] +mod tests { + use super::{DOCKER_BINARIES, SPEC, discover_docker_agent}; + use crate::test_helpers::MockDiscoveryContext; + + #[test] + fn discovers_docker_agent_hub_token() { + let ctx = + MockDiscoveryContext::new().with_env("DOCKER_ACCESS_TOKEN", "dckr_pat_test_token"); + let discovered = discover_docker_agent(&SPEC, &ctx).expect("provider"); + assert_eq!( + discovered.credentials.get("DOCKER_ACCESS_TOKEN"), + Some(&"dckr_pat_test_token".to_string()) + ); + } + + #[test] + fn discovers_docker_agent_without_token_when_binary_present() { + // No DOCKER_ACCESS_TOKEN set, but docker binary exists. + let ctx = MockDiscoveryContext::new().with_path(DOCKER_BINARIES[0]); + let discovered = discover_docker_agent(&SPEC, &ctx) + .expect("provider should be found when binary present"); + assert!( + discovered.credentials.is_empty(), + "no credentials expected when token is absent" + ); + } + + #[test] + fn no_discovery_without_token_or_binary() { + let ctx = MockDiscoveryContext::new(); + assert!( + discover_docker_agent(&SPEC, &ctx).is_none(), + "should not discover when neither token nor binary is present" + ); + } +} diff --git a/crates/openshell-providers/src/providers/mod.rs b/crates/openshell-providers/src/providers/mod.rs index 6fe395135..ad2fac967 100644 --- a/crates/openshell-providers/src/providers/mod.rs +++ b/crates/openshell-providers/src/providers/mod.rs @@ -5,6 +5,7 @@ pub mod anthropic; pub mod claude; pub mod codex; pub mod copilot; +pub mod docker_agent; pub mod generic; pub mod github; pub mod gitlab; diff --git a/crates/openshell-providers/src/test_helpers.rs b/crates/openshell-providers/src/test_helpers.rs index 68108389e..ff2d3d170 100644 --- a/crates/openshell-providers/src/test_helpers.rs +++ b/crates/openshell-providers/src/test_helpers.rs @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 use crate::DiscoveryContext; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Default)] pub struct MockDiscoveryContext { env: HashMap, + paths: HashSet, } impl MockDiscoveryContext { @@ -18,10 +19,19 @@ impl MockDiscoveryContext { self.env.insert(key.to_string(), value.to_string()); self } + + pub fn with_path(mut self, path: &str) -> Self { + self.paths.insert(path.to_string()); + self + } } impl DiscoveryContext for MockDiscoveryContext { fn env_var(&self, key: &str) -> Option { self.env.get(key).cloned() } + + fn path_exists(&self, path: &str) -> bool { + self.paths.contains(path) + } } diff --git a/providers/docker-agent.yaml b/providers/docker-agent.yaml new file mode 100644 index 000000000..211c70407 --- /dev/null +++ b/providers/docker-agent.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: docker-agent +display_name: Docker Agent +description: Docker AI agent runner (docker agent) +category: agent +inference_capable: false +credentials: + - name: hub_token + description: Docker Hub token for pulling agent images from OCI registries + env_vars: [DOCKER_ACCESS_TOKEN] + required: false + auth_style: bearer + header_name: authorization +endpoints: + - host: registry-1.docker.io + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: auth.docker.io + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: hub.docker.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce + - host: model-runner.docker.internal + port: 80 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/docker, /usr/local/bin/docker, /usr/bin/docker-agent, /usr/local/bin/docker-agent]