Skip to main content

netget/client/dbus/
actions.rs

1//! What the model can do on a D-Bus connection: call methods and read the replies, own names,
2//! subscribe to signals, emit signals, and answer the calls other clients make to it.
3use 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
18/// How long a call waits for its reply, by default (libdbus's own default is 25 s).
19pub 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
293/// A call to validate answer actions against (the shape matters, not the values).
294pub 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}