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