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