1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::{log_template::LogTemplate, ConnectContext, EventType};
7use crate::state::AppState;
8use anyhow::{bail, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11#[derive(Default)]
12pub struct DockerClientProtocol;
13impl DockerClientProtocol {
14 pub fn new() -> Self {
15 Self
16 }
17}
18fn p(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
19 Parameter {
20 name: name.into(),
21 type_hint: kind.into(),
22 description: description.into(),
23 required,
24 }
25}
26fn request() -> ActionDefinition {
27 ActionDefinition{name:"docker_request".into(),description:"Read one selected Docker Engine API resource using the negotiated API version. No arbitrary URLs, HTTP methods, request bodies or mutations.".into(),parameters:vec![
28 p("operation","string","ping, version, info, containers, container, images, networks, or volumes",true),
29 p("container_id","string","Required ID or name for container inspect; no slashes",false),
30 p("all","boolean","containers/images: include stopped containers or intermediate images; daemon default false",false),
31 p("limit","integer","containers: most recent 1..1000 including stopped",false),
32 p("size","boolean","containers/container: request filesystem sizes",false),
33 p("digests","boolean","images: include repository digests",false),
34 p("filters","object","containers/images/networks/volumes: Docker filter name to string arrays, JSON encoded by client",false)
35],example:json!({"type":"docker_request","operation":"containers","all":true,"filters":{"label":["netget.fixture=1"]}}),log_template:Some(LogTemplate::new().with_info("Read Docker {operation}"))}
36}
37fn disconnect() -> ActionDefinition {
38 ActionDefinition {
39 name: "disconnect".into(),
40 description: "Cancel pending I/O and close this logical client".into(),
41 parameters: vec![],
42 example: json!({"type":"disconnect"}),
43 log_template: Some(LogTemplate::new().with_info("Disconnect Docker client")),
44 }
45}
46fn actions() -> Vec<ActionDefinition> {
47 vec![request(), disconnect()]
48}
49fn event(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
50 EventType::new(id, description, request().example)
51 .with_parameters(params)
52 .with_actions(actions())
53}
54pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
55 event("docker_connected","Daemon HEAD /_ping completed; request a read operation",vec![p("endpoint","string","HTTP origin or native Unix socket URI",true),p("api_version","string","Selected min(daemon maximum, preferred client ceiling)",true),p("daemon","object","Ping API maximum, optional os_type and experimental headers; ping does not advertise minimum API",true)])
56});
57pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58 event("docker_response","Complete typed response; unknown extension fields are omitted",vec![p("request","object","Originating typed action",true),p("operation","string","Selected Docker Engine read operation",true),p("api_version","string","Negotiated request version",true),p("status","integer","Successful Docker Engine HTTP response status (200)",true),p("data","object|array","Snake case selected native response fields. Lists for containers/images/networks; container inspect has state/config/host_config/network_settings objects; volumes object has nullable volumes/warnings. Container/image created timestamps are Unix seconds; inspect/network/volume dates remain RFC3339 strings. Native nulls remain null; image shared_size/containers and volume usage may use -1 for unknown. Ports use protocol and optional public_port/ip. No protocol database.",true)])
59});
60pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
61 event(
62 "docker_request_error",
63 "Request failed; no partial response is accepted, client remains available",
64 vec![
65 p("request", "object", "Originating action", true),
66 p("api_version", "string", "Negotiated version", true),
67 p(
68 "status",
69 "integer|null",
70 "HTTP status when response received",
71 true,
72 ),
73 p("category", "string", "http, schema, or transport", true),
74 p(
75 "error",
76 "string",
77 "Docker JSON message or validation/transport/deadline failure",
78 true,
79 ),
80 ],
81 )
82});
83impl Protocol for DockerClientProtocol {
84 fn protocol_name(&self) -> &'static str {
85 "Docker"
86 }
87 fn stack_name(&self) -> &'static str {
88 "ETH>IP>TCP>HTTP>Docker"
89 }
90 fn keywords(&self) -> Vec<&'static str> {
91 vec!["docker", "engine", "containers"]
92 }
93 fn description(&self) -> &'static str {
94 "Read typed Docker Engine API resources"
95 }
96 fn group_name(&self) -> &'static str {
97 "AI & API"
98 }
99 fn example_prompt(&self) -> &'static str {
100 "List containers from Docker at localhost:2375"
101 }
102 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
103 actions()
104 }
105 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
106 vec![]
107 }
108 fn get_event_types(&self) -> Vec<EventType> {
109 vec![
110 CONNECTED_EVENT.clone(),
111 RESPONSE_EVENT.clone(),
112 ERROR_EVENT.clone(),
113 ]
114 }
115 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
116 vec![
117 ParameterDefinition{name:"api_version".into(),type_hint:"string".into(),description:"Preferred API ceiling1.24..1.47; negotiate down using daemon HEAD /_ping API-Version. A newer daemon minimum can still refuse the selected version with a typed HTTP error.".into(),required:false,example:json!("1.47"),default:Some(json!(super::DEFAULT_API_VERSION))},
118 ParameterDefinition{name:"request_timeout_secs".into(),type_hint:"integer".into(),description:"Whole connect/head/body deadline1..30 seconds for negotiation and each request".into(),required:false,example:json!(10),default:Some(json!(super::DEFAULT_TIMEOUT_SECS))}
119 ]
120 }
121 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
122 use crate::protocol::metadata::*;
123 ProtocolMetadataV2::builder().state(DevelopmentState::Experimental).privilege_requirement(PrivilegeRequirement::None).well_known_port(2375).implementation("Negotiated read-only Engine API over HTTP TCP and native Unix sockets, typed bounded JSON schemas").llm_control("Selected read operations, native Docker filters, typed responses/errors and generic memory").e2e_testing("tests/client/docker: independent Docker daemon over Unix/TCP relay, NetGet pair, handler paths, schema/bounds/cancellation; existing server real Docker CLI tests").notes("Read-only selected API1.24..1.47 fields; newer wire fields omitted. No lifecycle mutations, image pulls, logs/attach/exec streams, Docker contexts, credential files, proxy, redirects, TLS/HTTPS or Windows named pipes. Browser HTTP TCP only. Native unix:///absolute/socket returns unspecified socket metadata0.0.0.0:0; actual endpoint remains in connected event. Body4MiB, arrays4096, object fields256, text16KiB, nesting32/nodes65536; one request, queues8,4 followups. API ping advertises maximum only, minimum incompatibility is a typed daemon error. Existing server read-only501 behavior retained.").max_inbound_bytes(super::schema::MAX_BODY).build()
124 }
125 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
126 let llm = json!({"type":"open_client","base_stack":"docker","protocol":"docker","remote_addr":"127.0.0.1:2375","instruction":"List containers and summarize their states"});
127 let mut fixed = llm.clone();
128 fixed["event_handlers"] = json!([{"event_pattern":"docker_connected","handler":{"type":"static","actions":[{"type":"docker_request","operation":"containers","all":true}]}},{"event_pattern":"*","handler":{"type":"static","actions":[]}}]);
129 let mut script = fixed.clone();
130 script["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json, sys\njson.dump({'actions':[{'type':'docker_request','operation':'containers','all':True}]},sys.stdout)"});
131 crate::llm::actions::StartupExamples::new(llm, script, fixed)
132 }
133}
134impl Client for DockerClientProtocol {
135 fn connect(
136 &self,
137 ctx: ConnectContext,
138 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
139 {
140 Box::pin(super::connect(ctx))
141 }
142 fn execute_action(&self, value: Value) -> Result<ClientActionResult> {
143 match value["type"].as_str() {
144 Some("docker_request") => {
145 super::request(&value, super::DEFAULT_API_VERSION)?;
146 Ok(ClientActionResult::Custom {
147 name: "docker_request".into(),
148 data: value,
149 })
150 }
151 Some("disconnect") => Ok(ClientActionResult::Disconnect),
152 _ => bail!("unknown Docker client action"),
153 }
154 }
155}