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#[derive(Debug)]
28pub struct BindTarget {
29 pub addr: SocketAddr,
30 pub is_public: bool,
31}
32
33pub 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
56pub 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
74const 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
111pub 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 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 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 .route("/", get(|| async { Html(console_html().into_owned()) }))
207 .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 let rx = state.event_tx.subscribe();
260 let snapshot: Vec<Event> = recent.lock().iter().cloned().collect();
261 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 #[serde(default)]
440 persisted_tools: std::collections::HashMap<String, String>,
441}
442
443#[derive(serde::Deserialize)]
444struct PermissionsRequest {
445 mode: Option<String>,
446 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 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 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 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 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 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 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
1047async 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 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 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 "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 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
1407pub const MAX_ATTACHMENT_BYTES: usize = 10 * 1024 * 1024;
1409
1410pub 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 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
1524pub fn artifacts_dir() -> std::path::PathBuf {
1527 std::env::current_dir()
1528 .unwrap_or_else(|_| std::path::PathBuf::from("."))
1529 .join("artifacts")
1530}
1531
1532fn 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 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
1581async 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>, agent_md: Option<String>, allowed_tools: Option<Vec<String>>,
1625}
1626
1627async 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
1717async 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
1750async 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 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 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
1821pub 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 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 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
1910const PREVIEW_SCAN_PORTS: &[u16] = &[
1914 3000, 3001, 4200, 4321, 5000, 5173, 5174, 8000, 8080, 8081, 8501, 8888, 1313,
1915];
1916
1917async 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 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
1960async 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
2025async 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
2048async 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
2080fn 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
2132async 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 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
2170async 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
2214async 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 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 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
2279async 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
2318async 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 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 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#[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 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#[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 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
2596fn 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 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 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 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}