Skip to main content

rightkit_agent_tools/backends/
control.rs

1//! Tools for one Right Suite app, reached through its in-app control service
2//! (`rightkit-qa-driver`'s `embedded` server, a `rightkit-daemon` host named
3//! `control-<app>`). Real webview input, DOM/AX query, screenshot, and the app's
4//! own typed commands (`command{name,args}`), all over the user-only socket/pipe
5//! with the service's per-app request allowlist on the far side.
6
7use crate::exposure::{self, GENERIC_READ_METHODS};
8use crate::spec::*;
9use rightkit_daemon::{Client, ServiceError, Suite};
10use serde_json::{json, Value};
11use std::sync::Arc;
12use std::time::Duration;
13
14/// The control methods of the in-app server (`rightkit_qa_driver::embedded::METHODS`).
15pub const CONTROL_METHODS: &[&str] = &[
16    "health",
17    "click",
18    "move",
19    "drag",
20    "wheel",
21    "key",
22    "type",
23    "eval",
24    "dom",
25    "ax",
26    "screenshot",
27    "command",
28];
29
30/// The `app` label this crate's clients present to services (their allowlist key).
31pub const CLIENT_APP: &str = "right-ctl";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum CommandPolicy {
35    /// Any command the service itself allows (still subject to the hard exposure rules).
36    Any,
37    /// Only these command names.
38    Only(Vec<String>),
39}
40
41/// Per-app client-side allowlist: which control methods and app commands agents
42/// may use. The service enforces its own allowlist as well; this one is the
43/// agent-facing layer on top.
44#[derive(Debug, Clone)]
45pub struct AppProfile {
46    pub app: String,
47    /// Service name under `<suite>/runtime/`, default `control-<app>`.
48    pub service: String,
49    pub methods: Vec<String>,
50    pub commands: CommandPolicy,
51    /// Sensitive apps (HeardRight) expose no generic read channel.
52    pub sensitive: bool,
53}
54
55impl AppProfile {
56    /// Every control method; any app command.
57    pub fn standard(app: &str) -> Self {
58        Self {
59            app: app.into(),
60            service: format!("control-{app}"),
61            methods: CONTROL_METHODS.iter().map(|s| s.to_string()).collect(),
62            commands: CommandPolicy::Any,
63            sensitive: false,
64        }
65    }
66
67    /// An app whose own data must not leave through generic channels: `health`
68    /// and an explicit command list only. Commands the hard rules forbid are
69    /// dropped from the list.
70    pub fn sensitive(app: &str, commands: &[&str]) -> Self {
71        let commands = commands
72            .iter()
73            .filter(|c| exposure::forbidden(c).is_none())
74            .map(|c| c.to_string())
75            .collect();
76        Self {
77            app: app.into(),
78            service: format!("control-{app}"),
79            methods: vec!["health".into(), "command".into()],
80            commands: CommandPolicy::Only(commands),
81            sensitive: true,
82        }
83    }
84
85    /// The built-in profile for `app`: HeardRight is sensitive, everything else standard.
86    pub fn for_app(app: &str) -> Self {
87        match app {
88            "heardright" => Self::sensitive(
89                "heardright",
90                &["health", "version", "list_models", "model_status"],
91            ),
92            _ => Self::standard(app),
93        }
94    }
95
96    pub fn with_methods(mut self, methods: &[&str]) -> Self {
97        self.methods = methods.iter().map(|s| s.to_string()).collect();
98        self
99    }
100
101    pub fn with_commands(mut self, policy: CommandPolicy) -> Self {
102        self.commands = policy;
103        self
104    }
105
106    /// Allowlist patterns (see `Toolbox::allow`) that grant exactly this profile.
107    pub fn allow_patterns(&self) -> Vec<String> {
108        let mut out = vec![];
109        for m in &self.methods {
110            if m == "command" {
111                match &self.commands {
112                    CommandPolicy::Any => out.push(format!("{}.command:*", self.app)),
113                    CommandPolicy::Only(names) => {
114                        out.extend(names.iter().map(|n| format!("{}.command:{n}", self.app)))
115                    }
116                }
117            } else {
118                out.push(format!("{}.{m}", self.app));
119            }
120        }
121        out
122    }
123
124    fn method_enabled(&self, m: &str) -> bool {
125        self.methods.iter().any(|x| x == m)
126            && !(self.sensitive && GENERIC_READ_METHODS.contains(&m))
127    }
128}
129
130fn num(desc: &str) -> Value {
131    json!({"type": "number", "description": desc})
132}
133
134/// JSON schema + description of one control method.
135fn method_schema(method: &str) -> (&'static str, Effect, Value) {
136    match method {
137        "health" => ("Is the app's control server up? Returns its status.", Effect::Observe, json!({"type": "object", "properties": {}})),
138        "click" => (
139            "Real click in the webview (viewport CSS pixels, origin top-left).",
140            Effect::Act,
141            json!({"type": "object", "properties": {"x": num("x"), "y": num("y"), "button": {"type": "string", "enum": ["left", "right"]}, "count": {"type": "integer"}}, "required": ["x", "y"]}),
142        ),
143        "move" => ("Move the pointer to x,y (hover).", Effect::Act, json!({"type": "object", "properties": {"x": num("x"), "y": num("y")}, "required": ["x", "y"]})),
144        "drag" => (
145            "Drag from (x1,y1) to (x2,y2).",
146            Effect::Act,
147            json!({"type": "object", "properties": {"x1": num("x1"), "y1": num("y1"), "x2": num("x2"), "y2": num("y2"), "steps": {"type": "integer"}}, "required": ["x1", "y1", "x2", "y2"]}),
148        ),
149        "wheel" => ("Scroll wheel at x,y by dx,dy.", Effect::Act, json!({"type": "object", "properties": {"x": num("x"), "y": num("y"), "dx": num("dx"), "dy": num("dy")}, "required": ["x", "y", "dx", "dy"]})),
150        "key" => ("Press a key chord such as `cmd+k` or `enter`.", Effect::Act, json!({"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]})),
151        "type" => ("Type text into the focused element with real key events.", Effect::Act, json!({"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]})),
152        "eval" => ("Evaluate JavaScript in the webview and return the result.", Effect::Act, json!({"type": "object", "properties": {"js": {"type": "string"}}, "required": ["js"]})),
153        "dom" => ("Query DOM nodes by CSS selector.", Effect::Observe, json!({"type": "object", "properties": {"selector": {"type": "string"}}, "required": ["selector"]})),
154        "ax" => ("Accessibility tree of the app window.", Effect::Observe, json!({"type": "object", "properties": {}})),
155        "screenshot" => (
156            "Screenshot of the webview (password and secret elements are masked). Returns {path,bytes,masked}.",
157            Effect::Capture,
158            json!({"type": "object", "properties": {"mask": {"type": "array"}}}),
159        ),
160        "command" => (
161            "Run one of the app's own typed commands by name; `args` is the command's argument string.",
162            Effect::Act,
163            json!({"type": "object", "properties": {"name": {"type": "string"}, "args": {"type": "string"}}, "required": ["name"]}),
164        ),
165        _ => ("", Effect::Act, json!({"type": "object"})),
166    }
167}
168
169struct ControlTool {
170    spec: ToolSpec,
171    suite: Suite,
172    profile: AppProfile,
173    method: String,
174}
175
176fn map_err(e: ServiceError) -> ToolError {
177    ToolError {
178        code: e.code,
179        message: e.message,
180        retryable: e.retryable,
181    }
182}
183
184impl Tool for ControlTool {
185    fn spec(&self) -> &ToolSpec {
186        &self.spec
187    }
188
189    fn subject(&self, args: &Value) -> Option<String> {
190        (self.method == "command").then(|| args["name"].as_str().unwrap_or("").to_string())
191    }
192
193    fn call(&self, _ctx: &CallCtx, args: Value) -> Result<Value, ToolError> {
194        let params = if self.method == "command" {
195            let name = args["name"].as_str().unwrap_or_default();
196            let allowed = match &self.profile.commands {
197                CommandPolicy::Any => true,
198                CommandPolicy::Only(l) => l.iter().any(|c| c == name),
199            };
200            if !allowed || exposure::forbidden(name).is_some() {
201                return Err(ToolError::new(
202                    ERR_NOT_ALLOWED,
203                    format!("command `{name}` is not exposed for {}", self.profile.app),
204                ));
205            }
206            let a = match &args["args"] {
207                Value::Null => String::new(),
208                Value::String(s) => s.clone(),
209                other => other.to_string(),
210            };
211            json!({"name": name, "args": a})
212        } else {
213            args
214        };
215        let client = Client::connect_with(
216            &self.suite,
217            &self.profile.service,
218            CLIENT_APP,
219            Some(Duration::from_secs(30)),
220        )
221        .map_err(map_err)?;
222        client.call(&self.method, params).map_err(map_err)
223    }
224}
225
226/// Tools for `profile` on `suite`: one per enabled control method, named `<app>.<method>`.
227pub fn control_tools(suite: &Suite, profile: &AppProfile) -> Vec<Arc<dyn Tool>> {
228    CONTROL_METHODS
229        .iter()
230        .filter(|m| profile.method_enabled(m))
231        .map(|m| {
232            let (desc, effect, schema) = method_schema(m);
233            let description = format!("{desc} (app: {})", profile.app);
234            Arc::new(ControlTool {
235                spec: ToolSpec::new(format!("{}.{m}", profile.app), description, effect, schema),
236                suite: suite.clone(),
237                profile: profile.clone(),
238                method: m.to_string(),
239            }) as Arc<dyn Tool>
240        })
241        .collect()
242}