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
8 changes: 8 additions & 0 deletions crates/openshell-providers/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

pub trait DiscoveryContext {
fn env_var(&self, key: &str) -> Option<String>;

/// 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;
Expand Down
29 changes: 29 additions & 0 deletions crates/openshell-providers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"),
Expand All @@ -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()
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
104 changes: 104 additions & 0 deletions crates/openshell-providers/src/providers/docker_agent.rs
Original file line number Diff line number Diff line change
@@ -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<DiscoveredProvider> {
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<Option<DiscoveredProvider>, 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"
);
}
}
1 change: 1 addition & 0 deletions crates/openshell-providers/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion crates/openshell-providers/src/test_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
paths: HashSet<String>,
}

impl MockDiscoveryContext {
Expand All @@ -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<String> {
self.env.get(key).cloned()
}

fn path_exists(&self, path: &str) -> bool {
self.paths.contains(path)
}
}
37 changes: 37 additions & 0 deletions providers/docker-agent.yaml
Original file line number Diff line number Diff line change
@@ -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]
Loading