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