1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::{ConnectContext, EventType};
9use crate::server::dbus::actions::{
10 action, error_action, p, return_action, signal_action, VALUES_HELP,
11};
12use crate::server::dbus::wire;
13use crate::state::app_state::AppState;
14use anyhow::{bail, ensure, Context, Result};
15use serde_json::{json, Value};
16use std::sync::LazyLock;
17
18pub const DEFAULT_TIMEOUT_MS: u64 = 25_000;
20pub const DEFAULT_BUS: bool = true;
21
22#[derive(Default)]
23pub struct DbusClientProtocol;
24impl DbusClientProtocol {
25 pub fn new() -> Self {
26 Self
27 }
28}
29
30fn call_action() -> ActionDefinition {
31 action(
32 "dbus_call",
33 "Call a method; the answer arrives as dbus_reply or dbus_error_reply.",
34 vec![
35 p("destination", "string", "Bus name to call, e.g. org.freedesktop.DBus or a service's name (omit on a peer-to-peer connection)", false),
36 p("path", "string", "Object path, e.g. /org/freedesktop/DBus", true),
37 p("interface", "string", "Interface, e.g. org.freedesktop.DBus", false),
38 p("member", "string", "Method name, e.g. ListNames (letters, digits, underscore)", true),
39 p("signature", "string", "Signature of args (empty for none)", false),
40 p("args", "array", VALUES_HELP, false),
41 ],
42 json!({"type":"dbus_call","destination":"org.freedesktop.DBus","path":"/org/freedesktop/DBus","interface":"org.freedesktop.DBus","member":"ListNames"}),
43 )
44}
45
46fn request_name_action() -> ActionDefinition {
47 action(
48 "dbus_request_name",
49 "Own a well-known bus name, so other clients can call you; the bus's answer arrives as dbus_reply (1 primary owner, 3 exists, 4 already owner).",
50 vec![p("name", "string", "The name, e.g. net.netget.Demo", true)],
51 json!({"type":"dbus_request_name","name":"net.netget.Demo"}),
52 )
53}
54
55fn add_match_action() -> ActionDefinition {
56 action(
57 "dbus_add_match",
58 "Subscribe to signals matching a rule; each arrives as dbus_signal.",
59 vec![p(
60 "rule",
61 "string",
62 "Match rule, e.g. type='signal',interface='net.example.Thing'",
63 true,
64 )],
65 json!({"type":"dbus_add_match","rule":"type='signal',interface='org.freedesktop.DBus'"}),
66 )
67}
68
69fn disconnect_action() -> ActionDefinition {
70 action(
71 "disconnect",
72 "Close the connection; the bus releases every name it owned.",
73 vec![],
74 json!({"type":"disconnect"}),
75 )
76}
77
78pub fn actions() -> Vec<ActionDefinition> {
79 vec![
80 call_action(),
81 request_name_action(),
82 add_match_action(),
83 signal_action(),
84 disconnect_action(),
85 ]
86}
87
88fn answering_actions() -> Vec<ActionDefinition> {
89 let mut all = vec![return_action(), error_action()];
90 all.extend(actions());
91 all
92}
93
94fn ev(
95 id: &str,
96 description: &str,
97 example: Value,
98 parameters: Vec<crate::llm::actions::Parameter>,
99 answering: bool,
100) -> EventType {
101 EventType::new(id, description, example)
102 .with_parameters(parameters)
103 .with_actions(if answering {
104 answering_actions()
105 } else {
106 actions()
107 })
108}
109
110pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
111 ev(
112 "dbus_connected",
113 "Authenticated, and registered with the bus unless the connection is peer-to-peer.",
114 call_action().example.clone(),
115 vec![
116 p(
117 "unique_name",
118 "string",
119 "The unique name the bus assigned, e.g. :1.42",
120 false,
121 ),
122 p(
123 "server_guid",
124 "string",
125 "The server's GUID from authentication",
126 true,
127 ),
128 p("mechanism", "string", "EXTERNAL or ANONYMOUS", true),
129 ],
130 false,
131 )
132});
133
134pub static REPLY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
135 ev(
136 "dbus_reply",
137 "A method you called returned.",
138 call_action().example.clone(),
139 vec![
140 p(
141 "destination",
142 "string",
143 "The bus name the call was addressed to",
144 false,
145 ),
146 p(
147 "member",
148 "string",
149 "The method that was called, e.g. Greet",
150 true,
151 ),
152 p(
153 "signature",
154 "string",
155 "D-Bus type signature of values, e.g. \"as\"",
156 true,
157 ),
158 p(
159 "values",
160 "array",
161 "The returned values as JSON; variants as {signature, value}",
162 true,
163 ),
164 ],
165 false,
166 )
167});
168
169pub static ERROR_REPLY_EVENT: LazyLock<EventType> =
170 LazyLock::new(|| {
171 ev(
172 "dbus_error_reply",
173 "A method you called failed, or got no reply in time (org.freedesktop.DBus.Error.NoReply).",
174 call_action().example.clone(),
175 vec![
176 p("destination", "string", "The bus name the call was addressed to", false),
177 p("member", "string", "The method that was called, e.g. Greet", true),
178 p("name", "string", "The D-Bus error name", true),
179 p("message", "string", "The human-readable text the error carried", false),
180 ],
181 false,
182 )
183 });
184
185pub static SIGNAL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
186 ev(
187 "dbus_signal",
188 "A signal arrived (one you subscribed to with dbus_add_match, or one addressed to you).",
189 call_action().example.clone(),
190 vec![
191 p("sender", "string", "The emitter's unique name", false),
192 p(
193 "path",
194 "string",
195 "Object path, e.g. /org/freedesktop/DBus",
196 true,
197 ),
198 p(
199 "interface",
200 "string",
201 "Interface name in dotted form, e.g. org.freedesktop.DBus",
202 true,
203 ),
204 p(
205 "member",
206 "string",
207 "Signal name, e.g. Changed (letters, digits, underscore)",
208 true,
209 ),
210 p(
211 "signature",
212 "string",
213 "D-Bus type signature of args, e.g. \"si\"",
214 true,
215 ),
216 p("args", "array", "The arguments as JSON", true),
217 ],
218 false,
219 )
220});
221
222pub static INCOMING_CALL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
223 ev(
224 "dbus_method_call",
225 "Another client called a method on you. Answer with dbus_return or dbus_error unless no_reply_expected.",
226 return_action().example.clone(),
227 vec![
228 p("sender", "string", "The caller's unique name", false),
229 p("destination", "string", "The name it addressed", false),
230 p("path", "string", "Object path, e.g. /org/freedesktop/DBus", true),
231 p("interface", "string", "Interface, when named", false),
232 p("member", "string", "Method name, e.g. ListNames (letters, digits, underscore)", true),
233 p("signature", "string", "D-Bus type signature of args, e.g. \"si\"", true),
234 p("args", "array", "The arguments as JSON", true),
235 p("no_reply_expected", "boolean", "The caller wants no answer", true),
236 ],
237 true,
238 )
239});
240
241pub fn check(v: &Value) -> Result<()> {
242 let s = |k: &str| {
243 v[k].as_str()
244 .with_context(|| format!("{k} must be a string"))
245 };
246 match v["type"].as_str().unwrap_or_default() {
247 "dbus_call" => {
248 ensure!(
249 wire::valid_object_path(s("path")?),
250 "path is not a D-Bus object path"
251 );
252 ensure!(
253 wire::valid_member(s("member")?),
254 "member is not a D-Bus member name"
255 );
256 if let Some(i) = v.get("interface").and_then(Value::as_str) {
257 ensure!(
258 wire::valid_interface(i),
259 "interface is not a D-Bus interface name"
260 );
261 }
262 if let Some(d) = v.get("destination").and_then(Value::as_str) {
263 ensure!(
264 wire::valid_bus_name(d),
265 "destination is not a D-Bus bus name"
266 );
267 }
268 let args = match v.get("args") {
269 None | Some(Value::Null) => vec![],
270 Some(Value::Array(a)) => a.clone(),
271 Some(_) => bail!("args must be an array"),
272 };
273 wire::marshal(v["signature"].as_str().unwrap_or_default(), &args)?;
274 }
275 "dbus_request_name" => {
276 let n = s("name")?;
277 ensure!(
278 wire::valid_bus_name(n) && !n.starts_with(':'),
279 "{n:?} is not a well-known bus name"
280 );
281 }
282 "dbus_add_match" => {
283 s("rule")?;
284 }
285 "dbus_emit_signal" | "dbus_return" | "dbus_error" => {
286 crate::server::dbus::actions::answer_message(Some(&placeholder_call()), v, None)?;
287 }
288 other => bail!("Unknown D-Bus client action {other:?}"),
289 }
290 Ok(())
291}
292
293pub fn placeholder_call() -> wire::Message {
295 let mut m = wire::Message::call("/", None, "Check");
296 m.serial = 1;
297 m
298}
299
300impl Protocol for DbusClientProtocol {
301 fn protocol_name(&self) -> &'static str {
302 "D-Bus"
303 }
304 fn stack_name(&self) -> &'static str {
305 "ETH>IP>TCP>D-Bus"
306 }
307 fn keywords(&self) -> Vec<&'static str> {
308 vec!["dbus", "d-bus", "session bus", "system bus", "freedesktop"]
309 }
310 fn description(&self) -> &'static str {
311 "D-Bus client: joins a message bus (or a peer), calls methods, owns names and answers calls made to them, emits and receives signals"
312 }
313 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
314 actions()
315 }
316 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
317 vec![return_action(), error_action()]
318 }
319 fn get_event_types(&self) -> Vec<EventType> {
320 vec![
321 CONNECTED_EVENT.clone(),
322 REPLY_EVENT.clone(),
323 ERROR_REPLY_EVENT.clone(),
324 SIGNAL_EVENT.clone(),
325 INCOMING_CALL_EVENT.clone(),
326 ]
327 }
328 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
329 vec![
330 ParameterDefinition {
331 name: "socket_path".into(),
332 type_hint: "string".into(),
333 description: "Connect to this Unix socket (a bus address's unix:path=...) instead of remote_addr".into(),
334 required: false,
335 example: json!("/run/user/1000/bus"),
336 default: None,
337 },
338 ParameterDefinition {
339 name: "bus".into(),
340 type_hint: "boolean".into(),
341 description: "Register with a message bus (Hello) after authenticating; false for a peer-to-peer connection".into(),
342 required: false,
343 example: json!(false),
344 default: Some(json!(DEFAULT_BUS)),
345 },
346 ParameterDefinition {
347 name: "timeout_ms".into(),
348 type_hint: "number".into(),
349 description: "Milliseconds a call waits for its reply (100-120000)".into(),
350 required: false,
351 example: json!(5000),
352 default: Some(json!(DEFAULT_TIMEOUT_MS)),
353 },
354 ]
355 }
356 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
357 use crate::protocol::metadata::*;
358 ProtocolMetadataV2::builder()
359 .state(DevelopmentState::Experimental)
360 .privilege_requirement(PrivilegeRequirement::None)
361 .implementation("The D-Bus server's wire codec (src/server/dbus/wire.rs) over a Unix socket or TCP: SASL EXTERNAL (as the process's uid) then ANONYMOUS, Hello, calls matched to replies by serial with a timeout, incoming calls answered by the model and failed closed when it does not, signals both ways")
362 .llm_control("Which methods to call with which arguments, which names to own and signals to subscribe to, and the answer to every call made to it")
363 .e2e_testing("tests/client/dbus: dbus-daemon (the reference bus) with a python dbus-next service, dbus-send calling the names NetGet owns, and dbus-monitor; NetGet's own D-Bus server for the peer-to-peer path")
364 .notes("No Unix file descriptors and no DBUS_COOKIE_SHA1, so a TCP bus must allow ANONYMOUS. At most 64 calls await replies at once. Messages are bounded at 1 MiB and nesting at 32 like the server. Model turns follow from one another at most eight deep.")
365 .max_inbound_bytes(wire::MAX_MESSAGE_BYTES)
366 .build()
367 }
368 fn example_prompt(&self) -> &'static str {
369 "Connect to the session bus at /run/user/1000/bus, list the bus names, and own net.netget.Demo answering Ping with pong"
370 }
371 fn get_startup_examples(&self) -> StartupExamples {
372 let llm = json!({"type":"open_client","protocol":"dbus","remote_addr":"","startup_params":{"socket_path":"/run/user/1000/bus"},
373 "instruction":"List the names on the bus, then own net.netget.Demo and answer Ping with pong"});
374 let mut static_example = llm.clone();
375 static_example["event_handlers"] = json!([
376 {"event_pattern":"dbus_connected","handler":{"type":"static","actions":[{"type":"dbus_request_name","name":"net.netget.Demo"}]}},
377 {"event_pattern":"dbus_method_call","handler":{"type":"static","actions":[{"type":"dbus_return","signature":"s","values":["pong"]}]}}
378 ]);
379 let mut scripted = static_example.clone();
380 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'dbus_return','signature':'s','values':['pong']}] if e['member']=='Ping' else [{'type':'dbus_error','name':'org.freedesktop.DBus.Error.UnknownMethod','message':e['member']}]}))"});
381 StartupExamples::new(llm, scripted, static_example)
382 }
383 fn group_name(&self) -> &'static str {
384 "Application"
385 }
386}
387
388impl Client for DbusClientProtocol {
389 fn connect(
390 &self,
391 ctx: ConnectContext,
392 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
393 {
394 Box::pin(super::connect(ctx))
395 }
396 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
397 if v["type"] == "disconnect" {
398 return Ok(ClientActionResult::Disconnect);
399 }
400 check(&v)?;
401 Ok(ClientActionResult::Custom {
402 name: v["type"].as_str().unwrap_or_default().to_string(),
403 data: v,
404 })
405 }
406}