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