skiff_cli/session/
daemon.rs1use std::fs;
8use std::os::unix::fs::PermissionsExt;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13use serde_json::{json, Value};
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15use tokio::net::UnixListener;
16use tokio::sync::Mutex;
17
18use crate::error::{Error, Result};
19use crate::mcp::{
20 call_tool_on, connect_http, connect_stdio_with, list_tools_on, McpClient, TransportMode,
21};
22use crate::session::paths::{
23 chmod_0600, session_meta_path, session_sock_path, unlink_session_files, write_meta, SessionMeta,
24};
25use crate::session::peer::peer_uid_matches_self;
26use crate::session::protocol::{SessionMethod, SessionRequest, SessionResponse};
27use crate::session::spawn::DaemonConfig;
28use crate::tools_index::{build_compact_index, search_compact, CompactIndex};
29
30struct DaemonState {
31 name: String,
32 client: Option<McpClient>,
33 tools_cache: Option<Vec<Value>>,
34 tools_index: Option<CompactIndex>,
36 last_activity: Instant,
37}
38
39fn cache_tools(st: &mut DaemonState, tools: Vec<Value>) {
40 st.tools_index = Some(build_compact_index(&tools, false));
41 st.tools_cache = Some(tools);
42}
43
44pub fn run_session_daemon(config_path: PathBuf) -> Result<()> {
46 let raw = fs::read_to_string(&config_path)
47 .map_err(|e| Error::runtime(format!("cannot read session config: {e}")))?;
48 let _ = fs::remove_file(&config_path);
49 let config: DaemonConfig = serde_json::from_str(&raw)
50 .map_err(|e| Error::runtime(format!("invalid session config: {e}")))?;
51
52 let rt = tokio::runtime::Builder::new_multi_thread()
53 .enable_all()
54 .build()
55 .map_err(|e| Error::runtime(e.to_string()))?;
56 rt.block_on(daemon_main(config))
57}
58
59async fn daemon_main(config: DaemonConfig) -> Result<()> {
60 let name = config.name.clone();
61 let sock_path = session_sock_path(&name);
62 let _ = fs::remove_file(&sock_path);
63
64 let client = connect_mcp(&config).await?;
65 let created_at = SystemTime::now()
66 .duration_since(UNIX_EPOCH)
67 .map(|d| d.as_secs_f64())
68 .unwrap_or(0.0);
69 write_meta(
70 &name,
71 &SessionMeta {
72 pid: std::process::id(),
73 source: config.source.clone(),
74 transport: if config.is_stdio {
75 "stdio".into()
76 } else {
77 "http".into()
78 },
79 created_at,
80 idle_secs: config.idle_secs,
81 last_activity_at: created_at,
82 },
83 )?;
84
85 #[cfg(unix)]
94 let prev_umask = unsafe { libc::umask(0o177) };
95 let bind_result = UnixListener::bind(&sock_path);
96 #[cfg(unix)]
97 unsafe {
98 libc::umask(prev_umask);
99 }
100 let listener =
101 bind_result.map_err(|e| Error::runtime(format!("cannot bind session socket: {e}")))?;
102 chmod_0600(&sock_path)?;
103 if let Some(parent) = sock_path.parent() {
104 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
105 }
106
107 let state = Arc::new(Mutex::new(DaemonState {
108 name: name.clone(),
109 client: Some(client),
110 tools_cache: None,
111 tools_index: None,
112 last_activity: Instant::now(),
113 }));
114
115 let idle = Duration::from_secs(config.idle_secs);
116 let idle_enabled = config.idle_secs > 0;
117
118 loop {
119 tokio::select! {
120 _ = tokio::signal::ctrl_c() => {
121 break;
122 }
123 _ = wait_sigterm() => {
124 break;
125 }
126 accept = listener.accept() => {
127 match accept {
128 Ok((stream, _)) => {
129 let std_stream = match stream.into_std() {
130 Ok(s) => s,
131 Err(e) => {
132 tracing::debug!("session into_std: {e}");
133 continue;
134 }
135 };
136 if !peer_uid_matches_self(&std_stream) {
137 tracing::warn!("rejected session connection: peer UID mismatch");
138 continue;
139 }
140 let _ = std_stream.set_nonblocking(true);
141 let stream = match tokio::net::UnixStream::from_std(std_stream) {
142 Ok(s) => s,
143 Err(e) => {
144 tracing::debug!("session from_std: {e}");
145 continue;
146 }
147 };
148 let state = Arc::clone(&state);
149 tokio::spawn(async move {
150 if let Err(e) = handle_client(stream, state).await {
151 tracing::debug!("session client error: {e}");
152 }
153 });
154 }
155 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
156 Err(e) => {
157 tracing::debug!("session accept error: {e}");
158 tokio::time::sleep(Duration::from_millis(50)).await;
159 }
160 }
161 }
162 _ = tokio::time::sleep(Duration::from_secs(1)) => {
163 if idle_enabled {
164 let last = state.lock().await.last_activity;
165 if last.elapsed() >= idle {
166 tracing::info!("session idle timeout; shutting down");
167 break;
168 }
169 }
170 }
171 }
172 }
173
174 {
175 let mut st = state.lock().await;
176 if let Some(client) = st.client.take() {
177 let _ = client.cancel().await;
178 }
179 }
180 unlink_session_files(&name);
181 let _ = fs::remove_file(&sock_path);
182 let _ = fs::remove_file(session_meta_path(&name));
183 Ok(())
184}
185
186async fn wait_sigterm() {
187 #[cfg(unix)]
188 {
189 use tokio::signal::unix::{signal, SignalKind};
190 if let Ok(mut sigterm) = signal(SignalKind::terminate()) {
191 sigterm.recv().await;
192 return;
193 }
194 }
195 std::future::pending::<()>().await
196}
197
198async fn connect_mcp(config: &DaemonConfig) -> Result<McpClient> {
199 if config.is_stdio {
200 connect_stdio_with(&config.source, &config.env_vars, config.clean_env).await
201 } else {
202 let transport = TransportMode::parse(&config.transport)?;
203 connect_http(&config.source, &config.auth_headers, transport, None).await
204 }
205}
206
207async fn handle_client(
208 stream: tokio::net::UnixStream,
209 state: Arc<Mutex<DaemonState>>,
210) -> Result<()> {
211 let (reader, mut writer) = stream.into_split();
212 let mut lines = BufReader::new(reader).lines();
213 let Some(line) = lines
214 .next_line()
215 .await
216 .map_err(|e| Error::runtime(e.to_string()))?
217 else {
218 return Ok(());
219 };
220 let req: SessionRequest = serde_json::from_str(&line)
221 .map_err(|e| Error::runtime(format!("bad session request: {e}")))?;
222 let resp = match dispatch(&state, &req).await {
223 Ok(v) => SessionResponse::ok(req.id, v),
224 Err(e) => SessionResponse::err(req.id, e.to_string()),
225 };
226 let mut out = serde_json::to_string(&resp)?;
227 out.push('\n');
228 writer
229 .write_all(out.as_bytes())
230 .await
231 .map_err(|e| Error::runtime(e.to_string()))?;
232 let _ = writer.shutdown().await;
233 Ok(())
234}
235
236async fn dispatch(state: &Arc<Mutex<DaemonState>>, req: &SessionRequest) -> Result<Value> {
237 let method = SessionMethod::parse(&req.method)
238 .ok_or_else(|| Error::runtime(format!("Unknown method: {}", req.method)))?;
239
240 let mut st = state.lock().await;
241 st.last_activity = Instant::now();
242 let now = SystemTime::now()
243 .duration_since(UNIX_EPOCH)
244 .map(|d| d.as_secs_f64())
245 .unwrap_or(0.0);
246 if let Ok(Some(mut meta)) = crate::session::paths::load_meta(&st.name) {
247 meta.last_activity_at = now;
248 let _ = write_meta(&st.name, &meta);
249 }
250 let client = st
251 .client
252 .as_ref()
253 .ok_or_else(|| Error::runtime("session MCP client is shut down"))?;
254
255 match method {
256 SessionMethod::ListTools => {
257 let refresh = req
258 .params
259 .get("refresh")
260 .and_then(|v| v.as_bool())
261 .unwrap_or(false);
262 if !refresh {
263 if let Some(cached) = &st.tools_cache {
264 return Ok(Value::Array(cached.clone()));
265 }
266 }
267 let tools = list_tools_on(client).await?;
268 cache_tools(&mut st, tools.clone());
269 Ok(Value::Array(tools))
270 }
271 SessionMethod::ListToolsLight => {
272 let refresh = req
273 .params
274 .get("refresh")
275 .and_then(|v| v.as_bool())
276 .unwrap_or(false);
277 let search = req
278 .params
279 .get("search")
280 .and_then(|v| v.as_str())
281 .filter(|s| !s.is_empty());
282 if refresh || st.tools_cache.is_none() || st.tools_index.is_none() {
283 let tools = list_tools_on(client).await?;
284 cache_tools(&mut st, tools);
285 }
286 let index = st
287 .tools_index
288 .as_ref()
289 .ok_or_else(|| Error::runtime("session tools index missing"))?;
290 let entries = if let Some(pat) = search {
291 search_compact(index, pat)
292 } else {
293 index.to_entries()
294 };
295 Ok(index.to_light_tools_json(&entries))
296 }
297 SessionMethod::GetTool => {
298 let name = req
299 .params
300 .get("name")
301 .and_then(|v| v.as_str())
302 .ok_or_else(|| Error::usage("get_tool requires params.name"))?;
303 let refresh = req
304 .params
305 .get("refresh")
306 .and_then(|v| v.as_bool())
307 .unwrap_or(false);
308 if refresh || st.tools_cache.is_none() {
309 let tools = list_tools_on(client).await?;
310 cache_tools(&mut st, tools);
311 }
312 let tools = st.tools_cache.as_ref().unwrap();
313 let found = tools.iter().find(|t| {
315 let mcp = t.get("name").and_then(|n| n.as_str()).unwrap_or("");
316 mcp == name || crate::coerce::to_kebab(mcp) == name
317 });
318 Ok(found.cloned().unwrap_or(Value::Null))
319 }
320 SessionMethod::CallTool => {
321 let name = req
322 .params
323 .get("name")
324 .and_then(|v| v.as_str())
325 .ok_or_else(|| Error::usage("call_tool requires params.name"))?;
326 let arguments = req
327 .params
328 .get("arguments")
329 .and_then(|v| v.as_object())
330 .cloned()
331 .unwrap_or_default();
332 let full = req
333 .params
334 .get("full_envelope")
335 .and_then(|v| v.as_bool())
336 .unwrap_or(false);
337 call_tool_on(client, name, arguments, full).await
338 }
339 SessionMethod::ListResources => {
340 let resources = client
341 .list_all_resources()
342 .await
343 .map_err(|e| Error::runtime(format!("list_resources: {e}")))?;
344 let arr: Vec<Value> = resources
345 .into_iter()
346 .map(|r| {
347 json!({
348 "name": r.name,
349 "uri": r.uri,
350 "description": r.description.unwrap_or_default(),
351 "mimeType": r.mime_type.unwrap_or_default(),
352 })
353 })
354 .collect();
355 Ok(Value::Array(arr))
356 }
357 SessionMethod::ReadResource => {
358 let uri = req
359 .params
360 .get("uri")
361 .and_then(|v| v.as_str())
362 .ok_or_else(|| Error::usage("read_resource requires params.uri"))?;
363 let result = client
364 .read_resource(rmcp::model::ReadResourceRequestParams::new(uri))
365 .await
366 .map_err(|e| Error::runtime(format!("read_resource: {e}")))?;
367 Ok(serde_json::to_value(result).unwrap_or(Value::Null))
368 }
369 SessionMethod::ListResourceTemplates => {
370 let templates = client
371 .list_all_resource_templates()
372 .await
373 .map_err(|e| Error::runtime(format!("list_resource_templates: {e}")))?;
374 Ok(serde_json::to_value(templates).unwrap_or(Value::Null))
375 }
376 SessionMethod::ListPrompts => {
377 let prompts = client
378 .list_all_prompts()
379 .await
380 .map_err(|e| Error::runtime(format!("list_prompts: {e}")))?;
381 Ok(serde_json::to_value(prompts).unwrap_or(Value::Null))
382 }
383 SessionMethod::GetPrompt => {
384 let name = req
385 .params
386 .get("name")
387 .and_then(|v| v.as_str())
388 .ok_or_else(|| Error::usage("get_prompt requires params.name"))?;
389 let mut params = rmcp::model::GetPromptRequestParams::new(name);
390 if let Some(args) = req.params.get("arguments").and_then(|v| v.as_object()) {
391 params = params.with_arguments(args.clone());
392 }
393 let result = client
394 .get_prompt(params)
395 .await
396 .map_err(|e| Error::runtime(format!("get_prompt: {e}")))?;
397 Ok(serde_json::to_value(result).unwrap_or(Value::Null))
398 }
399 }
400}