Skip to main content

algocline_app/
service.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use algocline_core::QueryId;
5use algocline_engine::{Executor, SessionRegistry};
6
7// ─── Parameter types (MCP-independent) ──────────────────────────
8
9/// A single query response in a batch feed.
10#[derive(Debug)]
11pub struct QueryResponse {
12    /// Query ID (e.g. "q-0", "q-1").
13    pub query_id: String,
14    /// The host LLM's response for this query.
15    pub response: String,
16}
17
18// ─── Code resolution ────────────────────────────────────────────
19
20pub(crate) fn resolve_code(
21    code: Option<String>,
22    code_file: Option<String>,
23) -> Result<String, String> {
24    match (code, code_file) {
25        (Some(c), None) => Ok(c),
26        (None, Some(path)) => std::fs::read_to_string(Path::new(&path))
27            .map_err(|e| format!("Failed to read {path}: {e}")),
28        (Some(_), Some(_)) => Err("Provide either `code` or `code_file`, not both.".into()),
29        (None, None) => Err("Either `code` or `code_file` must be provided.".into()),
30    }
31}
32
33/// Build Lua code that loads a package by name and calls `pkg.run(ctx)`.
34///
35/// # Security: `name` is not sanitized
36///
37/// `name` is interpolated directly into a Lua `require()` call without
38/// sanitization. This is intentional in the current architecture:
39///
40/// - algocline is a **local development/execution tool** that runs Lua in
41///   the user's own environment via mlua (not a multi-tenant service).
42/// - The same caller has access to `alc_run`, which executes **arbitrary
43///   Lua code**. Sanitizing `name` here would not reduce the attack surface.
44/// - The MCP trust boundary lies at the **host/client** level — the host
45///   decides whether to invoke `alc_advice` at all.
46///
47/// If algocline is extended to a shared backend (e.g. a package registry
48/// server accepting untrusted strategy names), `name` **must** be validated
49/// (allowlist of `[a-zA-Z0-9_-]` or equivalent) before interpolation.
50///
51/// References:
52/// - [MCP Security Best Practices — Local MCP Server Compromise](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices)
53/// - [OWASP MCP Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/MCP_Security_Cheat_Sheet.html)
54pub(crate) fn make_require_code(name: &str) -> String {
55    format!(
56        r#"local pkg = require("{name}")
57return pkg.run(ctx)"#
58    )
59}
60
61pub(crate) fn packages_dir() -> Result<PathBuf, String> {
62    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
63    Ok(home.join(".algocline").join("packages"))
64}
65
66// ─── Application Service ────────────────────────────────────────
67
68#[derive(Clone)]
69pub struct AppService {
70    executor: Arc<Executor>,
71    registry: Arc<SessionRegistry>,
72}
73
74impl AppService {
75    pub fn new(executor: Arc<Executor>) -> Self {
76        Self {
77            executor,
78            registry: Arc::new(SessionRegistry::new()),
79        }
80    }
81
82    /// Execute Lua code with optional JSON context.
83    pub async fn run(
84        &self,
85        code: Option<String>,
86        code_file: Option<String>,
87        ctx: Option<serde_json::Value>,
88    ) -> Result<String, String> {
89        let code = resolve_code(code, code_file)?;
90        let ctx = ctx.unwrap_or(serde_json::Value::Null);
91        self.start_and_tick(code, ctx).await
92    }
93
94    /// Apply a built-in strategy to a task.
95    pub async fn advice(
96        &self,
97        strategy: &str,
98        task: String,
99        opts: Option<serde_json::Value>,
100    ) -> Result<String, String> {
101        let code = make_require_code(strategy);
102
103        let mut ctx_map = match opts {
104            Some(serde_json::Value::Object(m)) => m,
105            _ => serde_json::Map::new(),
106        };
107        ctx_map.insert("task".into(), serde_json::Value::String(task));
108        let ctx = serde_json::Value::Object(ctx_map);
109
110        self.start_and_tick(code, ctx).await
111    }
112
113    /// Continue a paused execution — batch feed.
114    pub async fn continue_batch(
115        &self,
116        session_id: &str,
117        responses: Vec<QueryResponse>,
118    ) -> Result<String, String> {
119        let mut last_result = None;
120        for qr in responses {
121            let qid = QueryId::parse(&qr.query_id);
122            let result = self
123                .registry
124                .feed_response(session_id, &qid, qr.response)
125                .await
126                .map_err(|e| format!("Continue failed: {e}"))?;
127            last_result = Some(result);
128        }
129        let result = last_result.ok_or("Empty responses array")?;
130        Ok(result.to_json(session_id).to_string())
131    }
132
133    /// Continue a paused execution — single response (with optional query_id).
134    pub async fn continue_single(
135        &self,
136        session_id: &str,
137        response: String,
138        query_id: Option<&str>,
139    ) -> Result<String, String> {
140        let query_id = match query_id {
141            Some(qid) => QueryId::parse(qid),
142            None => QueryId::single(),
143        };
144
145        let result = self
146            .registry
147            .feed_response(session_id, &query_id, response)
148            .await
149            .map_err(|e| format!("Continue failed: {e}"))?;
150
151        Ok(result.to_json(session_id).to_string())
152    }
153
154    // ─── Package Management ─────────────────────────────────────
155
156    /// List installed packages with metadata.
157    pub async fn pkg_list(&self) -> Result<String, String> {
158        let pkg_dir = packages_dir()?;
159        if !pkg_dir.is_dir() {
160            return Ok(serde_json::json!({ "packages": [] }).to_string());
161        }
162
163        let mut packages = Vec::new();
164        let entries =
165            std::fs::read_dir(&pkg_dir).map_err(|e| format!("Failed to read packages dir: {e}"))?;
166
167        for entry in entries.flatten() {
168            let path = entry.path();
169            if !path.is_dir() {
170                continue;
171            }
172            let init_lua = path.join("init.lua");
173            if !init_lua.exists() {
174                continue;
175            }
176            let name = entry.file_name().to_string_lossy().to_string();
177            let code = format!(
178                r#"local pkg = require("{name}")
179return pkg.meta or {{ name = "{name}" }}"#
180            );
181            match self.executor.eval_simple(code).await {
182                Ok(meta) => packages.push(meta),
183                Err(_) => {
184                    packages
185                        .push(serde_json::json!({ "name": name, "error": "failed to load meta" }));
186                }
187            }
188        }
189
190        Ok(serde_json::json!({ "packages": packages }).to_string())
191    }
192
193    /// Install a package from a Git URL or local path.
194    pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
195        let pkg_dir = packages_dir()?;
196        let _ = std::fs::create_dir_all(&pkg_dir);
197
198        let name = name.unwrap_or_else(|| {
199            url.trim_end_matches('/')
200                .rsplit('/')
201                .next()
202                .unwrap_or("unknown")
203                .trim_end_matches(".git")
204                .to_string()
205        });
206
207        let dest = pkg_dir.join(&name);
208        if dest.exists() {
209            return Err(format!(
210                "Package '{name}' already exists at {}. Remove it first.",
211                dest.display()
212            ));
213        }
214
215        // Normalize URL: add https:// only for bare domain-style URLs
216        let git_url = if url.starts_with("http://")
217            || url.starts_with("https://")
218            || url.starts_with("file://")
219            || url.starts_with("git@")
220            || url.starts_with('/')
221        {
222            url.clone()
223        } else {
224            format!("https://{url}")
225        };
226
227        let output = tokio::process::Command::new("git")
228            .args(["clone", "--depth", "1", &git_url, &dest.to_string_lossy()])
229            .output()
230            .await
231            .map_err(|e| format!("Failed to run git: {e}"))?;
232
233        if !output.status.success() {
234            let stderr = String::from_utf8_lossy(&output.stderr);
235            return Err(format!("git clone failed: {stderr}"));
236        }
237
238        // Verify init.lua exists
239        if !dest.join("init.lua").exists() {
240            let _ = std::fs::remove_dir_all(&dest);
241            return Err(format!(
242                "Package '{name}' has no init.lua at root. Not a valid algocline package."
243            ));
244        }
245
246        // Remove .git dir to save space
247        let _ = std::fs::remove_dir_all(dest.join(".git"));
248
249        Ok(serde_json::json!({
250            "installed": name,
251            "path": dest.to_string_lossy(),
252        })
253        .to_string())
254    }
255
256    /// Remove an installed package.
257    pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
258        let pkg_dir = packages_dir()?;
259        let dest = pkg_dir.join(name);
260
261        if !dest.exists() {
262            return Err(format!("Package '{name}' not found"));
263        }
264
265        // Safety: only remove within ~/.algocline/packages/
266        let canonical = dest
267            .canonicalize()
268            .map_err(|e| format!("Path error: {e}"))?;
269        let pkg_canonical = pkg_dir
270            .canonicalize()
271            .map_err(|e| format!("Path error: {e}"))?;
272        if !canonical.starts_with(&pkg_canonical) {
273            return Err("Path traversal detected".to_string());
274        }
275
276        std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
277
278        Ok(serde_json::json!({ "removed": name }).to_string())
279    }
280
281    // ─── Internal ───────────────────────────────────────────────
282
283    async fn start_and_tick(&self, code: String, ctx: serde_json::Value) -> Result<String, String> {
284        let session = self.executor.start_session(code, ctx).await?;
285        let (session_id, result) = self
286            .registry
287            .start_execution(session)
288            .await
289            .map_err(|e| format!("Execution failed: {e}"))?;
290        Ok(result.to_json(&session_id).to_string())
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::io::Write;
298
299    // ─── resolve_code tests ───
300
301    #[test]
302    fn resolve_code_inline() {
303        let result = resolve_code(Some("return 1".into()), None);
304        assert_eq!(result.unwrap(), "return 1");
305    }
306
307    #[test]
308    fn resolve_code_from_file() {
309        let mut tmp = tempfile::NamedTempFile::new().unwrap();
310        write!(tmp, "return 42").unwrap();
311
312        let result = resolve_code(None, Some(tmp.path().to_string_lossy().into()));
313        assert_eq!(result.unwrap(), "return 42");
314    }
315
316    #[test]
317    fn resolve_code_both_provided_error() {
318        let result = resolve_code(Some("code".into()), Some("file.lua".into()));
319        let err = result.unwrap_err();
320        assert!(err.contains("not both"), "error: {err}");
321    }
322
323    #[test]
324    fn resolve_code_neither_provided_error() {
325        let result = resolve_code(None, None);
326        let err = result.unwrap_err();
327        assert!(err.contains("must be provided"), "error: {err}");
328    }
329
330    #[test]
331    fn resolve_code_nonexistent_file_error() {
332        let result = resolve_code(
333            None,
334            Some("/tmp/algocline_nonexistent_test_file.lua".into()),
335        );
336        assert!(result.is_err());
337    }
338
339    // ─── make_require_code tests ───
340
341    #[test]
342    fn make_require_code_basic() {
343        let code = make_require_code("explore");
344        assert!(code.contains(r#"require("explore")"#), "code: {code}");
345        assert!(code.contains("pkg.run(ctx)"), "code: {code}");
346    }
347
348    #[test]
349    fn make_require_code_different_names() {
350        for name in &["panel", "chain", "ensemble", "verify"] {
351            let code = make_require_code(name);
352            assert!(
353                code.contains(&format!(r#"require("{name}")"#)),
354                "code for {name}: {code}"
355            );
356        }
357    }
358
359    // ─── packages_dir tests ───
360
361    #[test]
362    fn packages_dir_ends_with_expected_path() {
363        let dir = packages_dir().unwrap();
364        assert!(
365            dir.ends_with(".algocline/packages"),
366            "dir: {}",
367            dir.display()
368        );
369    }
370}