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