Skip to main content

sparrow/
console.rs

1use secrecy::ExposeSecret;
2use std::collections::HashMap;
3use std::net::SocketAddr;
4use std::process::Stdio;
5use std::sync::{Arc, RwLock};
6use std::time::Duration;
7use tokio::sync::{Mutex, broadcast, mpsc, oneshot};
8
9use crate::agent::AgentStore;
10use crate::auth::{AuthStore, Credential};
11use crate::capabilities::SkillLibrary;
12use crate::config::{Config, ConfigStore, FsConfigStore, ProviderConfig};
13use crate::engine::{ApprovalHandler, ApprovalRequest};
14use crate::event::{Decision, Event};
15use crate::memory::{Memory, MemoryDocKind};
16use crate::plan::ReadOnlyPlan;
17
18// ─── Bind address resolution (security: loopback by default) ────────────────────
19//
20// v0.8.1 D1/D2/D3: the console used to bind `0.0.0.0` unconditionally while
21// `--bind` was parsed but never read, exposing the agent (run/agents/file
22// writes) to the whole LAN. We now bind 127.0.0.1 by default, honour an
23// explicit `--bind`, validate the value, refuse to start over an existing
24// console, and warn loudly when the bind is non-loopback.
25
26/// Resolved bind target plus whether it is reachable beyond loopback.
27#[derive(Debug)]
28pub struct BindTarget {
29    pub addr: SocketAddr,
30    pub is_public: bool,
31}
32
33/// Resolve the bind address from an optional `--bind` value and a port.
34///
35/// - `None` → `127.0.0.1` (loopback, safe default).
36/// - A bare IP (`"0.0.0.0"`, `"192.168.1.5"`, `"::1"`) → that IP.
37/// - Anything containing a port (`"127.0.0.1:9876"`) or not a valid IP → error.
38pub fn resolve_bind_addr(bind: Option<&str>, port: u16) -> anyhow::Result<BindTarget> {
39    use std::net::IpAddr;
40    let ip: IpAddr = match bind.map(str::trim).filter(|s| !s.is_empty()) {
41        None => IpAddr::from([127, 0, 0, 1]),
42        Some(raw) => raw.parse::<IpAddr>().map_err(|_| {
43            anyhow::anyhow!(
44                "--bind attend une adresse IP seule (ex. 127.0.0.1 ou 0.0.0.0), \
45                 pas « {raw} ». Le port se règle avec --port."
46            )
47        })?,
48    };
49    let is_public = !ip.is_loopback();
50    Ok(BindTarget {
51        addr: SocketAddr::new(ip, port),
52        is_public,
53    })
54}
55
56/// Probe `/healthz` on the loopback interface for `port`. Returns true when an
57/// existing Sparrow console already answers there, so the caller can refuse to
58/// start a duplicate instead of dying with a raw OS "address in use" error.
59pub async fn console_already_running(port: u16) -> bool {
60    let url = format!("http://127.0.0.1:{port}/healthz");
61    let client = match reqwest::Client::builder()
62        .timeout(Duration::from_millis(500))
63        .build()
64    {
65        Ok(c) => c,
66        Err(_) => return false,
67    };
68    match client.get(&url).send().await {
69        Ok(resp) => resp.status().is_success(),
70        Err(_) => false,
71    }
72}
73
74// ─── Embedded HTML ─────────────────────────────────────────────────────────────
75//
76// console.html is `include_str!`d into the binary so a release build ships as
77// a single file. The drawback: any edit to console.html requires a fresh
78// `cargo build` — a plain WebView reload (Ctrl+R) re-fetches the same baked-in
79// bytes. To make the WebView dev loop tight, set the env var
80// `SPARROW_CONSOLE_HTML` to a path on disk and that file is served instead.
81
82const CONSOLE_HTML_EMBEDDED: &str = include_str!("../console.html");
83
84fn console_html() -> std::borrow::Cow<'static, str> {
85    if let Ok(path) = std::env::var("SPARROW_CONSOLE_HTML") {
86        if !path.trim().is_empty() {
87            match std::fs::read_to_string(&path) {
88                Ok(contents) => return std::borrow::Cow::Owned(contents),
89                Err(e) => {
90                    tracing::warn!(
91                        "SPARROW_CONSOLE_HTML={} unreadable ({}); falling back to embedded HTML",
92                        path,
93                        e
94                    );
95                }
96            }
97        }
98    }
99    std::borrow::Cow::Borrowed(CONSOLE_HTML_EMBEDDED)
100}
101
102fn looks_like_api_key(value: &str) -> bool {
103    let value = value.trim();
104    value.starts_with("sk-")
105        || value.starts_with("nvapi-")
106        || value.starts_with("gsk_")
107        || value.starts_with("sk-or-")
108        || value.len() > 40 && !value.chars().all(|c| c.is_ascii_uppercase() || c == '_')
109}
110
111// ─── WebView server ────────────────────────────────────────────────────────────
112
113pub struct WebViewServer {
114    addr: SocketAddr,
115    event_tx: broadcast::Sender<Event>,
116    command_tx: Option<mpsc::UnboundedSender<String>>,
117    config: Option<Arc<RwLock<Config>>>,
118    approvals: Option<Arc<WebApprovalBroker>>,
119    skills: Option<Arc<dyn SkillLibrary>>,
120    memory: Option<Arc<dyn Memory>>,
121    agent_store: Option<Arc<dyn AgentStore>>,
122}
123
124impl WebViewServer {
125    #[allow(clippy::too_many_arguments)]
126    pub fn new(
127        addr: SocketAddr,
128        event_tx: broadcast::Sender<Event>,
129        command_tx: Option<mpsc::UnboundedSender<String>>,
130        config: Option<Arc<RwLock<Config>>>,
131        approvals: Option<Arc<WebApprovalBroker>>,
132        skills: Option<Arc<dyn SkillLibrary>>,
133        memory: Option<Arc<dyn Memory>>,
134        agent_store: Option<Arc<dyn AgentStore>>,
135    ) -> Self {
136        Self {
137            addr,
138            event_tx,
139            command_tx,
140            config,
141            approvals,
142            skills,
143            memory,
144            agent_store,
145        }
146    }
147
148    pub async fn serve(&self) -> anyhow::Result<()> {
149        use axum::{
150            Router,
151            extract::{State, ws::WebSocketUpgrade},
152            response::Html,
153            routing::{get, post},
154        };
155
156        let event_tx = self.event_tx.clone();
157
158        // Replay-on-connect buffer: a client that opens (or refreshes) the
159        // cockpit mid-run must see the run so far, not a blank feed. Keep the
160        // current run's public events in a bounded ring (cleared on each
161        // RunStarted) and send it to every new WebSocket before going live.
162        let recent: Arc<parking_lot::Mutex<std::collections::VecDeque<Event>>> =
163            Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::new()));
164        {
165            let recent = recent.clone();
166            let mut brx = event_tx.subscribe();
167            tokio::spawn(async move {
168                const RING_CAP: usize = 800;
169                loop {
170                    match brx.recv().await {
171                        Ok(ev) => {
172                            if !ev.is_public() {
173                                continue;
174                            }
175                            let mut ring = recent.lock();
176                            if matches!(ev, Event::RunStarted { .. }) {
177                                ring.clear();
178                            }
179                            if ring.len() >= RING_CAP {
180                                ring.pop_front();
181                            }
182                            ring.push_back(ev);
183                        }
184                        // Lagged: skip dropped events; Closed: stop.
185                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
186                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
187                    }
188                }
189            });
190        }
191
192        let state = Arc::new(AppState {
193            event_tx: event_tx.clone(),
194            command_tx: self.command_tx.clone(),
195            config: self.config.clone(),
196            approvals: self.approvals.clone(),
197            skills: self.skills.clone(),
198            memory: self.memory.clone(),
199            agent_store: self.agent_store.clone(),
200        });
201
202        let app = Router::new()
203            // Reads from disk if `SPARROW_CONSOLE_HTML` is set (live-reload
204            // friendly: Ctrl+R picks up edits without recompile). Otherwise
205            // serves the include_str!()'d copy baked at compile time.
206            .route("/", get(|| async { Html(console_html().into_owned()) }))
207            // /healthz: lightweight liveness probe used by the VS Code
208            // extension and any external orchestrator to know the cockpit is
209            // up before opening a webview pointing at it.
210            .route(
211                "/healthz",
212                get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
213            )
214            .route("/run", post(run_task))
215            .route("/plan", post(plan_task))
216            .route("/cli", post(run_cli_command))
217            .route("/commands", get(get_commands))
218            .route("/memory", get(get_memory))
219            .route("/plugins", get(get_plugins))
220            .route("/tools", get(get_tools))
221            .route("/models", get(list_models))
222            .route("/status", get(get_status))
223            .route("/file", get(read_file))
224            .route("/conversation/reset", post(reset_conversation))
225            .route("/stop", post(stop_run))
226            .route("/approval", post(resolve_approval))
227            .route("/config", get(get_config).post(save_provider))
228            .route("/permissions", get(get_permissions).post(save_permissions))
229            .route("/security", get(get_security))
230            .route("/sessions", get(list_sessions))
231            .route("/sessions/load", post(load_session))
232            .route("/history", get(get_history))
233            .route("/agents", get(list_agents).post(create_agent))
234            .route("/agents/:name", axum::routing::delete(delete_agent))
235            .route("/skills", get(list_skills))
236            .route("/upload", post(upload_attachment))
237            .route("/artifacts", get(list_artifacts))
238            .route("/providers/scan", post(scan_provider_models))
239            .route("/routing", get(get_routing).post(save_routing))
240            .route("/todos", get(list_todos))
241            .route("/preview/scan", get(scan_preview_servers))
242            .route("/replay", get(replay_run))
243            .route("/replays", get(list_replays))
244            .route("/mcp/list", get(list_mcp_servers))
245            .route("/hooks", get(list_hooks))
246            .route("/update/check", get(check_update_api))
247            .route(
248                "/ws",
249                get(
250                    move |ws: WebSocketUpgrade, State(state): State<Arc<AppState>>| {
251                        let recent = recent.clone();
252                        async move {
253                            // Subscribe BEFORE snapshotting so no event falls in
254                            // the gap (a microsecond overlap may duplicate one
255                            // trailing event — harmless for a log feed).
256                            let rx = state.event_tx.subscribe();
257                            let snapshot: Vec<Event> = recent.lock().iter().cloned().collect();
258                            // v0.9 Pilier 2: resolve the experience mode/language
259                            // so the WS layer can attach a plain-language `human`
260                            // field to each event in simple mode (one table,
261                            // server-side — no duplication on the frontend).
262                            let (simple, lang) = state
263                                .config
264                                .as_ref()
265                                .and_then(|c| {
266                                    c.read().ok().map(|cfg| {
267                                        (cfg.experience.is_simple(), cfg.experience.lang())
268                                    })
269                                })
270                                .unwrap_or((true, crate::humanize::Lang::Fr));
271                            ws.on_upgrade(move |socket| {
272                                handle_ws(socket, rx, snapshot, simple, lang)
273                            })
274                        }
275                    },
276                ),
277            )
278            .with_state(state);
279
280        let listener = tokio::net::TcpListener::bind(self.addr).await?;
281        tracing::info!("WebView console: http://{}", self.addr);
282
283        axum::serve(listener, app).await?;
284        Ok(())
285    }
286}
287
288#[derive(Clone)]
289struct AppState {
290    event_tx: broadcast::Sender<Event>,
291    command_tx: Option<mpsc::UnboundedSender<String>>,
292    config: Option<Arc<RwLock<Config>>>,
293    approvals: Option<Arc<WebApprovalBroker>>,
294    skills: Option<Arc<dyn SkillLibrary>>,
295    memory: Option<Arc<dyn Memory>>,
296    agent_store: Option<Arc<dyn AgentStore>>,
297}
298
299#[derive(Default)]
300pub struct WebApprovalBroker {
301    pending: Mutex<HashMap<String, oneshot::Sender<Decision>>>,
302}
303
304impl WebApprovalBroker {
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    pub async fn resolve(&self, id: &str, decision: Decision) -> bool {
310        let mut pending = self.pending.lock().await;
311        pending
312            .remove(id)
313            .map(|tx| tx.send(decision).is_ok())
314            .unwrap_or(false)
315    }
316}
317
318#[async_trait::async_trait]
319impl ApprovalHandler for WebApprovalBroker {
320    async fn request_approval(&self, request: ApprovalRequest) -> Decision {
321        let (tx, rx) = oneshot::channel();
322        let id = request.id.clone();
323        {
324            let mut pending = self.pending.lock().await;
325            pending.insert(id.clone(), tx);
326        }
327        match tokio::time::timeout(Duration::from_secs(300), rx).await {
328            Ok(Ok(decision)) => decision,
329            _ => {
330                let mut pending = self.pending.lock().await;
331                pending.remove(&id);
332                Decision::Deny
333            }
334        }
335    }
336}
337
338#[derive(serde::Deserialize)]
339struct RunRequest {
340    task: String,
341    #[serde(default)]
342    model_override: Option<String>,
343    #[serde(default)]
344    agent_name: Option<String>,
345}
346
347#[derive(serde::Serialize)]
348struct RunResponse {
349    ok: bool,
350    message: String,
351}
352
353#[derive(serde::Serialize)]
354struct PlanResponse {
355    ok: bool,
356    message: String,
357    plan: Option<ReadOnlyPlan>,
358}
359
360#[derive(serde::Serialize)]
361struct CommandView {
362    name: String,
363    description: String,
364    usage: String,
365    source: String,
366}
367
368#[derive(serde::Serialize)]
369struct CommandsResponse {
370    ok: bool,
371    message: String,
372    commands: Vec<CommandView>,
373}
374
375#[derive(serde::Deserialize)]
376struct CliCommandRequest {
377    command: String,
378}
379
380#[derive(serde::Serialize)]
381struct CliCommandResponse {
382    ok: bool,
383    message: String,
384    status: Option<i32>,
385    stdout: String,
386    stderr: String,
387}
388
389#[derive(serde::Deserialize)]
390struct ApprovalResponseRequest {
391    id: String,
392    decision: String,
393}
394
395#[derive(serde::Serialize)]
396struct ProviderView {
397    name: String,
398    label: String,
399    adapter: String,
400    base_url: Option<String>,
401    models: Vec<String>,
402    tags: Vec<String>,
403    notes: String,
404    api_key_env: Option<String>,
405    has_credential: bool,
406    configured: bool,
407}
408
409#[derive(serde::Serialize)]
410struct BudgetView {
411    session_usd: f64,
412    daily_usd: f64,
413}
414
415#[derive(serde::Serialize)]
416struct ConfigResponse {
417    ok: bool,
418    message: String,
419    autonomy: String,
420    sandbox: String,
421    providers: Vec<ProviderView>,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    budget: Option<BudgetView>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    workdir: Option<String>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    skills_count: Option<usize>,
428}
429
430#[derive(serde::Serialize)]
431struct PermissionsResponse {
432    ok: bool,
433    message: String,
434    permissions: Option<crate::permissions::PermissionConfig>,
435    /// Per-tool persisted decisions (for the WebView approval UI).
436    #[serde(default)]
437    persisted_tools: std::collections::HashMap<String, String>,
438}
439
440#[derive(serde::Deserialize)]
441struct PermissionsRequest {
442    mode: Option<String>,
443    /// Per-tool permission updates: {"web_search": "allow_always", "code_exec": "deny"}
444    tools: Option<std::collections::HashMap<String, String>>,
445}
446
447#[derive(serde::Serialize)]
448struct MemoryDocView {
449    kind: String,
450    chars: usize,
451    limit: usize,
452    updated_at: String,
453    content: String,
454}
455
456#[derive(serde::Serialize)]
457struct MemoryFactView {
458    id: String,
459    key: String,
460    value: String,
461    updated_at: String,
462}
463
464#[derive(serde::Serialize)]
465struct MemoryResponse {
466    ok: bool,
467    message: String,
468    stats: Option<crate::memory::MemoryStats>,
469    docs: Vec<MemoryDocView>,
470    facts: Vec<MemoryFactView>,
471}
472
473#[derive(serde::Serialize)]
474struct PluginView {
475    name: String,
476    version: String,
477    description: String,
478    commands: usize,
479    skills: usize,
480    hooks: usize,
481    allowed: bool,
482    warnings: Vec<String>,
483}
484
485#[derive(serde::Serialize)]
486struct PluginsResponse {
487    ok: bool,
488    message: String,
489    plugins: Vec<PluginView>,
490}
491
492#[derive(serde::Serialize)]
493struct ToolsResponse {
494    ok: bool,
495    message: String,
496    toolsets: Vec<String>,
497    tools: Vec<crate::tools::ToolMetadata>,
498}
499
500#[derive(serde::Deserialize)]
501struct HistoryQuery {
502    limit: Option<usize>,
503}
504
505#[derive(serde::Serialize)]
506struct HistoryResponse {
507    ok: bool,
508    message: String,
509    inputs: Vec<String>,
510}
511
512#[derive(serde::Deserialize)]
513struct ProviderRequest {
514    #[serde(default)]
515    name: String,
516    #[serde(default)]
517    adapter: String,
518    base_url: Option<String>,
519    #[serde(default)]
520    models: Vec<String>,
521    api_key_env: Option<String>,
522    api_key: Option<String>,
523    autonomy: Option<String>,
524    sandbox: Option<String>,
525}
526
527async fn run_task(
528    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
529    axum::extract::Json(req): axum::extract::Json<RunRequest>,
530) -> axum::extract::Json<RunResponse> {
531    let task = req.task.trim().to_string();
532    if task.is_empty() {
533        return axum::extract::Json(RunResponse {
534            ok: false,
535            message: "empty task".into(),
536        });
537    }
538
539    // Prepend model override hint so the engine can parse it.
540    // The frontend sends "provider:model" — strip the provider prefix to
541    // match brain.id() which returns just the model name.
542    let dispatch = if let Some(m) = req.model_override.filter(|s| !s.is_empty()) {
543        let model_only = m.rsplit(':').next().unwrap_or(&m);
544        format!("__model:{model_only}__ {task}")
545    } else {
546        task
547    };
548    // Prepend agent identity override. When an agent is selected, load its
549    // soul and embed the identity so the engine runs with that persona.
550    let dispatch = if let Some(ref agent_name) = req.agent_name.filter(|s| !s.is_empty()) {
551        if let Some(ref store) = state.agent_store {
552            if let Some(soul) = store.get(agent_name) {
553                let identity = soul.to_identity();
554                // Base64-encode the personality to avoid delimiter collisions
555                use base64::{Engine as _, engine::general_purpose::STANDARD};
556                let b64 = STANDARD.encode(identity.personality.as_bytes());
557                format!(
558                    "__agent:{}__{}__{}__ {}",
559                    identity.name, identity.role, b64, dispatch
560                )
561            } else {
562                dispatch
563            }
564        } else {
565            dispatch
566        }
567    } else {
568        dispatch
569    };
570    match &state.command_tx {
571        Some(tx) if tx.send(dispatch).is_ok() => axum::extract::Json(RunResponse {
572            ok: true,
573            message: "queued".into(),
574        }),
575        _ => axum::extract::Json(RunResponse {
576            ok: false,
577            message: "console command channel unavailable".into(),
578        }),
579    }
580}
581
582async fn plan_task(
583    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
584    axum::extract::Json(req): axum::extract::Json<RunRequest>,
585) -> axum::extract::Json<PlanResponse> {
586    let task = req.task.trim().to_string();
587    if task.is_empty() {
588        return axum::extract::Json(PlanResponse {
589            ok: false,
590            message: "empty task".into(),
591            plan: None,
592        });
593    }
594    let commands = commands_for_state(&state);
595    let plan = crate::plan::build_read_only_plan(&task, &commands);
596    axum::extract::Json(PlanResponse {
597        ok: true,
598        message: "planned".into(),
599        plan: Some(plan),
600    })
601}
602
603async fn run_cli_command(
604    axum::extract::Json(req): axum::extract::Json<CliCommandRequest>,
605) -> axum::extract::Json<CliCommandResponse> {
606    let args = match webview_cli_args(&req.command) {
607        Ok(args) => args,
608        Err(message) => {
609            return axum::extract::Json(CliCommandResponse {
610                ok: false,
611                message,
612                status: None,
613                stdout: String::new(),
614                stderr: String::new(),
615            });
616        }
617    };
618
619    if let Some(message) = blocked_webview_cli_command(&args) {
620        return axum::extract::Json(CliCommandResponse {
621            ok: false,
622            message,
623            status: None,
624            stdout: String::new(),
625            stderr: String::new(),
626        });
627    }
628
629    let exe = match std::env::current_exe() {
630        Ok(exe) => exe,
631        Err(e) => {
632            return axum::extract::Json(CliCommandResponse {
633                ok: false,
634                message: format!("cannot locate Sparrow executable: {e}"),
635                status: None,
636                stdout: String::new(),
637                stderr: String::new(),
638            });
639        }
640    };
641
642    let child = match tokio::process::Command::new(exe)
643        .args(&args)
644        .env("SPARROW_WEBVIEW_CLI", "1")
645        .stdin(Stdio::null())
646        .stdout(Stdio::piped())
647        .stderr(Stdio::piped())
648        .kill_on_drop(true)
649        .spawn()
650    {
651        Ok(child) => child,
652        Err(e) => {
653            return axum::extract::Json(CliCommandResponse {
654                ok: false,
655                message: format!("failed to launch Sparrow command: {e}"),
656                status: None,
657                stdout: String::new(),
658                stderr: String::new(),
659            });
660        }
661    };
662
663    let output = match tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()).await
664    {
665        Ok(Ok(output)) => output,
666        Ok(Err(e)) => {
667            return axum::extract::Json(CliCommandResponse {
668                ok: false,
669                message: format!("Sparrow command failed to finish: {e}"),
670                status: None,
671                stdout: String::new(),
672                stderr: String::new(),
673            });
674        }
675        Err(_) => {
676            return axum::extract::Json(CliCommandResponse {
677                ok: false,
678                message: "Sparrow command timed out after 45s".into(),
679                status: None,
680                stdout: String::new(),
681                stderr: String::new(),
682            });
683        }
684    };
685
686    let status = output.status.code();
687    let stdout = String::from_utf8_lossy(&output.stdout)
688        .trim_end()
689        .to_string();
690    let stderr = String::from_utf8_lossy(&output.stderr)
691        .trim_end()
692        .to_string();
693    axum::extract::Json(CliCommandResponse {
694        ok: output.status.success(),
695        message: if output.status.success() {
696            "command completed".into()
697        } else {
698            format!("command exited with {}", status.unwrap_or(-1))
699        },
700        status,
701        stdout,
702        stderr,
703    })
704}
705
706fn webview_cli_args(command: &str) -> Result<Vec<String>, String> {
707    let command = command.trim().trim_start_matches('/').trim();
708    if command.is_empty() {
709        return Err("empty command".into());
710    }
711    let mut args = split_webview_command(command)?;
712    if args.is_empty() {
713        return Err("empty command".into());
714    }
715    match args[0].as_str() {
716        "models" => args[0] = "model".into(),
717        "routing" => args[0] = "route".into(),
718        _ => {}
719    }
720    if args[0] == "model" && args.len() == 1 {
721        args.push("--list".into());
722    }
723    if args[0] == "run" && args.len() > 2 {
724        let task = args[1..].join(" ");
725        args.truncate(1);
726        args.push(task);
727    }
728    if args[0] == "plan" && args.len() > 2 {
729        let task = args[1..].join(" ");
730        args.truncate(1);
731        args.push(task);
732    }
733    if args[0] == "swarm" && args.len() > 2 {
734        let task = args[1..].join(" ");
735        args.truncate(1);
736        args.push(task);
737    }
738    Ok(args)
739}
740
741fn blocked_webview_cli_command(args: &[String]) -> Option<String> {
742    let first = args.first().map(String::as_str)?;
743    if matches!(first, "console" | "tui" | "chat" | "daemon") {
744        return Some(format!(
745            "`/{first}` opens an interactive process; launch it from a terminal instead."
746        ));
747    }
748    if first == "gateway" && args.get(1).map(String::as_str) == Some("start") {
749        return Some("`/gateway start` starts a daemon; launch it from a terminal instead.".into());
750    }
751    None
752}
753
754fn split_webview_command(input: &str) -> Result<Vec<String>, String> {
755    let mut args = Vec::new();
756    let mut current = String::new();
757    let mut chars = input.chars().peekable();
758    let mut quote: Option<char> = None;
759    while let Some(ch) = chars.next() {
760        match (quote, ch) {
761            (Some(q), c) if c == q => quote = None,
762            (Some(_), '\\') => {
763                if let Some(next) = chars.next() {
764                    current.push(next);
765                }
766            }
767            (Some(_), c) => current.push(c),
768            (None, '\'' | '"') => quote = Some(ch),
769            (None, c) if c.is_whitespace() => {
770                if !current.is_empty() {
771                    args.push(std::mem::take(&mut current));
772                }
773            }
774            (None, c) => current.push(c),
775        }
776    }
777    if let Some(q) = quote {
778        return Err(format!("unterminated {q} quote"));
779    }
780    if !current.is_empty() {
781        args.push(current);
782    }
783    Ok(args)
784}
785
786async fn get_commands(
787    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
788) -> axum::extract::Json<CommandsResponse> {
789    let commands = commands_for_state(&state)
790        .into_iter()
791        .map(|cmd| CommandView {
792            name: format!("/{}", cmd.name),
793            description: cmd.description,
794            usage: cmd.body,
795            source: match cmd.source {
796                crate::commands::SlashCommandSource::Builtin => "builtin".into(),
797                crate::commands::SlashCommandSource::Project(path) => {
798                    format!("project:{}", path.display())
799                }
800                crate::commands::SlashCommandSource::User(path) => {
801                    format!("user:{}", path.display())
802                }
803                crate::commands::SlashCommandSource::Skill(name) => format!("skill:{}", name),
804                crate::commands::SlashCommandSource::Plugin(name) => format!("plugin:{}", name),
805            },
806        })
807        .collect();
808    axum::extract::Json(CommandsResponse {
809        ok: true,
810        message: "commands loaded".into(),
811        commands,
812    })
813}
814
815fn commands_for_state(state: &AppState) -> Vec<crate::commands::SlashCommand> {
816    let project_root = std::env::current_dir().unwrap_or_default();
817    let config_dir = state
818        .config
819        .as_ref()
820        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
821        .unwrap_or_else(|| {
822            dirs::config_dir()
823                .unwrap_or_else(|| std::path::PathBuf::from("."))
824                .join("sparrow")
825        });
826    crate::commands::all_commands(&project_root, &config_dir, state.skills.as_deref())
827}
828
829async fn get_memory(
830    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
831) -> axum::extract::Json<MemoryResponse> {
832    let Some(memory) = &state.memory else {
833        return axum::extract::Json(MemoryResponse {
834            ok: false,
835            message: "memory unavailable".into(),
836            stats: None,
837            docs: Vec::new(),
838            facts: Vec::new(),
839        });
840    };
841    let stats = memory.memory_stats();
842    let docs = [MemoryDocKind::Memory, MemoryDocKind::User]
843        .into_iter()
844        .filter_map(|kind| {
845            memory.memory_doc(kind).map(|doc| MemoryDocView {
846                kind: kind.as_str().to_string(),
847                chars: doc.content.chars().count(),
848                limit: kind.limit(),
849                updated_at: doc.updated_at,
850                content: doc.content,
851            })
852        })
853        .collect();
854    let facts = memory
855        .all_facts()
856        .into_iter()
857        .take(25)
858        .map(|fact| MemoryFactView {
859            id: fact.id,
860            key: fact.key,
861            value: fact.value,
862            updated_at: fact.updated_at,
863        })
864        .collect();
865    axum::extract::Json(MemoryResponse {
866        ok: true,
867        message: "loaded".into(),
868        stats: Some(stats),
869        docs,
870        facts,
871    })
872}
873
874async fn get_plugins(
875    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
876) -> axum::extract::Json<PluginsResponse> {
877    let config_dir = state
878        .config
879        .as_ref()
880        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
881        .unwrap_or_else(|| {
882            dirs::config_dir()
883                .unwrap_or_else(|| std::path::PathBuf::from("."))
884                .join("sparrow")
885        });
886    let dirs = [
887        std::env::current_dir()
888            .unwrap_or_default()
889            .join(".sparrow")
890            .join("plugins"),
891        config_dir.join("plugins"),
892    ];
893    let mut plugins = Vec::new();
894    for dir in dirs {
895        let registry = crate::capabilities::plugin::PluginRegistry::new(dir);
896        for plugin in registry.scan() {
897            let audit = registry.audit(&plugin);
898            plugins.push(PluginView {
899                name: plugin.manifest.name,
900                version: plugin.manifest.version,
901                description: plugin.manifest.description,
902                commands: plugin.manifest.commands.len(),
903                skills: plugin.manifest.skills.len(),
904                hooks: plugin.manifest.hooks.len(),
905                allowed: audit.allowed,
906                warnings: audit.warnings,
907            });
908        }
909    }
910    axum::extract::Json(PluginsResponse {
911        ok: true,
912        message: "loaded".into(),
913        plugins,
914    })
915}
916
917async fn get_tools() -> axum::extract::Json<ToolsResponse> {
918    axum::extract::Json(ToolsResponse {
919        ok: true,
920        message: "loaded".into(),
921        toolsets: crate::tools::TOOLSETS
922            .iter()
923            .map(|set| set.to_string())
924            .collect(),
925        tools: crate::tools::known_tool_metadata(None),
926    })
927}
928
929async fn list_models(
930    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
931) -> axum::extract::Json<serde_json::Value> {
932    use crate::config::providers::provider_registry;
933    let providers = provider_registry();
934    let out: Vec<serde_json::Value> = providers
935        .iter()
936        .map(|p| {
937            // Curated models from the static registry.
938            let mut models: Vec<serde_json::Value> = p
939                .models
940                .iter()
941                .map(|m| {
942                    serde_json::json!({
943                        "name": m.name,
944                        "label": m.label,
945                        "tags": m.tags,
946                        "context_window": m.context_window,
947                        "cost_in": m.cost_input_per_mtok,
948                        "cost_out": m.cost_output_per_mtok,
949                        "recommended": m.recommended,
950                        "source": "registry",
951                    })
952                })
953                .collect();
954            // Merge live-discovered models from the SQLite cache (e.g. the 92
955            // NVIDIA models) so the picker shows everything, not just curated.
956            // For each discovered model, infer per-model caps from the name so
957            // the WebView context-window meter adapts (DeepSeek V4 Pro = 1M,
958            // not the previous hard-coded 128k default).
959            if let Some(mem) = &state.memory {
960                let curated: std::collections::HashSet<String> =
961                    p.models.iter().map(|m| m.name.clone()).collect();
962                for name in mem.get_discovered_models(&p.id) {
963                    if !curated.contains(&name) {
964                        let caps = crate::config::providers::model_caps(&p.id, &name);
965                        models.push(serde_json::json!({
966                            "name": name,
967                            "label": name,
968                            "tags": [],
969                            "context_window": caps.context_window,
970                            "max_output": caps.max_output,
971                            "cost_in": caps.cost_input_per_mtok,
972                            "cost_out": caps.cost_output_per_mtok,
973                            "recommended": false,
974                            "source": "discovered",
975                        }));
976                    }
977                }
978            }
979            serde_json::json!({
980                "id": p.id,
981                "label": p.label,
982                "tags": p.tags,
983                "model_count": models.len(),
984                "models": models,
985            })
986        })
987        .collect();
988    axum::extract::Json(serde_json::json!({ "ok": true, "providers": out }))
989}
990
991async fn stop_run(
992    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
993) -> axum::extract::Json<RunResponse> {
994    match &state.command_tx {
995        Some(tx) if tx.send("__stop__".to_string()).is_ok() => axum::extract::Json(RunResponse {
996            ok: true,
997            message: "stop requested".into(),
998        }),
999        _ => axum::extract::Json(RunResponse {
1000            ok: false,
1001            message: "console command channel unavailable".into(),
1002        }),
1003    }
1004}
1005
1006async fn reset_conversation(
1007    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1008) -> axum::extract::Json<RunResponse> {
1009    // Send a sentinel string through the command channel; main.rs interprets
1010    // __reset_conversation__ as a request to drain conv_history.
1011    match &state.command_tx {
1012        Some(tx) if tx.send("__reset_conversation__".to_string()).is_ok() => {
1013            axum::extract::Json(RunResponse {
1014                ok: true,
1015                message: "conversation cleared".into(),
1016            })
1017        }
1018        _ => axum::extract::Json(RunResponse {
1019            ok: false,
1020            message: "console command channel unavailable".into(),
1021        }),
1022    }
1023}
1024
1025async fn get_status() -> axum::extract::Json<serde_json::Value> {
1026    use crate::config::providers::provider_registry;
1027    let providers = provider_registry();
1028    axum::extract::Json(serde_json::json!({
1029        "ok": true,
1030        "version": env!("CARGO_PKG_VERSION"),
1031        "providers_total": providers.len(),
1032        "workdir": std::env::current_dir().ok().map(|p| p.to_string_lossy().to_string()),
1033    }))
1034}
1035
1036/// Check for Sparrow updates — called by the WebView frontend on load.
1037async fn check_update_api() -> axum::extract::Json<serde_json::Value> {
1038    let current = env!("CARGO_PKG_VERSION");
1039    let update = tokio::task::spawn_blocking(crate::update::check_update)
1040        .await
1041        .ok()
1042        .flatten();
1043    match update {
1044        Some(info) => axum::extract::Json(serde_json::json!({
1045            "update_available": true,
1046            "current": info.current,
1047            "latest": info.latest,
1048            "download_url": info.download_url,
1049            "crate_url": info.crate_url,
1050            "release_url": info.release_url,
1051            "install_cmd": info.install_cmd,
1052        })),
1053        None => axum::extract::Json(serde_json::json!({
1054            "update_available": false,
1055            "current": current,
1056            "latest": current,
1057        })),
1058    }
1059}
1060
1061#[derive(serde::Deserialize)]
1062struct FileQuery {
1063    path: String,
1064}
1065
1066async fn read_file(
1067    axum::extract::Query(q): axum::extract::Query<FileQuery>,
1068) -> axum::response::Response {
1069    use axum::response::IntoResponse;
1070    // Sandbox: only allow paths inside cwd.
1071    let cwd = match std::env::current_dir() {
1072        Ok(d) => d,
1073        Err(_) => {
1074            return (
1075                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1076                "cwd unavailable",
1077            )
1078                .into_response();
1079        }
1080    };
1081    // Canonicalize cwd too so UNC prefixes match on Windows.
1082    let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
1083    let requested = std::path::Path::new(&q.path);
1084    let canonical = match cwd.join(requested).canonicalize() {
1085        Ok(p) => p,
1086        Err(_) => return (axum::http::StatusCode::NOT_FOUND, "file not found").into_response(),
1087    };
1088    if !canonical.starts_with(&cwd_canon) {
1089        return (axum::http::StatusCode::FORBIDDEN, "path outside workdir").into_response();
1090    }
1091    match std::fs::read_to_string(&canonical) {
1092        Ok(content) => {
1093            let ext = canonical.extension().and_then(|e| e.to_str()).unwrap_or("");
1094            let lang = match ext {
1095                "rs" => "rust",
1096                "js" | "ts" | "jsx" | "tsx" => "javascript",
1097                "py" => "python",
1098                "toml" => "toml",
1099                "md" => "markdown",
1100                "html" => "html",
1101                "css" => "css",
1102                "json" => "json",
1103                _ => "text",
1104            };
1105            axum::extract::Json(serde_json::json!({
1106                "ok": true, "path": q.path, "lang": lang,
1107                "lines": content.lines().count(),
1108                "content": content,
1109            }))
1110            .into_response()
1111        }
1112        Err(_) => (axum::http::StatusCode::NOT_FOUND, "cannot read file").into_response(),
1113    }
1114}
1115
1116async fn resolve_approval(
1117    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1118    axum::extract::Json(req): axum::extract::Json<ApprovalResponseRequest>,
1119) -> axum::extract::Json<RunResponse> {
1120    let Some(approvals) = &state.approvals else {
1121        return axum::extract::Json(RunResponse {
1122            ok: false,
1123            message: "approval channel unavailable".into(),
1124        });
1125    };
1126    let decision = match req.decision.trim().to_lowercase().as_str() {
1127        // The scope (once/session/always) is enforced client-side: the frontend
1128        // remembers session-approved tool names and skips the next prompt; an
1129        // "always" decision should be paired with a separate POST /permissions
1130        // call to persist the rule. All three map to Allow at the engine level.
1131        "allow" | "approve" | "approved" | "allow_once" | "allow_session" | "allow_always" => {
1132            Decision::Allow
1133        }
1134        "deny" | "reject" | "rejected" => Decision::Deny,
1135        _ => {
1136            return axum::extract::Json(RunResponse {
1137                ok: false,
1138                message: "decision must be approve/allow_once/allow_session/allow_always/deny"
1139                    .into(),
1140            });
1141        }
1142    };
1143    if approvals.resolve(req.id.trim(), decision).await {
1144        axum::extract::Json(RunResponse {
1145            ok: true,
1146            message: "approval resolved".into(),
1147        })
1148    } else {
1149        axum::extract::Json(RunResponse {
1150            ok: false,
1151            message: "approval not found or already resolved".into(),
1152        })
1153    }
1154}
1155
1156async fn get_config(
1157    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1158) -> axum::extract::Json<ConfigResponse> {
1159    let Some(shared) = &state.config else {
1160        return axum::extract::Json(ConfigResponse {
1161            ok: false,
1162            message: "config unavailable".into(),
1163            budget: None,
1164            workdir: None,
1165            skills_count: None,
1166            autonomy: String::new(),
1167            sandbox: String::new(),
1168            providers: vec![],
1169        });
1170    };
1171
1172    let cfg = shared.read().expect("config lock poisoned").clone();
1173    let auth = crate::auth::store::ChainedAuthStore::new(cfg.config_dir.clone());
1174    let mut providers = crate::config::providers::onboarding_providers()
1175        .into_iter()
1176        .map(|def| {
1177            let configured = cfg.providers.get(&def.id);
1178            let api_key_env = configured
1179                .and_then(|p| {
1180                    p.api_key_env
1181                        .as_ref()
1182                        .filter(|value| !looks_like_api_key(value))
1183                        .cloned()
1184                })
1185                .or_else(|| def.api_key_env.clone());
1186            let has_credential = auth.get(&def.id).is_some()
1187                || configured
1188                    .and_then(|p| p.api_key_env.as_ref())
1189                    .map(|value| {
1190                        looks_like_api_key(value)
1191                            || std::env::var(value)
1192                                .map(|env_value| !env_value.is_empty())
1193                                .unwrap_or(false)
1194                    })
1195                    .unwrap_or(false)
1196                || api_key_env
1197                    .as_ref()
1198                    .map(|value| {
1199                        std::env::var(value)
1200                            .map(|env_value| !env_value.is_empty())
1201                            .unwrap_or(false)
1202                    })
1203                    .unwrap_or(false);
1204
1205            // Merge configured + curated + discovered into a single sorted
1206            // unique list so the config panel's "X models" count survives
1207            // across sessions (was only counting the static registry, hiding
1208            // the 60+ models the user had just scanned).
1209            let mut models: Vec<String> = configured
1210                .map(|p| {
1211                    if p.models.is_empty() {
1212                        def.models.iter().map(|m| m.name.clone()).collect()
1213                    } else {
1214                        p.models.clone()
1215                    }
1216                })
1217                .unwrap_or_else(|| def.models.iter().map(|m| m.name.clone()).collect());
1218            if let Some(mem) = &state.memory {
1219                let known: std::collections::HashSet<String> = models.iter().cloned().collect();
1220                for name in mem.get_discovered_models(&def.id) {
1221                    if !known.contains(&name) {
1222                        models.push(name);
1223                    }
1224                }
1225            }
1226            ProviderView {
1227                name: def.id,
1228                label: def.label,
1229                adapter: configured.map(|p| p.adapter.clone()).unwrap_or(def.adapter),
1230                base_url: configured
1231                    .and_then(|p| p.base_url.clone())
1232                    .or(Some(def.base_url)),
1233                models,
1234                tags: def.tags,
1235                notes: def.notes,
1236                api_key_env,
1237                has_credential,
1238                configured: configured.is_some(),
1239            }
1240        })
1241        .collect::<Vec<_>>();
1242
1243    for (name, p) in &cfg.providers {
1244        if providers.iter().any(|view| &view.name == name) {
1245            continue;
1246        }
1247        let api_key_env = p
1248            .api_key_env
1249            .as_ref()
1250            .filter(|value| !looks_like_api_key(value))
1251            .cloned();
1252        providers.push(ProviderView {
1253            name: name.clone(),
1254            label: name.clone(),
1255            adapter: p.adapter.clone(),
1256            base_url: p.base_url.clone(),
1257            models: p.models.clone(),
1258            tags: vec!["custom".into()],
1259            notes: "Custom configured provider.".into(),
1260            api_key_env: api_key_env.clone(),
1261            has_credential: auth.get(name).is_some()
1262                || p.api_key_env
1263                    .as_ref()
1264                    .map(|value| {
1265                        looks_like_api_key(value)
1266                            || std::env::var(value)
1267                                .map(|env_value| !env_value.is_empty())
1268                                .unwrap_or(false)
1269                    })
1270                    .unwrap_or(false),
1271            configured: true,
1272        });
1273    }
1274    providers.sort_by(|a, b| a.name.cmp(&b.name));
1275
1276    axum::extract::Json(ConfigResponse {
1277        ok: true,
1278        message: "loaded".into(),
1279        autonomy: format!("{:?}", cfg.defaults.autonomy),
1280        sandbox: cfg.defaults.sandbox,
1281        providers,
1282        budget: Some(BudgetView {
1283            session_usd: cfg.budget.session_usd,
1284            daily_usd: cfg.budget.daily_usd,
1285        }),
1286        workdir: std::env::current_dir()
1287            .ok()
1288            .map(|p| p.to_string_lossy().to_string()),
1289        skills_count: None,
1290    })
1291}
1292
1293async fn save_provider(
1294    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1295    axum::extract::Json(req): axum::extract::Json<ProviderRequest>,
1296) -> axum::extract::Json<RunResponse> {
1297    let Some(shared) = &state.config else {
1298        return axum::extract::Json(RunResponse {
1299            ok: false,
1300            message: "config unavailable".into(),
1301        });
1302    };
1303
1304    let mut cfg = shared.write().expect("config lock poisoned");
1305    if let Some(level) = parse_autonomy(req.autonomy.as_deref()) {
1306        cfg.defaults.autonomy = level;
1307    }
1308    if let Some(sandbox) = req
1309        .sandbox
1310        .as_ref()
1311        .map(|s| s.trim().to_string())
1312        .filter(|s| !s.is_empty())
1313    {
1314        cfg.defaults.sandbox = sandbox;
1315    }
1316
1317    let name = req.name.trim().to_lowercase();
1318    if name.is_empty() {
1319        let saved = cfg.clone();
1320        let store = FsConfigStore::new(saved.config_dir.clone());
1321        if let Err(err) = store.save(&saved) {
1322            return axum::extract::Json(RunResponse {
1323                ok: false,
1324                message: format!("config save failed: {}", err),
1325            });
1326        }
1327        return axum::extract::Json(RunResponse {
1328            ok: true,
1329            message: "runtime preferences saved".into(),
1330        });
1331    }
1332
1333    let raw_api_key_env = req
1334        .api_key_env
1335        .as_ref()
1336        .map(|s| s.trim().to_string())
1337        .filter(|s| !s.is_empty());
1338    let api_key_env = raw_api_key_env
1339        .as_ref()
1340        .filter(|value| !looks_like_api_key(value))
1341        .cloned();
1342    let api_key_from_env_field = raw_api_key_env
1343        .as_ref()
1344        .filter(|value| looks_like_api_key(value))
1345        .cloned();
1346
1347    cfg.providers.insert(
1348        name.clone(),
1349        ProviderConfig {
1350            adapter: req.adapter.trim().to_string(),
1351            base_url: req
1352                .base_url
1353                .as_ref()
1354                .map(|s| s.trim().to_string())
1355                .filter(|s| !s.is_empty()),
1356            models: req
1357                .models
1358                .into_iter()
1359                .map(|m| m.trim().to_string())
1360                .filter(|m| !m.is_empty())
1361                .collect(),
1362            api_key_env,
1363        },
1364    );
1365
1366    let saved = cfg.clone();
1367    let store = FsConfigStore::new(saved.config_dir.clone());
1368    if let Err(err) = store.save(&saved) {
1369        return axum::extract::Json(RunResponse {
1370            ok: false,
1371            message: format!("config save failed: {}", err),
1372        });
1373    }
1374
1375    if let Some(key) = req
1376        .api_key
1377        .map(|k| k.trim().to_string())
1378        .filter(|k| !k.is_empty())
1379        .or(api_key_from_env_field)
1380    {
1381        let auth = crate::auth::store::ChainedAuthStore::new(saved.config_dir);
1382        if let Err(err) = auth.set(&name, Credential::api_key(key)) {
1383            return axum::extract::Json(RunResponse {
1384                ok: false,
1385                message: format!("credential save failed: {}", err),
1386            });
1387        }
1388    }
1389
1390    axum::extract::Json(RunResponse {
1391        ok: true,
1392        message: format!("provider '{}' saved", name),
1393    })
1394}
1395
1396/// Hard cap for WebView attachments (10 MB).
1397pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
1398
1399/// Where attachments are stored, relative to the current working directory.
1400pub fn attachments_dir() -> std::path::PathBuf {
1401    std::env::current_dir()
1402        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1403        .join(".sparrow")
1404        .join("attachments")
1405}
1406
1407#[derive(serde::Serialize)]
1408pub struct AttachmentMetadata {
1409    pub name: String,
1410    pub path: String,
1411    pub size: u64,
1412    pub mime: String,
1413    pub kind: &'static str,
1414}
1415
1416pub fn classify_attachment(mime: &str, ext: &str) -> &'static str {
1417    let ext = ext.to_ascii_lowercase();
1418    if mime.starts_with("image/")
1419        || matches!(
1420            ext.as_str(),
1421            "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
1422        )
1423    {
1424        "image"
1425    } else if mime.starts_with("audio/")
1426        || matches!(ext.as_str(), "mp3" | "wav" | "m4a" | "ogg" | "flac")
1427    {
1428        "audio"
1429    } else if mime == "application/pdf" || ext == "pdf" {
1430        "pdf"
1431    } else if mime.starts_with("text/")
1432        || matches!(
1433            ext.as_str(),
1434            "md" | "txt" | "csv" | "json" | "toml" | "yml" | "yaml"
1435        )
1436    {
1437        "text"
1438    } else {
1439        "file"
1440    }
1441}
1442
1443async fn upload_attachment(
1444    mut multipart: axum::extract::Multipart,
1445) -> axum::extract::Json<serde_json::Value> {
1446    let dir = attachments_dir();
1447    if let Err(e) = std::fs::create_dir_all(&dir) {
1448        return axum::extract::Json(serde_json::json!({
1449            "ok": false,
1450            "message": format!("could not create attachments dir: {}", e),
1451        }));
1452    }
1453    let mut accepted: Vec<AttachmentMetadata> = Vec::new();
1454    let mut rejected: Vec<serde_json::Value> = Vec::new();
1455    while let Ok(Some(field)) = multipart.next_field().await {
1456        let original = field
1457            .file_name()
1458            .map(|s| s.to_string())
1459            .unwrap_or_else(|| "upload.bin".into());
1460        let content_type = field
1461            .content_type()
1462            .unwrap_or("application/octet-stream")
1463            .to_string();
1464        let data = match field.bytes().await {
1465            Ok(b) => b,
1466            Err(e) => {
1467                rejected.push(
1468                    serde_json::json!({"name": original, "reason": format!("read error: {}", e)}),
1469                );
1470                continue;
1471            }
1472        };
1473        if data.len() > MAX_ATTACHMENT_BYTES {
1474            rejected.push(serde_json::json!({
1475                "name": original,
1476                "reason": format!("too large: {} bytes > limit {}", data.len(), MAX_ATTACHMENT_BYTES),
1477            }));
1478            continue;
1479        }
1480        // Sanitize filename: strip directory components.
1481        let safe = std::path::Path::new(&original)
1482            .file_name()
1483            .map(|s| s.to_string_lossy().to_string())
1484            .unwrap_or_else(|| "upload.bin".into());
1485        let dest = dir.join(&safe);
1486        if let Err(e) = std::fs::write(&dest, &data) {
1487            rejected
1488                .push(serde_json::json!({"name": safe, "reason": format!("write error: {}", e)}));
1489            continue;
1490        }
1491        let ext = std::path::Path::new(&safe)
1492            .extension()
1493            .map(|s| s.to_string_lossy().to_string())
1494            .unwrap_or_default();
1495        let kind = classify_attachment(&content_type, &ext);
1496        accepted.push(AttachmentMetadata {
1497            name: safe.clone(),
1498            path: dest.to_string_lossy().to_string(),
1499            size: data.len() as u64,
1500            mime: content_type,
1501            kind,
1502        });
1503    }
1504
1505    axum::extract::Json(serde_json::json!({
1506        "ok": !accepted.is_empty(),
1507        "accepted": accepted,
1508        "rejected": rejected,
1509        "limit_bytes": MAX_ATTACHMENT_BYTES,
1510    }))
1511}
1512
1513async fn list_artifacts() -> axum::extract::Json<serde_json::Value> {
1514    let dir = attachments_dir();
1515    let mut items: Vec<AttachmentMetadata> = Vec::new();
1516    if let Ok(entries) = std::fs::read_dir(&dir) {
1517        for entry in entries.flatten() {
1518            let path = entry.path();
1519            if !path.is_file() {
1520                continue;
1521            }
1522            let name = path
1523                .file_name()
1524                .map(|s| s.to_string_lossy().to_string())
1525                .unwrap_or_default();
1526            let ext = path
1527                .extension()
1528                .map(|s| s.to_string_lossy().to_string())
1529                .unwrap_or_default();
1530            let mime = mime_guess::from_path(&path)
1531                .first_or_octet_stream()
1532                .to_string();
1533            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
1534            let kind = classify_attachment(&mime, &ext);
1535            items.push(AttachmentMetadata {
1536                name,
1537                path: path.to_string_lossy().to_string(),
1538                size,
1539                mime,
1540                kind,
1541            });
1542        }
1543    }
1544    axum::extract::Json(serde_json::json!({
1545        "ok": true,
1546        "items": items,
1547        "dir": dir.to_string_lossy().to_string(),
1548    }))
1549}
1550
1551/// `GET /skills` — return the local skill library so the drawer panel can list
1552/// names + descriptions. Reads from the same dir the runtime uses.
1553async fn list_skills() -> axum::extract::Json<serde_json::Value> {
1554    use crate::capabilities::FsSkillLibrary;
1555    let skills_dir = dirs::config_dir()
1556        .unwrap_or_else(|| std::path::PathBuf::from("."))
1557        .join("sparrow")
1558        .join("skills");
1559    let lib = FsSkillLibrary::new(skills_dir.clone());
1560    let scanned = lib.scan();
1561    let skills: Vec<serde_json::Value> = scanned
1562        .into_iter()
1563        .map(|s| {
1564            serde_json::json!({
1565                "name": s.name,
1566                "description": s.description,
1567                "uses": s.usage_count,
1568                "score": s.score,
1569                "auto_generated": s.auto_generated,
1570            })
1571        })
1572        .collect();
1573    axum::extract::Json(serde_json::json!({
1574        "ok": true,
1575        "skills": skills,
1576        "dir": skills_dir.to_string_lossy().to_string(),
1577    }))
1578}
1579
1580#[derive(serde::Deserialize)]
1581struct CreateAgentReq {
1582    name: String,
1583    role: Option<String>,
1584    description: Option<String>,
1585    model: Option<String>,
1586    color_key: Option<String>,
1587    soul: Option<String>,     // raw markdown for .soul.toml personality
1588    agent_md: Option<String>, // raw markdown for .agent.md long-form
1589    allowed_tools: Option<Vec<String>>,
1590}
1591
1592/// `POST /agents` — create a persistent agent on disk under the user's local
1593/// `./agents/` dir (workdir). Writes `<name>.soul.toml` (TOML config) and
1594/// optionally `<name>.agent.md` (long-form persona / instructions).
1595async fn create_agent(
1596    axum::extract::Json(req): axum::extract::Json<CreateAgentReq>,
1597) -> axum::extract::Json<serde_json::Value> {
1598    let name = req.name.trim();
1599    if name.is_empty()
1600        || name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
1601    {
1602        return axum::extract::Json(serde_json::json!({
1603            "ok": false,
1604            "message": "agent name must be ascii alphanumeric/_/- only",
1605        }));
1606    }
1607    let dir = std::env::current_dir()
1608        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1609        .join("agents");
1610    if let Err(e) = std::fs::create_dir_all(&dir) {
1611        return axum::extract::Json(serde_json::json!({
1612            "ok": false,
1613            "message": format!("could not create agents dir: {e}"),
1614        }));
1615    }
1616    let role = req.role.as_deref().unwrap_or("custom agent");
1617    let description = req.description.as_deref().unwrap_or("");
1618    let color_key = req.color_key.as_deref().unwrap_or("steel");
1619    let model = req.model.as_deref().unwrap_or("");
1620    let allowed_tools = req.allowed_tools.unwrap_or_default();
1621    let soul_path = dir.join(format!("{name}.soul.toml"));
1622    let soul = req.soul.unwrap_or_else(|| {
1623        let tools_block = if allowed_tools.is_empty() {
1624            String::new()
1625        } else {
1626            format!(
1627                "allowed_tools = [{}]\n",
1628                allowed_tools
1629                    .iter()
1630                    .map(|t| format!("\"{}\"", t.replace('"', "\\\"")))
1631                    .collect::<Vec<_>>()
1632                    .join(", ")
1633            )
1634        };
1635        format!(
1636            "# Sparrow persistent agent\n\
1637             name = \"{name}\"\n\
1638             role = \"{role}\"\n\
1639             description = \"\"\"{description}\"\"\"\n\
1640             color_key = \"{color_key}\"\n\
1641             {model_line}\
1642             {tools_block}\n\
1643             [personality]\n\
1644             tone = \"concise, competent, direct\"\n",
1645            name = name,
1646            role = role.replace('"', "\\\""),
1647            description = description.replace('"', "\\\""),
1648            color_key = color_key,
1649            model_line = if model.is_empty() {
1650                String::new()
1651            } else {
1652                format!("model = \"{}\"\n", model.replace('"', "\\\""))
1653            },
1654            tools_block = tools_block,
1655        )
1656    });
1657    if let Err(e) = std::fs::write(&soul_path, soul) {
1658        return axum::extract::Json(serde_json::json!({
1659            "ok": false,
1660            "message": format!("could not write soul file: {e}"),
1661        }));
1662    }
1663    if let Some(md) = req.agent_md {
1664        if !md.trim().is_empty() {
1665            let md_path = dir.join(format!("{name}.agent.md"));
1666            if let Err(e) = std::fs::write(&md_path, md) {
1667                return axum::extract::Json(serde_json::json!({
1668                    "ok": false,
1669                    "message": format!("could not write agent.md: {e}"),
1670                }));
1671            }
1672        }
1673    }
1674    axum::extract::Json(serde_json::json!({
1675        "ok": true,
1676        "name": name,
1677        "soul_path": soul_path.to_string_lossy().to_string(),
1678        "message": "agent created",
1679    }))
1680}
1681
1682/// `DELETE /agents/:name` — remove a persistent agent's local files.
1683async fn delete_agent(
1684    axum::extract::Path(name): axum::extract::Path<String>,
1685) -> axum::extract::Json<serde_json::Value> {
1686    if name.is_empty()
1687        || name.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
1688    {
1689        return axum::extract::Json(serde_json::json!({
1690            "ok": false,
1691            "message": "invalid agent name",
1692        }));
1693    }
1694    let dir = std::env::current_dir()
1695        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1696        .join("agents");
1697    let soul = dir.join(format!("{name}.soul.toml"));
1698    let md = dir.join(format!("{name}.agent.md"));
1699    let mut removed = 0u32;
1700    if soul.exists() {
1701        let _ = std::fs::remove_file(&soul);
1702        removed += 1;
1703    }
1704    if md.exists() {
1705        let _ = std::fs::remove_file(&md);
1706        removed += 1;
1707    }
1708    axum::extract::Json(serde_json::json!({
1709        "ok": removed > 0,
1710        "removed": removed,
1711        "message": if removed > 0 { "deleted" } else { "not found" },
1712    }))
1713}
1714
1715/// `GET /agents` — return every installed agent so the WebView swarm row and
1716/// the composer's `@<name>` picker can render the real list (Sprint 1, item
1717/// dynamic WebView swarm row. Status is "idle" by default; the runtime updates a
1718/// shared `Arc<Mutex<AgentRuntimeState>>` later — for v0.3.0 we ship the
1719/// listing as a cold view backed by `FsAgentStore::list()`.
1720async fn list_agents() -> axum::extract::Json<serde_json::Value> {
1721    use crate::agent::{AgentStore, FsAgentStore};
1722
1723    let agents_dir = dirs::config_dir()
1724        .unwrap_or_else(|| std::path::PathBuf::from("."))
1725        .join("sparrow")
1726        .join("agents");
1727
1728    // Collect souls from user config dir + local repo `agents/` dir + `.sparrow/agents/`.
1729    let extra_dirs: Vec<std::path::PathBuf> = [
1730        std::env::current_dir().ok().map(|d| d.join("agents")),
1731        std::env::current_dir()
1732            .ok()
1733            .map(|d| d.join(".sparrow").join("agents")),
1734    ]
1735    .into_iter()
1736    .flatten()
1737    .filter(|p| p.is_dir())
1738    .collect();
1739
1740    let store = FsAgentStore::new(agents_dir.clone());
1741    let mut souls = store.list();
1742    let mut seen: std::collections::HashSet<String> =
1743        souls.iter().map(|s| s.name.clone()).collect();
1744    for dir in &extra_dirs {
1745        let extra = FsAgentStore::new(dir.clone()).list();
1746        for s in extra {
1747            if seen.insert(s.name.clone()) {
1748                souls.push(s);
1749            }
1750        }
1751    }
1752
1753    let items: Vec<serde_json::Value> = souls
1754        .into_iter()
1755        .map(|s| {
1756            // Pick a colour key the WebView already knows about; falls back to
1757            // the canonical triad if the agent uses one of those role names.
1758            let color_key = match s.role.to_lowercase().as_str() {
1759                "planner" => "planner",
1760                "coder" => "coder",
1761                "verifier" => "verifier",
1762                _ => s
1763                    .color
1764                    .as_deref()
1765                    .map(classify_agent_color)
1766                    .unwrap_or("steel"),
1767            };
1768            serde_json::json!({
1769                "name": s.name,
1770                "role": s.role,
1771                "description": s.description,
1772                "status": "idle",
1773                "msg": "",
1774                "color_key": color_key,
1775            })
1776        })
1777        .collect();
1778
1779    axum::extract::Json(serde_json::json!({
1780        "ok": true,
1781        "dir": agents_dir.to_string_lossy(),
1782        "agents": items,
1783    }))
1784}
1785
1786/// Maps the optional `color` field of a `Soul` to one of the known WebView
1787/// theme tokens. Unknown values fall back to `steel`.
1788pub fn classify_agent_color(raw: &str) -> &'static str {
1789    match raw.trim().to_lowercase().as_str() {
1790        "planner" | "blue" => "planner",
1791        "coder" | "teal" | "agent" => "coder",
1792        "verifier" | "sand" => "verifier",
1793        "gold" | "yellow" => "gold",
1794        "coral" | "red" => "coral",
1795        _ => "steel",
1796    }
1797}
1798
1799#[derive(serde::Deserialize)]
1800struct LoadSessionRequest {
1801    id: String,
1802}
1803
1804async fn load_session(
1805    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1806    axum::extract::Json(req): axum::extract::Json<LoadSessionRequest>,
1807) -> axum::extract::Json<RunResponse> {
1808    let id = req.id.trim();
1809    if id.is_empty() {
1810        return axum::extract::Json(RunResponse {
1811            ok: false,
1812            message: "empty session id".into(),
1813        });
1814    }
1815    // Reuse the same command channel sentinel mechanism that powers
1816    // __reset_conversation__ — main.rs interprets __load_session__:<id> and
1817    // swaps the live `conv_history` with the loaded session's messages.
1818    let sentinel = format!("__load_session__:{}", id);
1819    match &state.command_tx {
1820        Some(tx) if tx.send(sentinel).is_ok() => axum::extract::Json(RunResponse {
1821            ok: true,
1822            message: "session load requested".into(),
1823        }),
1824        _ => axum::extract::Json(RunResponse {
1825            ok: false,
1826            message: "console command channel unavailable".into(),
1827        }),
1828    }
1829}
1830
1831async fn list_sessions() -> axum::extract::Json<serde_json::Value> {
1832    // Resolve the same DB path the CLI uses. Failures degrade to an empty list
1833    // rather than 500'ing the WebView panel.
1834    let db_path = session_db_path();
1835    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1836        Ok(s) => s,
1837        Err(e) => {
1838            return axum::extract::Json(serde_json::json!({
1839                "ok": false,
1840                "message": format!("could not open session db: {}", e),
1841                "db_path": db_path.to_string_lossy(),
1842                "sessions": [],
1843            }));
1844        }
1845    };
1846    let sessions = store.list();
1847    axum::extract::Json(serde_json::json!({
1848        "ok": true,
1849        "db_path": db_path.to_string_lossy(),
1850        "sessions": sessions,
1851    }))
1852}
1853
1854async fn get_history(
1855    axum::extract::Query(query): axum::extract::Query<HistoryQuery>,
1856) -> axum::extract::Json<HistoryResponse> {
1857    let db_path = session_db_path();
1858    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1859        Ok(s) => s,
1860        Err(e) => {
1861            return axum::extract::Json(HistoryResponse {
1862                ok: false,
1863                message: format!("could not open session db: {}", e),
1864                inputs: Vec::new(),
1865            });
1866        }
1867    };
1868    axum::extract::Json(HistoryResponse {
1869        ok: true,
1870        message: "loaded".into(),
1871        inputs: store.recent_inputs(query.limit.unwrap_or(50)),
1872    })
1873}
1874
1875/// Common local dev-server ports the Preview panel probes: Node/CRA/Express
1876/// (3000/3001), Angular (4200), Astro (4321), Vite (5173/5174), Flask/uvicorn
1877/// (5000/8000), php/http.server (8080/8081/8888), Hugo (1313), Streamlit (8501).
1878const PREVIEW_SCAN_PORTS: &[u16] = &[
1879    3000, 3001, 4200, 4321, 5000, 5173, 5174, 8000, 8080, 8081, 8501, 8888, 1313,
1880];
1881
1882/// `GET /preview/scan` — probe the loopback interface for live HTTP dev
1883/// servers so the Preview panel shows what is actually running (no guessing,
1884/// no fakes). The console's own port is excluded via the request Host header.
1885async fn scan_preview_servers(
1886    headers: axum::http::HeaderMap,
1887) -> axum::extract::Json<serde_json::Value> {
1888    let self_port: Option<u16> = headers
1889        .get(axum::http::header::HOST)
1890        .and_then(|h| h.to_str().ok())
1891        .and_then(|h| h.rsplit(':').next())
1892        .and_then(|p| p.parse().ok());
1893    let client = match reqwest::Client::builder()
1894        .timeout(Duration::from_millis(600))
1895        .redirect(reqwest::redirect::Policy::none())
1896        .build()
1897    {
1898        Ok(c) => c,
1899        Err(_) => {
1900            return axum::extract::Json(serde_json::json!({ "ok": false, "servers": [] }));
1901        }
1902    };
1903    let probes = PREVIEW_SCAN_PORTS
1904        .iter()
1905        .copied()
1906        .filter(|p| Some(*p) != self_port)
1907        .map(|port| {
1908            let client = client.clone();
1909            async move {
1910                let url = format!("http://127.0.0.1:{port}/");
1911                let resp = client.get(&url).send().await.ok()?;
1912                let status = resp.status().as_u16();
1913                // Any HTTP answer (including 404) means a live server.
1914                Some(serde_json::json!({ "url": url, "port": port, "status": status }))
1915            }
1916        });
1917    let servers: Vec<serde_json::Value> = futures::future::join_all(probes)
1918        .await
1919        .into_iter()
1920        .flatten()
1921        .collect();
1922    axum::extract::Json(serde_json::json!({ "ok": true, "servers": servers }))
1923}
1924
1925/// `GET /todos` — read-only view over the `todo` tool's SQLite table so the
1926/// WebView Plan panel can render the agent's real task list (objective steps,
1927/// status). Reads the same DB the tool writes (`state_dir/sparrow/sparrow.db`);
1928/// a missing DB or table degrades to an empty list instead of a 500.
1929async fn list_todos() -> axum::extract::Json<serde_json::Value> {
1930    let db_path = dirs::state_dir()
1931        .or_else(dirs::data_local_dir)
1932        .or_else(dirs::data_dir)
1933        .unwrap_or_else(|| std::path::PathBuf::from("."))
1934        .join("sparrow")
1935        .join("sparrow.db");
1936    let todos = tokio::task::spawn_blocking(move || -> Vec<serde_json::Value> {
1937        let Ok(conn) = rusqlite::Connection::open_with_flags(
1938            &db_path,
1939            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1940        ) else {
1941            return Vec::new();
1942        };
1943        let Ok(mut stmt) = conn.prepare(
1944            "SELECT id, content, status, updated_at FROM todos ORDER BY created_at LIMIT 100",
1945        ) else {
1946            return Vec::new();
1947        };
1948        stmt.query_map([], |row| {
1949            Ok(serde_json::json!({
1950                "id": row.get::<_, String>(0)?,
1951                "content": row.get::<_, String>(1)?,
1952                "status": row.get::<_, String>(2)?,
1953                "updated_at": row.get::<_, i64>(3)?,
1954            }))
1955        })
1956        .map(|rows| rows.filter_map(|r| r.ok()).collect())
1957        .unwrap_or_default()
1958    })
1959    .await
1960    .unwrap_or_default();
1961    axum::extract::Json(serde_json::json!({ "ok": true, "todos": todos }))
1962}
1963
1964fn session_db_path() -> std::path::PathBuf {
1965    dirs::state_dir()
1966        .or_else(dirs::data_local_dir)
1967        .or_else(dirs::data_dir)
1968        .unwrap_or_else(|| std::path::PathBuf::from("."))
1969        .join("sparrow")
1970        .join("sessions.db")
1971}
1972
1973/// Default-profile transcripts dir — mirrors the resolution in main.rs.
1974/// (Profile-scoped consoles record under profiles/<name>/transcripts; the
1975/// replay endpoints serve the default tree, which is what `sparrow console`
1976/// without --profile uses.)
1977fn transcripts_dir() -> std::path::PathBuf {
1978    dirs::state_dir()
1979        .or_else(dirs::data_local_dir)
1980        .or_else(dirs::data_dir)
1981        .unwrap_or_else(|| std::path::PathBuf::from("."))
1982        .join("sparrow")
1983        .join("transcripts")
1984}
1985
1986#[derive(serde::Deserialize)]
1987struct ReplayQuery {
1988    #[serde(default)]
1989    run_id: Option<String>,
1990}
1991
1992/// `GET /replays` — list recorded run transcripts (most recent first) so the
1993/// WebView replay button can offer real runs instead of a blind prompt.
1994async fn list_replays() -> axum::extract::Json<serde_json::Value> {
1995    use crate::runtime::recorder::{FsRecorder, Replayer};
1996    let rec = FsRecorder::new(transcripts_dir());
1997    let mut items: Vec<serde_json::Value> = rec
1998        .list_transcripts()
1999        .into_iter()
2000        .filter_map(|id| {
2001            let meta_path = transcripts_dir().join(&id).join("meta.json");
2002            let inputs_path = transcripts_dir().join(&id).join("inputs.json");
2003            let meta: serde_json::Value = std::fs::read_to_string(&meta_path)
2004                .ok()
2005                .and_then(|s| serde_json::from_str(&s).ok())
2006                .unwrap_or(serde_json::Value::Null);
2007            let task = std::fs::read_to_string(&inputs_path)
2008                .ok()
2009                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
2010                .and_then(|v| v.get("task").and_then(|t| t.as_str()).map(String::from))
2011                .unwrap_or_default();
2012            Some(serde_json::json!({
2013                "run_id": id,
2014                "task": task,
2015                "event_count": meta.get("event_count").cloned().unwrap_or(0.into()),
2016                "created_at": meta.get("created_at").cloned().unwrap_or("".into()),
2017            }))
2018        })
2019        .collect();
2020    // meta.created_at is "YYYY-MM-DD HH:MM:SS" — lexicographic sort works.
2021    items.sort_by(|a, b| {
2022        b["created_at"]
2023            .as_str()
2024            .unwrap_or("")
2025            .cmp(a["created_at"].as_str().unwrap_or(""))
2026    });
2027    axum::extract::Json(serde_json::json!({ "ok": true, "replays": items }))
2028}
2029
2030/// `GET /replay?run_id=<id>` — return a recorded transcript's public events so
2031/// the WebView can re-render the run. Without `run_id`, replays the most
2032/// recent transcript. The chrome ▸ replay button has pointed here since v0.3;
2033/// the endpoint finally exists.
2034async fn replay_run(
2035    axum::extract::Query(q): axum::extract::Query<ReplayQuery>,
2036) -> axum::extract::Json<serde_json::Value> {
2037    use crate::runtime::recorder::{FsRecorder, Replayer};
2038    let rec = FsRecorder::new(transcripts_dir());
2039    let run_id = match q.run_id.filter(|s| !s.trim().is_empty()) {
2040        Some(id) => id.trim().to_string(),
2041        None => {
2042            // Most recent transcript by meta.created_at.
2043            let mut best: Option<(String, String)> = None;
2044            for id in rec.list_transcripts() {
2045                let created =
2046                    std::fs::read_to_string(transcripts_dir().join(&id).join("meta.json"))
2047                        .ok()
2048                        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
2049                        .and_then(|v| {
2050                            v.get("created_at")
2051                                .and_then(|c| c.as_str())
2052                                .map(String::from)
2053                        })
2054                        .unwrap_or_default();
2055                if best.as_ref().map(|(_, c)| created > *c).unwrap_or(true) {
2056                    best = Some((id, created));
2057                }
2058            }
2059            match best {
2060                Some((id, _)) => id,
2061                None => {
2062                    return axum::extract::Json(serde_json::json!({
2063                        "ok": false,
2064                        "message": "no recorded runs yet — run a task first",
2065                        "events": [],
2066                    }));
2067                }
2068            }
2069        }
2070    };
2071    // Path-traversal guard: transcript ids are directory names, never paths.
2072    if run_id.contains(['/', '\\', '.']) {
2073        return axum::extract::Json(serde_json::json!({
2074            "ok": false, "message": "invalid run id", "events": [],
2075        }));
2076    }
2077    match rec.load(&run_id) {
2078        Some(t) => {
2079            let events: Vec<&Event> = t.events.iter().filter(|e| e.is_public()).collect();
2080            axum::extract::Json(serde_json::json!({
2081                "ok": true,
2082                "run_id": t.run_id,
2083                "task": t.inputs.task,
2084                "events": events,
2085            }))
2086        }
2087        None => axum::extract::Json(serde_json::json!({
2088            "ok": false,
2089            "message": format!("transcript '{}' not found", run_id),
2090            "events": [],
2091        })),
2092    }
2093}
2094
2095/// `GET /mcp/list` — list configured MCP connectors (from the same
2096/// `mcp_servers.json` the runtime loads) so the config panel's "MCP" tab shows
2097/// real state instead of "not exposed yet".
2098async fn list_mcp_servers(
2099    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2100) -> axum::extract::Json<serde_json::Value> {
2101    use crate::capabilities::mcp::{BasicMcpClient, McpClient};
2102    let config_dir = state
2103        .config
2104        .as_ref()
2105        .and_then(|cfg| cfg.read().ok().map(|c| c.config_dir.clone()))
2106        .unwrap_or_else(|| {
2107            dirs::config_dir()
2108                .unwrap_or_else(|| std::path::PathBuf::from("."))
2109                .join("sparrow")
2110        });
2111    let client = BasicMcpClient::new(config_dir.clone());
2112    let servers: Vec<serde_json::Value> = client
2113        .list_servers()
2114        .await
2115        .into_iter()
2116        .map(|s| {
2117            serde_json::json!({
2118                "name": s.name,
2119                "transport": format!("{:?}", s.transport).to_lowercase(),
2120                "command": s.command,
2121                "args": s.args,
2122                "url": s.url,
2123                "allow_tools": s.allow_tools,
2124            })
2125        })
2126        .collect();
2127    axum::extract::Json(serde_json::json!({
2128        "ok": true,
2129        "servers": servers,
2130        "config_path": config_dir.join("mcp_servers.json").to_string_lossy(),
2131    }))
2132}
2133
2134/// `GET /hooks` — list configured lifecycle hooks from the live config so the
2135/// config panel's "hooks" tab shows real state.
2136async fn list_hooks(
2137    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2138) -> axum::extract::Json<serde_json::Value> {
2139    let hooks = state
2140        .config
2141        .as_ref()
2142        .and_then(|cfg| cfg.read().ok().map(|c| c.hooks.clone()))
2143        .unwrap_or_default();
2144    axum::extract::Json(serde_json::json!({ "ok": true, "hooks": hooks }))
2145}
2146
2147async fn get_security(
2148    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2149) -> axum::extract::Json<serde_json::Value> {
2150    let Some(shared) = &state.config else {
2151        return axum::extract::Json(serde_json::json!({
2152            "ok": false,
2153            "message": "config unavailable",
2154        }));
2155    };
2156    let cfg = shared.read().expect("config lock poisoned").clone();
2157    let audit = crate::security::SecurityAudit::run(&cfg, &cfg.hooks);
2158    axum::extract::Json(serde_json::json!({
2159        "ok": true,
2160        "audit": audit,
2161    }))
2162}
2163
2164async fn get_permissions(
2165    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2166) -> axum::extract::Json<PermissionsResponse> {
2167    let Some(shared) = &state.config else {
2168        return axum::extract::Json(PermissionsResponse {
2169            ok: false,
2170            message: "config unavailable".into(),
2171            permissions: None,
2172            persisted_tools: std::collections::HashMap::new(),
2173        });
2174    };
2175    let cfg = shared.read().expect("config lock poisoned").clone();
2176    let persisted_tools = cfg.permissions.store.to_api_map();
2177    axum::extract::Json(PermissionsResponse {
2178        ok: true,
2179        message: "loaded".into(),
2180        permissions: Some(cfg.permissions),
2181        persisted_tools,
2182    })
2183}
2184
2185async fn save_permissions(
2186    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2187    axum::extract::Json(req): axum::extract::Json<PermissionsRequest>,
2188) -> axum::extract::Json<RunResponse> {
2189    let Some(shared) = &state.config else {
2190        return axum::extract::Json(RunResponse {
2191            ok: false,
2192            message: "config unavailable".into(),
2193        });
2194    };
2195    let mut cfg = shared.write().expect("config lock poisoned");
2196
2197    // ── Global permission mode ───────────────────────────────────────────
2198    if let Some(mode) = req.mode.as_deref() {
2199        let Some(mode) = crate::permissions::PermissionMode::parse(mode) else {
2200            return axum::extract::Json(RunResponse {
2201                ok: false,
2202                message: "unknown permission mode".into(),
2203            });
2204        };
2205        cfg.defaults.autonomy = mode.autonomy_level();
2206        cfg.permissions.mode = mode;
2207    }
2208
2209    // ── Per-tool persisted decisions ──────────────────────────────────────
2210    if let Some(tools) = &req.tools {
2211        for (tool_name, decision_str) in tools {
2212            let decision = match decision_str.as_str() {
2213                "allow_always" => crate::event::Decision::AllowAlways,
2214                "allow_session" => crate::event::Decision::AllowSession,
2215                "deny" => crate::event::Decision::Deny,
2216                "ask_user" => crate::event::Decision::AskUser,
2217                _ => continue,
2218            };
2219            let config_dir = cfg.config_dir.clone();
2220            let _ = cfg
2221                .permissions
2222                .store
2223                .set_and_save(tool_name, &decision, &config_dir);
2224        }
2225    }
2226
2227    let saved = cfg.clone();
2228    let store = FsConfigStore::new(saved.config_dir.clone());
2229    if let Err(err) = store.save(&saved) {
2230        return axum::extract::Json(RunResponse {
2231            ok: false,
2232            message: format!("permissions save failed: {}", err),
2233        });
2234    }
2235    axum::extract::Json(RunResponse {
2236        ok: true,
2237        message: "permissions saved".into(),
2238    })
2239}
2240
2241fn parse_autonomy(value: Option<&str>) -> Option<crate::event::AutonomyLevel> {
2242    match value.map(|s| s.trim().to_lowercase()).as_deref() {
2243        Some("supervised") => Some(crate::event::AutonomyLevel::Supervised),
2244        Some("trusted") => Some(crate::event::AutonomyLevel::Trusted),
2245        Some("autonomous") => Some(crate::event::AutonomyLevel::Autonomous),
2246        _ => None,
2247    }
2248}
2249
2250// ─── Provider model scan ──────────────────────────────────────────────────────
2251
2252#[derive(serde::Deserialize)]
2253struct ScanRequest {
2254    provider: String,
2255}
2256
2257#[derive(serde::Serialize)]
2258struct ScanResponse {
2259    ok: bool,
2260    message: String,
2261    models: Vec<String>,
2262}
2263
2264async fn scan_provider_models(
2265    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2266    axum::extract::Json(req): axum::extract::Json<ScanRequest>,
2267) -> axum::extract::Json<ScanResponse> {
2268    use crate::config::providers::find_provider;
2269
2270    let provider_id = req.provider.trim().to_string();
2271
2272    let Some(def) = find_provider(&provider_id) else {
2273        return axum::extract::Json(ScanResponse {
2274            ok: false,
2275            message: format!("Unknown provider: {}", provider_id),
2276            models: vec![],
2277        });
2278    };
2279
2280    // Resolve API key: auth store -> env var
2281    let api_key = {
2282        let key_from_store = state.config.as_ref().and_then(|cfg| {
2283            let c = cfg.read().ok()?;
2284            let auth = crate::auth::store::ChainedAuthStore::new(c.config_dir.clone());
2285            match auth.get(&provider_id) {
2286                Some(crate::auth::Credential::ApiKey(k)) => Some(k.expose_secret().to_string()),
2287                _ => None,
2288            }
2289        });
2290        let key_from_env = def
2291            .api_key_env
2292            .as_deref()
2293            .and_then(|env| std::env::var(env).ok());
2294        key_from_store.or(key_from_env).unwrap_or_default()
2295    };
2296
2297    match crate::provider::discovery::discover_models(&def.adapter, &def.base_url, &api_key).await {
2298        Ok(models) => {
2299            let count = models.len();
2300            axum::extract::Json(ScanResponse {
2301                ok: true,
2302                message: format!("Found {} model(s) for {}", count, def.label),
2303                models,
2304            })
2305        }
2306        Err(err) => axum::extract::Json(ScanResponse {
2307            ok: false,
2308            message: format!("Scan failed: {}", err),
2309            models: vec![],
2310        }),
2311    }
2312}
2313
2314// ─── Routing config get/set ───────────────────────────────────────────────────
2315
2316#[derive(serde::Serialize)]
2317struct RoutingResponse {
2318    ok: bool,
2319    preferred_provider: Option<String>,
2320    preferred_model: Option<String>,
2321    routing_mode: String,
2322    auto_discover: bool,
2323    all_providers: Vec<String>,
2324}
2325
2326#[derive(serde::Deserialize)]
2327struct RoutingRequest {
2328    preferred_provider: Option<String>,
2329    preferred_model: Option<String>,
2330    routing_mode: Option<String>,
2331    auto_discover: Option<bool>,
2332}
2333
2334async fn get_routing(
2335    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2336) -> axum::extract::Json<RoutingResponse> {
2337    use crate::config::providers::provider_registry;
2338
2339    let all_providers: Vec<String> = provider_registry().iter().map(|p| p.id.clone()).collect();
2340
2341    let Some(shared) = &state.config else {
2342        return axum::extract::Json(RoutingResponse {
2343            ok: false,
2344            preferred_provider: None,
2345            preferred_model: None,
2346            routing_mode: "auto".into(),
2347            auto_discover: true,
2348            all_providers,
2349        });
2350    };
2351
2352    let cfg = shared.read().expect("config lock poisoned");
2353    axum::extract::Json(RoutingResponse {
2354        ok: true,
2355        preferred_provider: cfg.routing.preferred_provider.clone(),
2356        preferred_model: cfg.routing.preferred_model.clone(),
2357        routing_mode: cfg.routing.routing_mode.clone(),
2358        auto_discover: cfg.routing.auto_discover,
2359        all_providers,
2360    })
2361}
2362
2363async fn save_routing(
2364    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
2365    axum::extract::Json(req): axum::extract::Json<RoutingRequest>,
2366) -> axum::extract::Json<RunResponse> {
2367    let Some(shared) = &state.config else {
2368        return axum::extract::Json(RunResponse {
2369            ok: false,
2370            message: "config unavailable".into(),
2371        });
2372    };
2373
2374    {
2375        let mut cfg = shared.write().expect("config lock poisoned");
2376
2377        // preferred_provider: empty string or missing = clear
2378        cfg.routing.preferred_provider = req
2379            .preferred_provider
2380            .map(|s| s.trim().to_string())
2381            .filter(|s| !s.is_empty());
2382
2383        cfg.routing.preferred_model = req
2384            .preferred_model
2385            .map(|s| s.trim().to_string())
2386            .filter(|s| !s.is_empty());
2387
2388        if let Some(ref mode) = req.routing_mode {
2389            cfg.routing.routing_mode = mode.trim().to_string();
2390        }
2391
2392        if let Some(ad) = req.auto_discover {
2393            cfg.routing.auto_discover = ad;
2394        }
2395
2396        let saved = cfg.clone();
2397        let store = FsConfigStore::new(saved.config_dir.clone());
2398        if let Err(err) = store.save(&saved) {
2399            return axum::extract::Json(RunResponse {
2400                ok: false,
2401                message: format!("save failed: {}", err),
2402            });
2403        }
2404    }
2405
2406    axum::extract::Json(RunResponse {
2407        ok: true,
2408        message: "Routing preferences saved.".into(),
2409    })
2410}
2411
2412/// Serialize an event to JSON for the WS feed. In simple mode, attach a
2413/// plain-language `human` line (from the one humanize table) and a `simple`
2414/// flag so the frontend can render the human layer without duplicating the
2415/// table. Returns `None` only if serialization fails.
2416fn encode_event(event: &Event, simple: bool, lang: crate::humanize::Lang) -> Option<String> {
2417    if !simple {
2418        return serde_json::to_string(event).ok();
2419    }
2420    let mut value = serde_json::to_value(event).ok()?;
2421    if let Some(obj) = value.as_object_mut() {
2422        obj.insert("simple".into(), serde_json::Value::Bool(true));
2423        if let Some(human) = crate::humanize::humanize(event, lang) {
2424            obj.insert("human".into(), serde_json::Value::String(human));
2425        }
2426    }
2427    serde_json::to_string(&value).ok()
2428}
2429
2430async fn handle_ws(
2431    mut socket: axum::extract::ws::WebSocket,
2432    mut event_rx: tokio::sync::broadcast::Receiver<Event>,
2433    snapshot: Vec<Event>,
2434    simple: bool,
2435    lang: crate::humanize::Lang,
2436) {
2437    tracing::info!("WebSocket connected, replaying {} events", snapshot.len());
2438    // Replay the current run so far, so a refresh mid-run never shows a
2439    // blank feed. The snapshot only holds public events.
2440    for event in &snapshot {
2441        if let Some(json) = encode_event(event, simple, lang) {
2442            use axum::extract::ws::Message;
2443            if socket.send(Message::Text(json.into())).await.is_err() {
2444                return;
2445            }
2446        }
2447    }
2448    loop {
2449        tokio::select! {
2450            result = event_rx.recv() => {
2451                match result {
2452                    Ok(event) => {
2453                        if !event.is_public() {
2454                            continue;
2455                        }
2456                        if let Some(json) = encode_event(&event, simple, lang) {
2457                            use axum::extract::ws::Message;
2458                            if socket.send(Message::Text(json.into())).await.is_err() {
2459                                break;
2460                            }
2461                        }
2462                    }
2463                    Err(_) => break,
2464                }
2465            }
2466            _ = tokio::time::sleep(tokio::time::Duration::from_secs(30)) => {
2467                // Ping keep-alive
2468                use axum::extract::ws::Message;
2469                if socket.send(Message::Ping(vec![])).await.is_err() {
2470                    break;
2471                }
2472            }
2473        }
2474    }
2475}
2476
2477#[cfg(test)]
2478mod tests {
2479    use super::*;
2480
2481    #[test]
2482    fn webview_cli_args_maps_model_alias() {
2483        assert_eq!(
2484            webview_cli_args("/models").unwrap(),
2485            vec!["model".to_string(), "--list".to_string()]
2486        );
2487    }
2488
2489    #[test]
2490    fn webview_cli_args_keeps_quoted_arguments() {
2491        assert_eq!(
2492            webview_cli_args("/auth add \"open router\"").unwrap(),
2493            vec![
2494                "auth".to_string(),
2495                "add".to_string(),
2496                "open router".to_string()
2497            ]
2498        );
2499    }
2500
2501    #[test]
2502    fn webview_cli_args_joins_run_task() {
2503        assert_eq!(
2504            webview_cli_args("/run analyse le repo github").unwrap(),
2505            vec!["run".to_string(), "analyse le repo github".to_string()]
2506        );
2507    }
2508
2509    #[test]
2510    fn webview_cli_blocks_interactive_commands() {
2511        let args = webview_cli_args("/console --port 9339").unwrap();
2512        assert!(blocked_webview_cli_command(&args).is_some());
2513        let args = webview_cli_args("/gateway start").unwrap();
2514        assert!(blocked_webview_cli_command(&args).is_some());
2515    }
2516
2517    #[test]
2518    fn resolve_bind_defaults_to_loopback() {
2519        let t = resolve_bind_addr(None, 9339).unwrap();
2520        assert_eq!(t.addr.ip().to_string(), "127.0.0.1");
2521        assert_eq!(t.addr.port(), 9339);
2522        assert!(
2523            !t.is_public,
2524            "default bind must not be reachable from the LAN"
2525        );
2526    }
2527
2528    #[test]
2529    fn resolve_bind_honours_explicit_public_ip_and_flags_it() {
2530        let t = resolve_bind_addr(Some("0.0.0.0"), 8080).unwrap();
2531        assert_eq!(t.addr.ip().to_string(), "0.0.0.0");
2532        assert!(
2533            t.is_public,
2534            "0.0.0.0 must be flagged as public for the warning"
2535        );
2536    }
2537
2538    #[test]
2539    fn resolve_bind_rejects_value_with_port() {
2540        // D3: a port inside --bind used to be silently ignored.
2541        let err = resolve_bind_addr(Some("127.0.0.1:9876"), 9339).unwrap_err();
2542        assert!(err.to_string().contains("--bind"));
2543    }
2544
2545    #[test]
2546    fn resolve_bind_rejects_garbage() {
2547        assert!(resolve_bind_addr(Some("not-an-ip"), 9339).is_err());
2548    }
2549
2550    #[test]
2551    fn resolve_bind_blank_is_loopback() {
2552        let t = resolve_bind_addr(Some("   "), 9339).unwrap();
2553        assert_eq!(t.addr.ip().to_string(), "127.0.0.1");
2554    }
2555}