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