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