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// ─── Embedded HTML ─────────────────────────────────────────────────────────────
19//
20// console.html is `include_str!`d into the binary so a release build ships as
21// a single file. The drawback: any edit to console.html requires a fresh
22// `cargo build` — a plain WebView reload (Ctrl+R) re-fetches the same baked-in
23// bytes. To make the WebView dev loop tight, set the env var
24// `SPARROW_CONSOLE_HTML` to a path on disk and that file is served instead.
25
26const CONSOLE_HTML_EMBEDDED: &str = include_str!("../console.html");
27
28fn console_html() -> std::borrow::Cow<'static, str> {
29    if let Ok(path) = std::env::var("SPARROW_CONSOLE_HTML") {
30        if !path.trim().is_empty() {
31            match std::fs::read_to_string(&path) {
32                Ok(contents) => return std::borrow::Cow::Owned(contents),
33                Err(e) => {
34                    tracing::warn!(
35                        "SPARROW_CONSOLE_HTML={} unreadable ({}); falling back to embedded HTML",
36                        path,
37                        e
38                    );
39                }
40            }
41        }
42    }
43    std::borrow::Cow::Borrowed(CONSOLE_HTML_EMBEDDED)
44}
45
46fn looks_like_api_key(value: &str) -> bool {
47    let value = value.trim();
48    value.starts_with("sk-")
49        || value.starts_with("nvapi-")
50        || value.starts_with("gsk_")
51        || value.starts_with("sk-or-")
52        || value.len() > 40 && !value.chars().all(|c| c.is_ascii_uppercase() || c == '_')
53}
54
55// ─── WebView server ────────────────────────────────────────────────────────────
56
57pub struct WebViewServer {
58    addr: SocketAddr,
59    event_tx: broadcast::Sender<Event>,
60    command_tx: Option<mpsc::UnboundedSender<String>>,
61    config: Option<Arc<RwLock<Config>>>,
62    approvals: Option<Arc<WebApprovalBroker>>,
63    skills: Option<Arc<dyn SkillLibrary>>,
64    memory: Option<Arc<dyn Memory>>,
65    agent_store: Option<Arc<dyn AgentStore>>,
66}
67
68impl WebViewServer {
69    #[allow(clippy::too_many_arguments)]
70    pub fn new(
71        addr: SocketAddr,
72        event_tx: broadcast::Sender<Event>,
73        command_tx: Option<mpsc::UnboundedSender<String>>,
74        config: Option<Arc<RwLock<Config>>>,
75        approvals: Option<Arc<WebApprovalBroker>>,
76        skills: Option<Arc<dyn SkillLibrary>>,
77        memory: Option<Arc<dyn Memory>>,
78        agent_store: Option<Arc<dyn AgentStore>>,
79    ) -> Self {
80        Self {
81            addr,
82            event_tx,
83            command_tx,
84            config,
85            approvals,
86            skills,
87            memory,
88            agent_store,
89        }
90    }
91
92    pub async fn serve(&self) -> anyhow::Result<()> {
93        use axum::{
94            Router,
95            extract::{State, ws::WebSocketUpgrade},
96            response::Html,
97            routing::{get, post},
98        };
99
100        let event_tx = self.event_tx.clone();
101        let state = Arc::new(AppState {
102            event_tx: event_tx.clone(),
103            command_tx: self.command_tx.clone(),
104            config: self.config.clone(),
105            approvals: self.approvals.clone(),
106            skills: self.skills.clone(),
107            memory: self.memory.clone(),
108            agent_store: self.agent_store.clone(),
109        });
110
111        let app = Router::new()
112            // Reads from disk if `SPARROW_CONSOLE_HTML` is set (live-reload
113            // friendly: Ctrl+R picks up edits without recompile). Otherwise
114            // serves the include_str!()'d copy baked at compile time.
115            .route("/", get(|| async { Html(console_html().into_owned()) }))
116            // /healthz: lightweight liveness probe used by the VS Code
117            // extension and any external orchestrator to know the cockpit is
118            // up before opening a webview pointing at it.
119            .route(
120                "/healthz",
121                get(|| async { axum::Json(serde_json::json!({"ok": true})) }),
122            )
123            .route("/run", post(run_task))
124            .route("/plan", post(plan_task))
125            .route("/cli", post(run_cli_command))
126            .route("/commands", get(get_commands))
127            .route("/memory", get(get_memory))
128            .route("/plugins", get(get_plugins))
129            .route("/tools", get(get_tools))
130            .route("/models", get(list_models))
131            .route("/status", get(get_status))
132            .route("/file", get(read_file))
133            .route("/conversation/reset", post(reset_conversation))
134            .route("/stop", post(stop_run))
135            .route("/approval", post(resolve_approval))
136            .route("/config", get(get_config).post(save_provider))
137            .route("/permissions", get(get_permissions).post(save_permissions))
138            .route("/security", get(get_security))
139            .route("/sessions", get(list_sessions))
140            .route("/sessions/load", post(load_session))
141            .route("/history", get(get_history))
142            .route("/agents", get(list_agents))
143            .route("/upload", post(upload_attachment))
144            .route("/artifacts", get(list_artifacts))
145            .route("/providers/scan", post(scan_provider_models))
146            .route("/routing", get(get_routing).post(save_routing))
147            .route(
148                "/ws",
149                get(
150                    move |ws: WebSocketUpgrade, State(state): State<Arc<AppState>>| async move {
151                        let rx = state.event_tx.subscribe();
152                        ws.on_upgrade(move |socket| handle_ws(socket, rx))
153                    },
154                ),
155            )
156            .with_state(state);
157
158        let listener = tokio::net::TcpListener::bind(self.addr).await?;
159        tracing::info!("WebView console: http://{}", self.addr);
160
161        axum::serve(listener, app).await?;
162        Ok(())
163    }
164}
165
166#[derive(Clone)]
167struct AppState {
168    event_tx: broadcast::Sender<Event>,
169    command_tx: Option<mpsc::UnboundedSender<String>>,
170    config: Option<Arc<RwLock<Config>>>,
171    approvals: Option<Arc<WebApprovalBroker>>,
172    skills: Option<Arc<dyn SkillLibrary>>,
173    memory: Option<Arc<dyn Memory>>,
174    agent_store: Option<Arc<dyn AgentStore>>,
175}
176
177#[derive(Default)]
178pub struct WebApprovalBroker {
179    pending: Mutex<HashMap<String, oneshot::Sender<Decision>>>,
180}
181
182impl WebApprovalBroker {
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    pub async fn resolve(&self, id: &str, decision: Decision) -> bool {
188        let mut pending = self.pending.lock().await;
189        pending
190            .remove(id)
191            .map(|tx| tx.send(decision).is_ok())
192            .unwrap_or(false)
193    }
194}
195
196#[async_trait::async_trait]
197impl ApprovalHandler for WebApprovalBroker {
198    async fn request_approval(&self, request: ApprovalRequest) -> Decision {
199        let (tx, rx) = oneshot::channel();
200        {
201            let mut pending = self.pending.lock().await;
202            pending.insert(request.id, tx);
203        }
204        rx.await.unwrap_or(Decision::Deny)
205    }
206}
207
208#[derive(serde::Deserialize)]
209struct RunRequest {
210    task: String,
211    #[serde(default)]
212    model_override: Option<String>,
213    #[serde(default)]
214    agent_name: Option<String>,
215}
216
217#[derive(serde::Serialize)]
218struct RunResponse {
219    ok: bool,
220    message: String,
221}
222
223#[derive(serde::Serialize)]
224struct PlanResponse {
225    ok: bool,
226    message: String,
227    plan: Option<ReadOnlyPlan>,
228}
229
230#[derive(serde::Serialize)]
231struct CommandView {
232    name: String,
233    description: String,
234    usage: String,
235    source: String,
236}
237
238#[derive(serde::Serialize)]
239struct CommandsResponse {
240    ok: bool,
241    message: String,
242    commands: Vec<CommandView>,
243}
244
245#[derive(serde::Deserialize)]
246struct CliCommandRequest {
247    command: String,
248}
249
250#[derive(serde::Serialize)]
251struct CliCommandResponse {
252    ok: bool,
253    message: String,
254    status: Option<i32>,
255    stdout: String,
256    stderr: String,
257}
258
259#[derive(serde::Deserialize)]
260struct ApprovalResponseRequest {
261    id: String,
262    decision: String,
263}
264
265#[derive(serde::Serialize)]
266struct ProviderView {
267    name: String,
268    label: String,
269    adapter: String,
270    base_url: Option<String>,
271    models: Vec<String>,
272    tags: Vec<String>,
273    notes: String,
274    api_key_env: Option<String>,
275    has_credential: bool,
276    configured: bool,
277}
278
279#[derive(serde::Serialize)]
280struct BudgetView {
281    session_usd: f64,
282    daily_usd: f64,
283}
284
285#[derive(serde::Serialize)]
286struct ConfigResponse {
287    ok: bool,
288    message: String,
289    autonomy: String,
290    sandbox: String,
291    providers: Vec<ProviderView>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    budget: Option<BudgetView>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    workdir: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    skills_count: Option<usize>,
298}
299
300#[derive(serde::Serialize)]
301struct PermissionsResponse {
302    ok: bool,
303    message: String,
304    permissions: Option<crate::permissions::PermissionConfig>,
305}
306
307#[derive(serde::Deserialize)]
308struct PermissionsRequest {
309    mode: Option<String>,
310}
311
312#[derive(serde::Serialize)]
313struct MemoryDocView {
314    kind: String,
315    chars: usize,
316    limit: usize,
317    updated_at: String,
318    content: String,
319}
320
321#[derive(serde::Serialize)]
322struct MemoryFactView {
323    id: String,
324    key: String,
325    value: String,
326    updated_at: String,
327}
328
329#[derive(serde::Serialize)]
330struct MemoryResponse {
331    ok: bool,
332    message: String,
333    stats: Option<crate::memory::MemoryStats>,
334    docs: Vec<MemoryDocView>,
335    facts: Vec<MemoryFactView>,
336}
337
338#[derive(serde::Serialize)]
339struct PluginView {
340    name: String,
341    version: String,
342    description: String,
343    commands: usize,
344    skills: usize,
345    hooks: usize,
346    allowed: bool,
347    warnings: Vec<String>,
348}
349
350#[derive(serde::Serialize)]
351struct PluginsResponse {
352    ok: bool,
353    message: String,
354    plugins: Vec<PluginView>,
355}
356
357#[derive(serde::Serialize)]
358struct ToolsResponse {
359    ok: bool,
360    message: String,
361    toolsets: Vec<String>,
362    tools: Vec<crate::tools::ToolMetadata>,
363}
364
365#[derive(serde::Deserialize)]
366struct HistoryQuery {
367    limit: Option<usize>,
368}
369
370#[derive(serde::Serialize)]
371struct HistoryResponse {
372    ok: bool,
373    message: String,
374    inputs: Vec<String>,
375}
376
377#[derive(serde::Deserialize)]
378struct ProviderRequest {
379    #[serde(default)]
380    name: String,
381    #[serde(default)]
382    adapter: String,
383    base_url: Option<String>,
384    #[serde(default)]
385    models: Vec<String>,
386    api_key_env: Option<String>,
387    api_key: Option<String>,
388    autonomy: Option<String>,
389    sandbox: Option<String>,
390}
391
392async fn run_task(
393    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
394    axum::extract::Json(req): axum::extract::Json<RunRequest>,
395) -> axum::extract::Json<RunResponse> {
396    let task = req.task.trim().to_string();
397    if task.is_empty() {
398        return axum::extract::Json(RunResponse {
399            ok: false,
400            message: "empty task".into(),
401        });
402    }
403
404    // Prepend model override hint so the engine can parse it.
405    // The frontend sends "provider:model" — strip the provider prefix to
406    // match brain.id() which returns just the model name.
407    let dispatch = if let Some(m) = req.model_override.filter(|s| !s.is_empty()) {
408        let model_only = m.rsplit(':').next().unwrap_or(&m);
409        format!("__model:{model_only}__ {task}")
410    } else {
411        task
412    };
413    // Prepend agent identity override. When an agent is selected, load its
414    // soul and embed the identity so the engine runs with that persona.
415    let dispatch = if let Some(ref agent_name) = req.agent_name.filter(|s| !s.is_empty()) {
416        if let Some(ref store) = state.agent_store {
417            if let Some(soul) = store.get(agent_name) {
418                let identity = soul.to_identity();
419                // Base64-encode the personality to avoid delimiter collisions
420                use base64::{Engine as _, engine::general_purpose::STANDARD};
421                let b64 = STANDARD.encode(identity.personality.as_bytes());
422                format!(
423                    "__agent:{}__{}__{}__ {}",
424                    identity.name, identity.role, b64, dispatch
425                )
426            } else {
427                dispatch
428            }
429        } else {
430            dispatch
431        }
432    } else {
433        dispatch
434    };
435    match &state.command_tx {
436        Some(tx) if tx.send(dispatch).is_ok() => axum::extract::Json(RunResponse {
437            ok: true,
438            message: "queued".into(),
439        }),
440        _ => axum::extract::Json(RunResponse {
441            ok: false,
442            message: "console command channel unavailable".into(),
443        }),
444    }
445}
446
447async fn plan_task(
448    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
449    axum::extract::Json(req): axum::extract::Json<RunRequest>,
450) -> axum::extract::Json<PlanResponse> {
451    let task = req.task.trim().to_string();
452    if task.is_empty() {
453        return axum::extract::Json(PlanResponse {
454            ok: false,
455            message: "empty task".into(),
456            plan: None,
457        });
458    }
459    let commands = commands_for_state(&state);
460    let plan = crate::plan::build_read_only_plan(&task, &commands);
461    axum::extract::Json(PlanResponse {
462        ok: true,
463        message: "planned".into(),
464        plan: Some(plan),
465    })
466}
467
468async fn run_cli_command(
469    axum::extract::Json(req): axum::extract::Json<CliCommandRequest>,
470) -> axum::extract::Json<CliCommandResponse> {
471    let args = match webview_cli_args(&req.command) {
472        Ok(args) => args,
473        Err(message) => {
474            return axum::extract::Json(CliCommandResponse {
475                ok: false,
476                message,
477                status: None,
478                stdout: String::new(),
479                stderr: String::new(),
480            });
481        }
482    };
483
484    if let Some(message) = blocked_webview_cli_command(&args) {
485        return axum::extract::Json(CliCommandResponse {
486            ok: false,
487            message,
488            status: None,
489            stdout: String::new(),
490            stderr: String::new(),
491        });
492    }
493
494    let exe = match std::env::current_exe() {
495        Ok(exe) => exe,
496        Err(e) => {
497            return axum::extract::Json(CliCommandResponse {
498                ok: false,
499                message: format!("cannot locate Sparrow executable: {e}"),
500                status: None,
501                stdout: String::new(),
502                stderr: String::new(),
503            });
504        }
505    };
506
507    let child = match tokio::process::Command::new(exe)
508        .args(&args)
509        .env("SPARROW_WEBVIEW_CLI", "1")
510        .stdin(Stdio::null())
511        .stdout(Stdio::piped())
512        .stderr(Stdio::piped())
513        .kill_on_drop(true)
514        .spawn()
515    {
516        Ok(child) => child,
517        Err(e) => {
518            return axum::extract::Json(CliCommandResponse {
519                ok: false,
520                message: format!("failed to launch Sparrow command: {e}"),
521                status: None,
522                stdout: String::new(),
523                stderr: String::new(),
524            });
525        }
526    };
527
528    let output = match tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()).await
529    {
530        Ok(Ok(output)) => output,
531        Ok(Err(e)) => {
532            return axum::extract::Json(CliCommandResponse {
533                ok: false,
534                message: format!("Sparrow command failed to finish: {e}"),
535                status: None,
536                stdout: String::new(),
537                stderr: String::new(),
538            });
539        }
540        Err(_) => {
541            return axum::extract::Json(CliCommandResponse {
542                ok: false,
543                message: "Sparrow command timed out after 45s".into(),
544                status: None,
545                stdout: String::new(),
546                stderr: String::new(),
547            });
548        }
549    };
550
551    let status = output.status.code();
552    let stdout = String::from_utf8_lossy(&output.stdout)
553        .trim_end()
554        .to_string();
555    let stderr = String::from_utf8_lossy(&output.stderr)
556        .trim_end()
557        .to_string();
558    axum::extract::Json(CliCommandResponse {
559        ok: output.status.success(),
560        message: if output.status.success() {
561            "command completed".into()
562        } else {
563            format!("command exited with {}", status.unwrap_or(-1))
564        },
565        status,
566        stdout,
567        stderr,
568    })
569}
570
571fn webview_cli_args(command: &str) -> Result<Vec<String>, String> {
572    let command = command.trim().trim_start_matches('/').trim();
573    if command.is_empty() {
574        return Err("empty command".into());
575    }
576    let mut args = split_webview_command(command)?;
577    if args.is_empty() {
578        return Err("empty command".into());
579    }
580    match args[0].as_str() {
581        "models" => args[0] = "model".into(),
582        "routing" => args[0] = "route".into(),
583        _ => {}
584    }
585    if args[0] == "model" && args.len() == 1 {
586        args.push("--list".into());
587    }
588    if args[0] == "run" && args.len() > 2 {
589        let task = args[1..].join(" ");
590        args.truncate(1);
591        args.push(task);
592    }
593    if args[0] == "plan" && args.len() > 2 {
594        let task = args[1..].join(" ");
595        args.truncate(1);
596        args.push(task);
597    }
598    if args[0] == "swarm" && args.len() > 2 {
599        let task = args[1..].join(" ");
600        args.truncate(1);
601        args.push(task);
602    }
603    Ok(args)
604}
605
606fn blocked_webview_cli_command(args: &[String]) -> Option<String> {
607    let first = args.first().map(String::as_str)?;
608    if matches!(first, "console" | "tui" | "chat" | "daemon") {
609        return Some(format!(
610            "`/{first}` opens an interactive process; launch it from a terminal instead."
611        ));
612    }
613    if first == "gateway" && args.get(1).map(String::as_str) == Some("start") {
614        return Some("`/gateway start` starts a daemon; launch it from a terminal instead.".into());
615    }
616    None
617}
618
619fn split_webview_command(input: &str) -> Result<Vec<String>, String> {
620    let mut args = Vec::new();
621    let mut current = String::new();
622    let mut chars = input.chars().peekable();
623    let mut quote: Option<char> = None;
624    while let Some(ch) = chars.next() {
625        match (quote, ch) {
626            (Some(q), c) if c == q => quote = None,
627            (Some(_), '\\') => {
628                if let Some(next) = chars.next() {
629                    current.push(next);
630                }
631            }
632            (Some(_), c) => current.push(c),
633            (None, '\'' | '"') => quote = Some(ch),
634            (None, c) if c.is_whitespace() => {
635                if !current.is_empty() {
636                    args.push(std::mem::take(&mut current));
637                }
638            }
639            (None, c) => current.push(c),
640        }
641    }
642    if let Some(q) = quote {
643        return Err(format!("unterminated {q} quote"));
644    }
645    if !current.is_empty() {
646        args.push(current);
647    }
648    Ok(args)
649}
650
651async fn get_commands(
652    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
653) -> axum::extract::Json<CommandsResponse> {
654    let commands = commands_for_state(&state)
655        .into_iter()
656        .map(|cmd| CommandView {
657            name: format!("/{}", cmd.name),
658            description: cmd.description,
659            usage: cmd.body,
660            source: match cmd.source {
661                crate::commands::SlashCommandSource::Builtin => "builtin".into(),
662                crate::commands::SlashCommandSource::Project(path) => {
663                    format!("project:{}", path.display())
664                }
665                crate::commands::SlashCommandSource::User(path) => {
666                    format!("user:{}", path.display())
667                }
668                crate::commands::SlashCommandSource::Skill(name) => format!("skill:{}", name),
669                crate::commands::SlashCommandSource::Plugin(name) => format!("plugin:{}", name),
670            },
671        })
672        .collect();
673    axum::extract::Json(CommandsResponse {
674        ok: true,
675        message: "commands loaded".into(),
676        commands,
677    })
678}
679
680fn commands_for_state(state: &AppState) -> Vec<crate::commands::SlashCommand> {
681    let project_root = std::env::current_dir().unwrap_or_default();
682    let config_dir = state
683        .config
684        .as_ref()
685        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
686        .unwrap_or_else(|| {
687            dirs::config_dir()
688                .unwrap_or_else(|| std::path::PathBuf::from("."))
689                .join("sparrow")
690        });
691    crate::commands::all_commands(&project_root, &config_dir, state.skills.as_deref())
692}
693
694async fn get_memory(
695    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
696) -> axum::extract::Json<MemoryResponse> {
697    let Some(memory) = &state.memory else {
698        return axum::extract::Json(MemoryResponse {
699            ok: false,
700            message: "memory unavailable".into(),
701            stats: None,
702            docs: Vec::new(),
703            facts: Vec::new(),
704        });
705    };
706    let stats = memory.memory_stats();
707    let docs = [MemoryDocKind::Memory, MemoryDocKind::User]
708        .into_iter()
709        .filter_map(|kind| {
710            memory.memory_doc(kind).map(|doc| MemoryDocView {
711                kind: kind.as_str().to_string(),
712                chars: doc.content.chars().count(),
713                limit: kind.limit(),
714                updated_at: doc.updated_at,
715                content: doc.content,
716            })
717        })
718        .collect();
719    let facts = memory
720        .all_facts()
721        .into_iter()
722        .take(25)
723        .map(|fact| MemoryFactView {
724            id: fact.id,
725            key: fact.key,
726            value: fact.value,
727            updated_at: fact.updated_at,
728        })
729        .collect();
730    axum::extract::Json(MemoryResponse {
731        ok: true,
732        message: "loaded".into(),
733        stats: Some(stats),
734        docs,
735        facts,
736    })
737}
738
739async fn get_plugins(
740    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
741) -> axum::extract::Json<PluginsResponse> {
742    let config_dir = state
743        .config
744        .as_ref()
745        .and_then(|cfg| cfg.read().ok().map(|cfg| cfg.config_dir.clone()))
746        .unwrap_or_else(|| {
747            dirs::config_dir()
748                .unwrap_or_else(|| std::path::PathBuf::from("."))
749                .join("sparrow")
750        });
751    let dirs = [
752        std::env::current_dir()
753            .unwrap_or_default()
754            .join(".sparrow")
755            .join("plugins"),
756        config_dir.join("plugins"),
757    ];
758    let mut plugins = Vec::new();
759    for dir in dirs {
760        let registry = crate::capabilities::plugin::PluginRegistry::new(dir);
761        for plugin in registry.scan() {
762            let audit = registry.audit(&plugin);
763            plugins.push(PluginView {
764                name: plugin.manifest.name,
765                version: plugin.manifest.version,
766                description: plugin.manifest.description,
767                commands: plugin.manifest.commands.len(),
768                skills: plugin.manifest.skills.len(),
769                hooks: plugin.manifest.hooks.len(),
770                allowed: audit.allowed,
771                warnings: audit.warnings,
772            });
773        }
774    }
775    axum::extract::Json(PluginsResponse {
776        ok: true,
777        message: "loaded".into(),
778        plugins,
779    })
780}
781
782async fn get_tools() -> axum::extract::Json<ToolsResponse> {
783    axum::extract::Json(ToolsResponse {
784        ok: true,
785        message: "loaded".into(),
786        toolsets: crate::tools::TOOLSETS
787            .iter()
788            .map(|set| set.to_string())
789            .collect(),
790        tools: crate::tools::known_tool_metadata(None),
791    })
792}
793
794async fn list_models(
795    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
796) -> axum::extract::Json<serde_json::Value> {
797    use crate::config::providers::provider_registry;
798    let providers = provider_registry();
799    let out: Vec<serde_json::Value> = providers
800        .iter()
801        .map(|p| {
802            // Curated models from the static registry.
803            let mut models: Vec<serde_json::Value> = p
804                .models
805                .iter()
806                .map(|m| {
807                    serde_json::json!({
808                        "name": m.name,
809                        "label": m.label,
810                        "tags": m.tags,
811                        "context_window": m.context_window,
812                        "cost_in": m.cost_input_per_mtok,
813                        "cost_out": m.cost_output_per_mtok,
814                        "recommended": m.recommended,
815                        "source": "registry",
816                    })
817                })
818                .collect();
819            // Merge live-discovered models from the SQLite cache (e.g. the 92
820            // NVIDIA models) so the picker shows everything, not just curated.
821            // For each discovered model, infer per-model caps from the name so
822            // the WebView context-window meter adapts (DeepSeek V4 Pro = 1M,
823            // not the previous hard-coded 128k default).
824            if let Some(mem) = &state.memory {
825                let curated: std::collections::HashSet<String> =
826                    p.models.iter().map(|m| m.name.clone()).collect();
827                for name in mem.get_discovered_models(&p.id) {
828                    if !curated.contains(&name) {
829                        let caps = crate::config::providers::model_caps(&p.id, &name);
830                        models.push(serde_json::json!({
831                            "name": name,
832                            "label": name,
833                            "tags": [],
834                            "context_window": caps.context_window,
835                            "max_output": caps.max_output,
836                            "cost_in": caps.cost_input_per_mtok,
837                            "cost_out": caps.cost_output_per_mtok,
838                            "recommended": false,
839                            "source": "discovered",
840                        }));
841                    }
842                }
843            }
844            serde_json::json!({
845                "id": p.id,
846                "label": p.label,
847                "tags": p.tags,
848                "model_count": models.len(),
849                "models": models,
850            })
851        })
852        .collect();
853    axum::extract::Json(serde_json::json!({ "ok": true, "providers": out }))
854}
855
856async fn stop_run(
857    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
858) -> axum::extract::Json<RunResponse> {
859    match &state.command_tx {
860        Some(tx) if tx.send("__stop__".to_string()).is_ok() => axum::extract::Json(RunResponse {
861            ok: true,
862            message: "stop requested".into(),
863        }),
864        _ => axum::extract::Json(RunResponse {
865            ok: false,
866            message: "console command channel unavailable".into(),
867        }),
868    }
869}
870
871async fn reset_conversation(
872    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
873) -> axum::extract::Json<RunResponse> {
874    // Send a sentinel string through the command channel; main.rs interprets
875    // __reset_conversation__ as a request to drain conv_history.
876    match &state.command_tx {
877        Some(tx) if tx.send("__reset_conversation__".to_string()).is_ok() => {
878            axum::extract::Json(RunResponse {
879                ok: true,
880                message: "conversation cleared".into(),
881            })
882        }
883        _ => axum::extract::Json(RunResponse {
884            ok: false,
885            message: "console command channel unavailable".into(),
886        }),
887    }
888}
889
890async fn get_status() -> axum::extract::Json<serde_json::Value> {
891    use crate::config::providers::provider_registry;
892    let providers = provider_registry();
893    axum::extract::Json(serde_json::json!({
894        "ok": true,
895        "version": env!("CARGO_PKG_VERSION"),
896        "providers_total": providers.len(),
897        "workdir": std::env::current_dir().ok().map(|p| p.to_string_lossy().to_string()),
898    }))
899}
900
901#[derive(serde::Deserialize)]
902struct FileQuery {
903    path: String,
904}
905
906async fn read_file(
907    axum::extract::Query(q): axum::extract::Query<FileQuery>,
908) -> axum::response::Response {
909    use axum::response::IntoResponse;
910    // Sandbox: only allow paths inside cwd.
911    let cwd = match std::env::current_dir() {
912        Ok(d) => d,
913        Err(_) => {
914            return (
915                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
916                "cwd unavailable",
917            )
918                .into_response();
919        }
920    };
921    // Canonicalize cwd too so UNC prefixes match on Windows.
922    let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
923    let requested = std::path::Path::new(&q.path);
924    let canonical = match cwd.join(requested).canonicalize() {
925        Ok(p) => p,
926        Err(_) => return (axum::http::StatusCode::NOT_FOUND, "file not found").into_response(),
927    };
928    if !canonical.starts_with(&cwd_canon) {
929        return (axum::http::StatusCode::FORBIDDEN, "path outside workdir").into_response();
930    }
931    match std::fs::read_to_string(&canonical) {
932        Ok(content) => {
933            let ext = canonical.extension().and_then(|e| e.to_str()).unwrap_or("");
934            let lang = match ext {
935                "rs" => "rust",
936                "js" | "ts" | "jsx" | "tsx" => "javascript",
937                "py" => "python",
938                "toml" => "toml",
939                "md" => "markdown",
940                "html" => "html",
941                "css" => "css",
942                "json" => "json",
943                _ => "text",
944            };
945            axum::extract::Json(serde_json::json!({
946                "ok": true, "path": q.path, "lang": lang,
947                "lines": content.lines().count(),
948                "content": content,
949            }))
950            .into_response()
951        }
952        Err(_) => (axum::http::StatusCode::NOT_FOUND, "cannot read file").into_response(),
953    }
954}
955
956async fn resolve_approval(
957    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
958    axum::extract::Json(req): axum::extract::Json<ApprovalResponseRequest>,
959) -> axum::extract::Json<RunResponse> {
960    let Some(approvals) = &state.approvals else {
961        return axum::extract::Json(RunResponse {
962            ok: false,
963            message: "approval channel unavailable".into(),
964        });
965    };
966    let decision = match req.decision.trim().to_lowercase().as_str() {
967        "allow" | "approve" | "approved" => Decision::Allow,
968        "deny" | "reject" | "rejected" => Decision::Deny,
969        _ => {
970            return axum::extract::Json(RunResponse {
971                ok: false,
972                message: "decision must be approve or deny".into(),
973            });
974        }
975    };
976    if approvals.resolve(req.id.trim(), decision).await {
977        axum::extract::Json(RunResponse {
978            ok: true,
979            message: "approval resolved".into(),
980        })
981    } else {
982        axum::extract::Json(RunResponse {
983            ok: false,
984            message: "approval not found or already resolved".into(),
985        })
986    }
987}
988
989async fn get_config(
990    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
991) -> axum::extract::Json<ConfigResponse> {
992    let Some(shared) = &state.config else {
993        return axum::extract::Json(ConfigResponse {
994            ok: false,
995            message: "config unavailable".into(),
996            budget: None,
997            workdir: None,
998            skills_count: None,
999            autonomy: String::new(),
1000            sandbox: String::new(),
1001            providers: vec![],
1002        });
1003    };
1004
1005    let cfg = shared.read().expect("config lock poisoned").clone();
1006    let auth = crate::auth::store::ChainedAuthStore::new(cfg.config_dir.clone());
1007    let mut providers = crate::config::providers::onboarding_providers()
1008        .into_iter()
1009        .map(|def| {
1010            let configured = cfg.providers.get(&def.id);
1011            let api_key_env = configured
1012                .and_then(|p| {
1013                    p.api_key_env
1014                        .as_ref()
1015                        .filter(|value| !looks_like_api_key(value))
1016                        .cloned()
1017                })
1018                .or_else(|| def.api_key_env.clone());
1019            let has_credential = auth.get(&def.id).is_some()
1020                || configured
1021                    .and_then(|p| p.api_key_env.as_ref())
1022                    .map(|value| {
1023                        looks_like_api_key(value)
1024                            || std::env::var(value)
1025                                .map(|env_value| !env_value.is_empty())
1026                                .unwrap_or(false)
1027                    })
1028                    .unwrap_or(false)
1029                || api_key_env
1030                    .as_ref()
1031                    .map(|value| {
1032                        std::env::var(value)
1033                            .map(|env_value| !env_value.is_empty())
1034                            .unwrap_or(false)
1035                    })
1036                    .unwrap_or(false);
1037
1038            // Merge configured + curated + discovered into a single sorted
1039            // unique list so the config panel's "X models" count survives
1040            // across sessions (was only counting the static registry, hiding
1041            // the 60+ models the user had just scanned).
1042            let mut models: Vec<String> = configured
1043                .map(|p| {
1044                    if p.models.is_empty() {
1045                        def.models.iter().map(|m| m.name.clone()).collect()
1046                    } else {
1047                        p.models.clone()
1048                    }
1049                })
1050                .unwrap_or_else(|| def.models.iter().map(|m| m.name.clone()).collect());
1051            if let Some(mem) = &state.memory {
1052                let known: std::collections::HashSet<String> = models.iter().cloned().collect();
1053                for name in mem.get_discovered_models(&def.id) {
1054                    if !known.contains(&name) {
1055                        models.push(name);
1056                    }
1057                }
1058            }
1059            ProviderView {
1060                name: def.id,
1061                label: def.label,
1062                adapter: configured.map(|p| p.adapter.clone()).unwrap_or(def.adapter),
1063                base_url: configured
1064                    .and_then(|p| p.base_url.clone())
1065                    .or(Some(def.base_url)),
1066                models,
1067                tags: def.tags,
1068                notes: def.notes,
1069                api_key_env,
1070                has_credential,
1071                configured: configured.is_some(),
1072            }
1073        })
1074        .collect::<Vec<_>>();
1075
1076    for (name, p) in &cfg.providers {
1077        if providers.iter().any(|view| &view.name == name) {
1078            continue;
1079        }
1080        let api_key_env = p
1081            .api_key_env
1082            .as_ref()
1083            .filter(|value| !looks_like_api_key(value))
1084            .cloned();
1085        providers.push(ProviderView {
1086            name: name.clone(),
1087            label: name.clone(),
1088            adapter: p.adapter.clone(),
1089            base_url: p.base_url.clone(),
1090            models: p.models.clone(),
1091            tags: vec!["custom".into()],
1092            notes: "Custom configured provider.".into(),
1093            api_key_env: api_key_env.clone(),
1094            has_credential: auth.get(name).is_some()
1095                || p.api_key_env
1096                    .as_ref()
1097                    .map(|value| {
1098                        looks_like_api_key(value)
1099                            || std::env::var(value)
1100                                .map(|env_value| !env_value.is_empty())
1101                                .unwrap_or(false)
1102                    })
1103                    .unwrap_or(false),
1104            configured: true,
1105        });
1106    }
1107    providers.sort_by(|a, b| a.name.cmp(&b.name));
1108
1109    axum::extract::Json(ConfigResponse {
1110        ok: true,
1111        message: "loaded".into(),
1112        autonomy: format!("{:?}", cfg.defaults.autonomy),
1113        sandbox: cfg.defaults.sandbox,
1114        providers,
1115        budget: Some(BudgetView {
1116            session_usd: cfg.budget.session_usd,
1117            daily_usd: cfg.budget.daily_usd,
1118        }),
1119        workdir: std::env::current_dir()
1120            .ok()
1121            .map(|p| p.to_string_lossy().to_string()),
1122        skills_count: None,
1123    })
1124}
1125
1126async fn save_provider(
1127    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1128    axum::extract::Json(req): axum::extract::Json<ProviderRequest>,
1129) -> axum::extract::Json<RunResponse> {
1130    let Some(shared) = &state.config else {
1131        return axum::extract::Json(RunResponse {
1132            ok: false,
1133            message: "config unavailable".into(),
1134        });
1135    };
1136
1137    let mut cfg = shared.write().expect("config lock poisoned");
1138    if let Some(level) = parse_autonomy(req.autonomy.as_deref()) {
1139        cfg.defaults.autonomy = level;
1140    }
1141    if let Some(sandbox) = req
1142        .sandbox
1143        .as_ref()
1144        .map(|s| s.trim().to_string())
1145        .filter(|s| !s.is_empty())
1146    {
1147        cfg.defaults.sandbox = sandbox;
1148    }
1149
1150    let name = req.name.trim().to_lowercase();
1151    if name.is_empty() {
1152        let saved = cfg.clone();
1153        let store = FsConfigStore::new(saved.config_dir.clone());
1154        if let Err(err) = store.save(&saved) {
1155            return axum::extract::Json(RunResponse {
1156                ok: false,
1157                message: format!("config save failed: {}", err),
1158            });
1159        }
1160        return axum::extract::Json(RunResponse {
1161            ok: true,
1162            message: "runtime preferences saved".into(),
1163        });
1164    }
1165
1166    let raw_api_key_env = req
1167        .api_key_env
1168        .as_ref()
1169        .map(|s| s.trim().to_string())
1170        .filter(|s| !s.is_empty());
1171    let api_key_env = raw_api_key_env
1172        .as_ref()
1173        .filter(|value| !looks_like_api_key(value))
1174        .cloned();
1175    let api_key_from_env_field = raw_api_key_env
1176        .as_ref()
1177        .filter(|value| looks_like_api_key(value))
1178        .cloned();
1179
1180    cfg.providers.insert(
1181        name.clone(),
1182        ProviderConfig {
1183            adapter: req.adapter.trim().to_string(),
1184            base_url: req
1185                .base_url
1186                .as_ref()
1187                .map(|s| s.trim().to_string())
1188                .filter(|s| !s.is_empty()),
1189            models: req
1190                .models
1191                .into_iter()
1192                .map(|m| m.trim().to_string())
1193                .filter(|m| !m.is_empty())
1194                .collect(),
1195            api_key_env,
1196        },
1197    );
1198
1199    let saved = cfg.clone();
1200    let store = FsConfigStore::new(saved.config_dir.clone());
1201    if let Err(err) = store.save(&saved) {
1202        return axum::extract::Json(RunResponse {
1203            ok: false,
1204            message: format!("config save failed: {}", err),
1205        });
1206    }
1207
1208    if let Some(key) = req
1209        .api_key
1210        .map(|k| k.trim().to_string())
1211        .filter(|k| !k.is_empty())
1212        .or(api_key_from_env_field)
1213    {
1214        let auth = crate::auth::store::ChainedAuthStore::new(saved.config_dir);
1215        if let Err(err) = auth.set(&name, Credential::api_key(key)) {
1216            return axum::extract::Json(RunResponse {
1217                ok: false,
1218                message: format!("credential save failed: {}", err),
1219            });
1220        }
1221    }
1222
1223    axum::extract::Json(RunResponse {
1224        ok: true,
1225        message: format!("provider '{}' saved", name),
1226    })
1227}
1228
1229/// Hard cap for WebView attachments (10 MB).
1230pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
1231
1232/// Where attachments are stored, relative to the current working directory.
1233pub fn attachments_dir() -> std::path::PathBuf {
1234    std::env::current_dir()
1235        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1236        .join(".sparrow")
1237        .join("attachments")
1238}
1239
1240#[derive(serde::Serialize)]
1241pub struct AttachmentMetadata {
1242    pub name: String,
1243    pub path: String,
1244    pub size: u64,
1245    pub mime: String,
1246    pub kind: &'static str,
1247}
1248
1249pub fn classify_attachment(mime: &str, ext: &str) -> &'static str {
1250    let ext = ext.to_ascii_lowercase();
1251    if mime.starts_with("image/")
1252        || matches!(
1253            ext.as_str(),
1254            "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
1255        )
1256    {
1257        "image"
1258    } else if mime.starts_with("audio/")
1259        || matches!(ext.as_str(), "mp3" | "wav" | "m4a" | "ogg" | "flac")
1260    {
1261        "audio"
1262    } else if mime == "application/pdf" || ext == "pdf" {
1263        "pdf"
1264    } else if mime.starts_with("text/")
1265        || matches!(
1266            ext.as_str(),
1267            "md" | "txt" | "csv" | "json" | "toml" | "yml" | "yaml"
1268        )
1269    {
1270        "text"
1271    } else {
1272        "file"
1273    }
1274}
1275
1276async fn upload_attachment(
1277    mut multipart: axum::extract::Multipart,
1278) -> axum::extract::Json<serde_json::Value> {
1279    let dir = attachments_dir();
1280    if let Err(e) = std::fs::create_dir_all(&dir) {
1281        return axum::extract::Json(serde_json::json!({
1282            "ok": false,
1283            "message": format!("could not create attachments dir: {}", e),
1284        }));
1285    }
1286    let mut accepted: Vec<AttachmentMetadata> = Vec::new();
1287    let mut rejected: Vec<serde_json::Value> = Vec::new();
1288    while let Ok(Some(field)) = multipart.next_field().await {
1289        let original = field
1290            .file_name()
1291            .map(|s| s.to_string())
1292            .unwrap_or_else(|| "upload.bin".into());
1293        let content_type = field
1294            .content_type()
1295            .unwrap_or("application/octet-stream")
1296            .to_string();
1297        let data = match field.bytes().await {
1298            Ok(b) => b,
1299            Err(e) => {
1300                rejected.push(
1301                    serde_json::json!({"name": original, "reason": format!("read error: {}", e)}),
1302                );
1303                continue;
1304            }
1305        };
1306        if data.len() > MAX_ATTACHMENT_BYTES {
1307            rejected.push(serde_json::json!({
1308                "name": original,
1309                "reason": format!("too large: {} bytes > limit {}", data.len(), MAX_ATTACHMENT_BYTES),
1310            }));
1311            continue;
1312        }
1313        // Sanitize filename: strip directory components.
1314        let safe = std::path::Path::new(&original)
1315            .file_name()
1316            .map(|s| s.to_string_lossy().to_string())
1317            .unwrap_or_else(|| "upload.bin".into());
1318        let dest = dir.join(&safe);
1319        if let Err(e) = std::fs::write(&dest, &data) {
1320            rejected
1321                .push(serde_json::json!({"name": safe, "reason": format!("write error: {}", e)}));
1322            continue;
1323        }
1324        let ext = std::path::Path::new(&safe)
1325            .extension()
1326            .map(|s| s.to_string_lossy().to_string())
1327            .unwrap_or_default();
1328        let kind = classify_attachment(&content_type, &ext);
1329        accepted.push(AttachmentMetadata {
1330            name: safe.clone(),
1331            path: dest.to_string_lossy().to_string(),
1332            size: data.len() as u64,
1333            mime: content_type,
1334            kind,
1335        });
1336    }
1337
1338    axum::extract::Json(serde_json::json!({
1339        "ok": !accepted.is_empty(),
1340        "accepted": accepted,
1341        "rejected": rejected,
1342        "limit_bytes": MAX_ATTACHMENT_BYTES,
1343    }))
1344}
1345
1346async fn list_artifacts() -> axum::extract::Json<serde_json::Value> {
1347    let dir = attachments_dir();
1348    let mut items: Vec<AttachmentMetadata> = Vec::new();
1349    if let Ok(entries) = std::fs::read_dir(&dir) {
1350        for entry in entries.flatten() {
1351            let path = entry.path();
1352            if !path.is_file() {
1353                continue;
1354            }
1355            let name = path
1356                .file_name()
1357                .map(|s| s.to_string_lossy().to_string())
1358                .unwrap_or_default();
1359            let ext = path
1360                .extension()
1361                .map(|s| s.to_string_lossy().to_string())
1362                .unwrap_or_default();
1363            let mime = mime_guess::from_path(&path)
1364                .first_or_octet_stream()
1365                .to_string();
1366            let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
1367            let kind = classify_attachment(&mime, &ext);
1368            items.push(AttachmentMetadata {
1369                name,
1370                path: path.to_string_lossy().to_string(),
1371                size,
1372                mime,
1373                kind,
1374            });
1375        }
1376    }
1377    axum::extract::Json(serde_json::json!({
1378        "ok": true,
1379        "items": items,
1380        "dir": dir.to_string_lossy().to_string(),
1381    }))
1382}
1383
1384/// `GET /agents` — return every installed agent so the WebView swarm row and
1385/// the composer's `@<name>` picker can render the real list (Sprint 1, item
1386/// dynamic WebView swarm row. Status is "idle" by default; the runtime updates a
1387/// shared `Arc<Mutex<AgentRuntimeState>>` later — for v0.3.0 we ship the
1388/// listing as a cold view backed by `FsAgentStore::list()`.
1389async fn list_agents() -> axum::extract::Json<serde_json::Value> {
1390    use crate::agent::{AgentStore, FsAgentStore};
1391
1392    let agents_dir = dirs::config_dir()
1393        .unwrap_or_else(|| std::path::PathBuf::from("."))
1394        .join("sparrow")
1395        .join("agents");
1396
1397    // Collect souls from user config dir + local repo `agents/` dir + `.sparrow/agents/`.
1398    let extra_dirs: Vec<std::path::PathBuf> = [
1399        std::env::current_dir().ok().map(|d| d.join("agents")),
1400        std::env::current_dir()
1401            .ok()
1402            .map(|d| d.join(".sparrow").join("agents")),
1403    ]
1404    .into_iter()
1405    .flatten()
1406    .filter(|p| p.is_dir())
1407    .collect();
1408
1409    let store = FsAgentStore::new(agents_dir.clone());
1410    let mut souls = store.list();
1411    let mut seen: std::collections::HashSet<String> =
1412        souls.iter().map(|s| s.name.clone()).collect();
1413    for dir in &extra_dirs {
1414        let extra = FsAgentStore::new(dir.clone()).list();
1415        for s in extra {
1416            if seen.insert(s.name.clone()) {
1417                souls.push(s);
1418            }
1419        }
1420    }
1421
1422    let items: Vec<serde_json::Value> = souls
1423        .into_iter()
1424        .map(|s| {
1425            // Pick a colour key the WebView already knows about; falls back to
1426            // the canonical triad if the agent uses one of those role names.
1427            let color_key = match s.role.to_lowercase().as_str() {
1428                "planner" => "planner",
1429                "coder" => "coder",
1430                "verifier" => "verifier",
1431                _ => s
1432                    .color
1433                    .as_deref()
1434                    .map(classify_agent_color)
1435                    .unwrap_or("steel"),
1436            };
1437            serde_json::json!({
1438                "name": s.name,
1439                "role": s.role,
1440                "description": s.description,
1441                "status": "idle",
1442                "msg": "",
1443                "color_key": color_key,
1444            })
1445        })
1446        .collect();
1447
1448    axum::extract::Json(serde_json::json!({
1449        "ok": true,
1450        "dir": agents_dir.to_string_lossy(),
1451        "agents": items,
1452    }))
1453}
1454
1455/// Maps the optional `color` field of a `Soul` to one of the known WebView
1456/// theme tokens. Unknown values fall back to `steel`.
1457pub fn classify_agent_color(raw: &str) -> &'static str {
1458    match raw.trim().to_lowercase().as_str() {
1459        "planner" | "blue" => "planner",
1460        "coder" | "teal" | "agent" => "coder",
1461        "verifier" | "sand" => "verifier",
1462        "gold" | "yellow" => "gold",
1463        "coral" | "red" => "coral",
1464        _ => "steel",
1465    }
1466}
1467
1468#[derive(serde::Deserialize)]
1469struct LoadSessionRequest {
1470    id: String,
1471}
1472
1473async fn load_session(
1474    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1475    axum::extract::Json(req): axum::extract::Json<LoadSessionRequest>,
1476) -> axum::extract::Json<RunResponse> {
1477    let id = req.id.trim();
1478    if id.is_empty() {
1479        return axum::extract::Json(RunResponse {
1480            ok: false,
1481            message: "empty session id".into(),
1482        });
1483    }
1484    // Reuse the same command channel sentinel mechanism that powers
1485    // __reset_conversation__ — main.rs interprets __load_session__:<id> and
1486    // swaps the live `conv_history` with the loaded session's messages.
1487    let sentinel = format!("__load_session__:{}", id);
1488    match &state.command_tx {
1489        Some(tx) if tx.send(sentinel).is_ok() => axum::extract::Json(RunResponse {
1490            ok: true,
1491            message: "session load requested".into(),
1492        }),
1493        _ => axum::extract::Json(RunResponse {
1494            ok: false,
1495            message: "console command channel unavailable".into(),
1496        }),
1497    }
1498}
1499
1500async fn list_sessions() -> axum::extract::Json<serde_json::Value> {
1501    // Resolve the same DB path the CLI uses. Failures degrade to an empty list
1502    // rather than 500'ing the WebView panel.
1503    let db_path = session_db_path();
1504    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1505        Ok(s) => s,
1506        Err(e) => {
1507            return axum::extract::Json(serde_json::json!({
1508                "ok": false,
1509                "message": format!("could not open session db: {}", e),
1510                "db_path": db_path.to_string_lossy(),
1511                "sessions": [],
1512            }));
1513        }
1514    };
1515    let sessions = store.list();
1516    axum::extract::Json(serde_json::json!({
1517        "ok": true,
1518        "db_path": db_path.to_string_lossy(),
1519        "sessions": sessions,
1520    }))
1521}
1522
1523async fn get_history(
1524    axum::extract::Query(query): axum::extract::Query<HistoryQuery>,
1525) -> axum::extract::Json<HistoryResponse> {
1526    let db_path = session_db_path();
1527    let store = match crate::runtime::session::SessionStore::open(&db_path) {
1528        Ok(s) => s,
1529        Err(e) => {
1530            return axum::extract::Json(HistoryResponse {
1531                ok: false,
1532                message: format!("could not open session db: {}", e),
1533                inputs: Vec::new(),
1534            });
1535        }
1536    };
1537    axum::extract::Json(HistoryResponse {
1538        ok: true,
1539        message: "loaded".into(),
1540        inputs: store.recent_inputs(query.limit.unwrap_or(50)),
1541    })
1542}
1543
1544fn session_db_path() -> std::path::PathBuf {
1545    dirs::state_dir()
1546        .or_else(dirs::data_local_dir)
1547        .or_else(dirs::data_dir)
1548        .unwrap_or_else(|| std::path::PathBuf::from("."))
1549        .join("sparrow")
1550        .join("sessions.db")
1551}
1552
1553async fn get_security(
1554    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1555) -> axum::extract::Json<serde_json::Value> {
1556    let Some(shared) = &state.config else {
1557        return axum::extract::Json(serde_json::json!({
1558            "ok": false,
1559            "message": "config unavailable",
1560        }));
1561    };
1562    let cfg = shared.read().expect("config lock poisoned").clone();
1563    let audit = crate::security::SecurityAudit::run(&cfg, &cfg.hooks);
1564    axum::extract::Json(serde_json::json!({
1565        "ok": true,
1566        "audit": audit,
1567    }))
1568}
1569
1570async fn get_permissions(
1571    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1572) -> axum::extract::Json<PermissionsResponse> {
1573    let Some(shared) = &state.config else {
1574        return axum::extract::Json(PermissionsResponse {
1575            ok: false,
1576            message: "config unavailable".into(),
1577            permissions: None,
1578        });
1579    };
1580    let cfg = shared.read().expect("config lock poisoned").clone();
1581    axum::extract::Json(PermissionsResponse {
1582        ok: true,
1583        message: "loaded".into(),
1584        permissions: Some(cfg.permissions),
1585    })
1586}
1587
1588async fn save_permissions(
1589    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1590    axum::extract::Json(req): axum::extract::Json<PermissionsRequest>,
1591) -> axum::extract::Json<RunResponse> {
1592    let Some(shared) = &state.config else {
1593        return axum::extract::Json(RunResponse {
1594            ok: false,
1595            message: "config unavailable".into(),
1596        });
1597    };
1598    let mut cfg = shared.write().expect("config lock poisoned");
1599    if let Some(mode) = req.mode.as_deref() {
1600        let Some(mode) = crate::permissions::PermissionMode::parse(mode) else {
1601            return axum::extract::Json(RunResponse {
1602                ok: false,
1603                message: "unknown permission mode".into(),
1604            });
1605        };
1606        cfg.defaults.autonomy = mode.autonomy_level();
1607        cfg.permissions.mode = mode;
1608    }
1609    let saved = cfg.clone();
1610    let store = FsConfigStore::new(saved.config_dir.clone());
1611    if let Err(err) = store.save(&saved) {
1612        return axum::extract::Json(RunResponse {
1613            ok: false,
1614            message: format!("permissions save failed: {}", err),
1615        });
1616    }
1617    axum::extract::Json(RunResponse {
1618        ok: true,
1619        message: "permissions saved".into(),
1620    })
1621}
1622
1623fn parse_autonomy(value: Option<&str>) -> Option<crate::event::AutonomyLevel> {
1624    match value.map(|s| s.trim().to_lowercase()).as_deref() {
1625        Some("supervised") => Some(crate::event::AutonomyLevel::Supervised),
1626        Some("trusted") => Some(crate::event::AutonomyLevel::Trusted),
1627        Some("autonomous") => Some(crate::event::AutonomyLevel::Autonomous),
1628        _ => None,
1629    }
1630}
1631
1632// ─── Provider model scan ──────────────────────────────────────────────────────
1633
1634#[derive(serde::Deserialize)]
1635struct ScanRequest {
1636    provider: String,
1637}
1638
1639#[derive(serde::Serialize)]
1640struct ScanResponse {
1641    ok: bool,
1642    message: String,
1643    models: Vec<String>,
1644}
1645
1646async fn scan_provider_models(
1647    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1648    axum::extract::Json(req): axum::extract::Json<ScanRequest>,
1649) -> axum::extract::Json<ScanResponse> {
1650    use crate::config::providers::find_provider;
1651
1652    let provider_id = req.provider.trim().to_string();
1653
1654    let Some(def) = find_provider(&provider_id) else {
1655        return axum::extract::Json(ScanResponse {
1656            ok: false,
1657            message: format!("Unknown provider: {}", provider_id),
1658            models: vec![],
1659        });
1660    };
1661
1662    // Resolve API key: auth store -> env var
1663    let api_key = {
1664        let key_from_store = state.config.as_ref().and_then(|cfg| {
1665            let c = cfg.read().ok()?;
1666            let auth = crate::auth::store::ChainedAuthStore::new(c.config_dir.clone());
1667            match auth.get(&provider_id) {
1668                Some(crate::auth::Credential::ApiKey(k)) => Some(k.expose_secret().to_string()),
1669                _ => None,
1670            }
1671        });
1672        let key_from_env = def
1673            .api_key_env
1674            .as_deref()
1675            .and_then(|env| std::env::var(env).ok());
1676        key_from_store.or(key_from_env).unwrap_or_default()
1677    };
1678
1679    match crate::provider::discovery::discover_models(&def.adapter, &def.base_url, &api_key).await {
1680        Ok(models) => {
1681            let count = models.len();
1682            axum::extract::Json(ScanResponse {
1683                ok: true,
1684                message: format!("Found {} model(s) for {}", count, def.label),
1685                models,
1686            })
1687        }
1688        Err(err) => axum::extract::Json(ScanResponse {
1689            ok: false,
1690            message: format!("Scan failed: {}", err),
1691            models: vec![],
1692        }),
1693    }
1694}
1695
1696// ─── Routing config get/set ───────────────────────────────────────────────────
1697
1698#[derive(serde::Serialize)]
1699struct RoutingResponse {
1700    ok: bool,
1701    preferred_provider: Option<String>,
1702    auto_discover: bool,
1703    all_providers: Vec<String>,
1704}
1705
1706#[derive(serde::Deserialize)]
1707struct RoutingRequest {
1708    /// Set to "" or null to clear the preference.
1709    preferred_provider: Option<String>,
1710    auto_discover: Option<bool>,
1711}
1712
1713async fn get_routing(
1714    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1715) -> axum::extract::Json<RoutingResponse> {
1716    use crate::config::providers::provider_registry;
1717
1718    let all_providers: Vec<String> = provider_registry().iter().map(|p| p.id.clone()).collect();
1719
1720    let Some(shared) = &state.config else {
1721        return axum::extract::Json(RoutingResponse {
1722            ok: false,
1723            preferred_provider: None,
1724            auto_discover: true,
1725            all_providers,
1726        });
1727    };
1728
1729    let cfg = shared.read().expect("config lock poisoned");
1730    axum::extract::Json(RoutingResponse {
1731        ok: true,
1732        preferred_provider: cfg.routing.preferred_provider.clone(),
1733        auto_discover: cfg.routing.auto_discover,
1734        all_providers,
1735    })
1736}
1737
1738async fn save_routing(
1739    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
1740    axum::extract::Json(req): axum::extract::Json<RoutingRequest>,
1741) -> axum::extract::Json<RunResponse> {
1742    let Some(shared) = &state.config else {
1743        return axum::extract::Json(RunResponse {
1744            ok: false,
1745            message: "config unavailable".into(),
1746        });
1747    };
1748
1749    {
1750        let mut cfg = shared.write().expect("config lock poisoned");
1751
1752        // preferred_provider: empty string or missing = clear
1753        cfg.routing.preferred_provider = req
1754            .preferred_provider
1755            .map(|s| s.trim().to_string())
1756            .filter(|s| !s.is_empty());
1757
1758        if let Some(ad) = req.auto_discover {
1759            cfg.routing.auto_discover = ad;
1760        }
1761
1762        let saved = cfg.clone();
1763        let store = FsConfigStore::new(saved.config_dir.clone());
1764        if let Err(err) = store.save(&saved) {
1765            return axum::extract::Json(RunResponse {
1766                ok: false,
1767                message: format!("save failed: {}", err),
1768            });
1769        }
1770    }
1771
1772    axum::extract::Json(RunResponse {
1773        ok: true,
1774        message: "Routing preferences saved.".into(),
1775    })
1776}
1777
1778async fn handle_ws(
1779    mut socket: axum::extract::ws::WebSocket,
1780    mut event_rx: tokio::sync::broadcast::Receiver<Event>,
1781) {
1782    loop {
1783        tokio::select! {
1784            result = event_rx.recv() => {
1785                match result {
1786                    Ok(event) => {
1787                        if !event.is_public() {
1788                            continue;
1789                        }
1790                        if let Ok(json) = serde_json::to_string(&event) {
1791                            use axum::extract::ws::Message;
1792                            if socket.send(Message::Text(json.into())).await.is_err() {
1793                                break;
1794                            }
1795                        }
1796                    }
1797                    Err(_) => break,
1798                }
1799            }
1800            _ = tokio::time::sleep(tokio::time::Duration::from_secs(30)) => {
1801                // Ping keep-alive
1802                use axum::extract::ws::Message;
1803                if socket.send(Message::Ping(vec![])).await.is_err() {
1804                    break;
1805                }
1806            }
1807        }
1808    }
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813    use super::*;
1814
1815    #[test]
1816    fn webview_cli_args_maps_model_alias() {
1817        assert_eq!(
1818            webview_cli_args("/models").unwrap(),
1819            vec!["model".to_string(), "--list".to_string()]
1820        );
1821    }
1822
1823    #[test]
1824    fn webview_cli_args_keeps_quoted_arguments() {
1825        assert_eq!(
1826            webview_cli_args("/auth add \"open router\"").unwrap(),
1827            vec![
1828                "auth".to_string(),
1829                "add".to_string(),
1830                "open router".to_string()
1831            ]
1832        );
1833    }
1834
1835    #[test]
1836    fn webview_cli_args_joins_run_task() {
1837        assert_eq!(
1838            webview_cli_args("/run analyse le repo github").unwrap(),
1839            vec!["run".to_string(), "analyse le repo github".to_string()]
1840        );
1841    }
1842
1843    #[test]
1844    fn webview_cli_blocks_interactive_commands() {
1845        let args = webview_cli_args("/console --port 9339").unwrap();
1846        assert!(blocked_webview_cli_command(&args).is_some());
1847        let args = webview_cli_args("/gateway start").unwrap();
1848        assert!(blocked_webview_cli_command(&args).is_some());
1849    }
1850}