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