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