1use crate::config::ShuttleConfig;
2use crate::pool::ConnectionPool;
3use crate::tools;
4use crate::tunnel::TunnelManager;
5use serde_json::{json, Value};
6use std::collections::HashMap;
7use std::sync::Arc;
8use styrene_identity::signer::{IdentitySigner, RootSecret};
9use tokio::sync::RwLock;
10use zeroize::Zeroize;
11
12pub struct ShuttleExtension {
13 config: Arc<RwLock<ShuttleConfig>>,
14 pool: ConnectionPool,
15 tunnels: TunnelManager,
16 signer: Arc<RwLock<Option<Box<dyn IdentitySigner>>>>,
19 root_cache: Arc<RwLock<CachedRoot>>,
20}
21
22const ROOT_SECRET_TTL: std::time::Duration = std::time::Duration::from_secs(300);
23
24struct CachedRoot {
25 secret: Option<RootSecret>,
26 cached_at: Option<std::time::Instant>,
27}
28
29impl CachedRoot {
30 fn empty() -> Self {
31 Self {
32 secret: None,
33 cached_at: None,
34 }
35 }
36
37 fn get(&self) -> Option<&RootSecret> {
38 let cached_at = self.cached_at?;
39 if cached_at.elapsed() > ROOT_SECRET_TTL {
40 return None;
41 }
42 self.secret.as_ref()
43 }
44
45 fn set(&mut self, root: &RootSecret) {
46 self.secret = Some(RootSecret::new(*root.as_bytes()));
47 self.cached_at = Some(std::time::Instant::now());
48 }
49}
50
51impl ShuttleExtension {
52 pub fn new() -> Self {
53 let config = ShuttleConfig::default();
54 Self {
55 config: Arc::new(RwLock::new(config)),
56 pool: ConnectionPool::new(),
57 tunnels: TunnelManager::new(),
58 signer: Arc::new(RwLock::new(None)),
59 root_cache: Arc::new(RwLock::new(CachedRoot::empty())),
60 }
61 }
62
63 async fn ensure_signer(&self) -> omegon_extension::Result<()> {
65 {
66 let s = self.signer.read().await;
67 if s.is_some() {
68 return Ok(());
69 }
70 }
71
72 let discovered = styrene_identity::discover::discover().ok_or_else(|| {
73 omegon_extension::Error::internal_error(
74 "no styrene identity found — run `styrene identity init` first",
75 )
76 })?;
77
78 let mut passphrase = std::env::var("STYRENE_PASSPHRASE")
81 .map(|s| s.into_bytes())
82 .map_err(|_| {
83 omegon_extension::Error::internal_error(
84 "set STYRENE_PASSPHRASE to unlock the identity file",
85 )
86 })?;
87
88 #[allow(unused_unsafe)]
89 unsafe {
90 std::env::remove_var("STYRENE_PASSPHRASE");
91 }
92
93 let provider =
94 styrene_identity::file_signer::StaticPassphraseProvider::new(&passphrase);
95 passphrase.zeroize();
96
97 let signer =
98 styrene_identity::file_signer::FileSigner::new(&discovered.path, Box::new(provider));
99
100 let mut s = self.signer.write().await;
101 *s = Some(Box::new(signer));
102 Ok(())
103 }
104
105 async fn root_secret(&self) -> omegon_extension::Result<RootSecret> {
106 {
108 let cache = self.root_cache.read().await;
109 if let Some(root) = cache.get() {
110 return Ok(RootSecret::new(*root.as_bytes()));
111 }
112 }
113
114 self.ensure_signer().await?;
116 let signer_guard = self.signer.read().await;
117 let signer = signer_guard.as_ref().unwrap();
118 let root = signer
119 .root_secret()
120 .await
121 .map_err(|e| omegon_extension::Error::internal_error(format!("unlock identity: {e}")))?;
122
123 let mut cache = self.root_cache.write().await;
124 cache.set(&root);
125
126 Ok(root)
127 }
128
129 async fn execute_tool(&self, name: &str, params: Value) -> omegon_extension::Result<Value> {
130 match name {
131 "ssh_exec" => self.tool_ssh_exec(params).await,
132 "ssh_script" => self.tool_ssh_script(params).await,
133 "scp_push" => self.tool_scp_push(params).await,
134 "scp_pull" => self.tool_scp_pull(params).await,
135 "sftp_ls" => self.tool_sftp_ls(params).await,
136 "sftp_read" => self.tool_sftp_read(params).await,
137 "ssh_tunnel_open" => self.tool_tunnel_open(params).await,
138 "ssh_tunnel_close" => self.tool_tunnel_close(params).await,
139 "ssh_tunnel_list" => self.tool_tunnel_list(params).await,
140 "ssh_hosts" => self.tool_ssh_hosts(params).await,
141 "ssh_ping" => self.tool_ssh_ping(params).await,
142 "ssh_migrate_analyze" => self.tool_migrate_analyze(params).await,
143 _ => Err(omegon_extension::Error::method_not_found(&format!("tool '{name}'"))),
144 }
145 }
146
147 fn extract_host(params: &Value) -> omegon_extension::Result<&str> {
148 params
149 .get("host")
150 .and_then(|v| v.as_str())
151 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'host'"))
152 }
153
154 async fn acquire_client(
155 &self,
156 host_name: &str,
157 ) -> omegon_extension::Result<Arc<tokio::sync::Mutex<crate::client::SshClient>>> {
158 let config = self.config.read().await;
159 let root = self.root_secret().await?;
160 self.pool
161 .acquire(host_name, &config, &root)
162 .await
163 .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))
164 }
165
166 async fn tool_ssh_exec(&self, params: Value) -> omegon_extension::Result<Value> {
169 let host = Self::extract_host(¶ms)?;
170 let command = params.get("command").and_then(|v| v.as_str()).unwrap_or("");
171 tracing::info!(
172 tool = "ssh_exec",
173 host,
174 command,
175 "executing remote command"
176 );
177 let client = self.acquire_client(host).await?;
178 let config = self.config.read().await;
179 let result = crate::exec::ssh_exec(&client, &config, ¶ms).await;
180 if let Ok(ref v) = result {
181 tracing::info!(
182 tool = "ssh_exec",
183 host,
184 exit_code = v.get("exit_code").and_then(|v| v.as_u64()).unwrap_or(255),
185 truncated = v.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),
186 "command completed"
187 );
188 }
189 result
190 }
191
192 async fn tool_ssh_script(&self, params: Value) -> omegon_extension::Result<Value> {
193 let host = Self::extract_host(¶ms)?;
194 let script = params.get("script").and_then(|v| v.as_str()).unwrap_or("");
195 let interpreter = params
196 .get("interpreter")
197 .and_then(|v| v.as_str())
198 .unwrap_or("/bin/bash");
199 let script_hash = format!("{:016x}", fxhash(script.as_bytes()));
200 tracing::info!(
201 tool = "ssh_script",
202 host,
203 interpreter,
204 script_hash,
205 script_len = script.len(),
206 "executing remote script"
207 );
208 let client = self.acquire_client(host).await?;
209 let config = self.config.read().await;
210 let result = crate::exec::ssh_script(&client, &config, ¶ms).await;
211 if let Ok(ref v) = result {
212 tracing::info!(
213 tool = "ssh_script",
214 host,
215 exit_code = v.get("exit_code").and_then(|v| v.as_u64()).unwrap_or(255),
216 "script completed"
217 );
218 }
219 result
220 }
221
222 async fn tool_scp_push(&self, params: Value) -> omegon_extension::Result<Value> {
223 let host = Self::extract_host(¶ms)?;
224 let local = params.get("local_path").and_then(|v| v.as_str()).unwrap_or("");
225 let remote = params.get("remote_path").and_then(|v| v.as_str()).unwrap_or("");
226 tracing::info!(tool = "scp_push", host, local, remote, "uploading file");
227 let client = self.acquire_client(host).await?;
228 let config = self.config.read().await;
229 let result = crate::transfer::scp_push(&client, &config, ¶ms).await;
230 if let Ok(ref v) = result {
231 tracing::info!(
232 tool = "scp_push",
233 host,
234 bytes = v.get("bytes_written").and_then(|v| v.as_u64()).unwrap_or(0),
235 "upload complete"
236 );
237 }
238 result
239 }
240
241 async fn tool_scp_pull(&self, params: Value) -> omegon_extension::Result<Value> {
242 let host = Self::extract_host(¶ms)?;
243 let remote = params.get("remote_path").and_then(|v| v.as_str()).unwrap_or("");
244 let local = params.get("local_path").and_then(|v| v.as_str()).unwrap_or("");
245 tracing::info!(tool = "scp_pull", host, remote, local, "downloading file");
246 let client = self.acquire_client(host).await?;
247 let config = self.config.read().await;
248 crate::transfer::scp_pull(&client, &config, ¶ms).await
249 }
250
251 async fn tool_sftp_ls(&self, params: Value) -> omegon_extension::Result<Value> {
252 let host = Self::extract_host(¶ms)?;
253 let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
254 tracing::info!(tool = "sftp_ls", host, path, "listing remote directory");
255 let client = self.acquire_client(host).await?;
256 let config = self.config.read().await;
257 crate::transfer::sftp_ls(&client, &config, ¶ms).await
258 }
259
260 async fn tool_sftp_read(&self, params: Value) -> omegon_extension::Result<Value> {
261 let host = Self::extract_host(¶ms)?;
262 let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
263 tracing::info!(tool = "sftp_read", host, path, "reading remote file");
264 let client = self.acquire_client(host).await?;
265 let config = self.config.read().await;
266 let result = crate::transfer::sftp_read(&client, &config, ¶ms).await;
267 if let Ok(ref v) = result {
268 tracing::info!(
269 tool = "sftp_read",
270 host,
271 size = v.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
272 truncated = v.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),
273 "read complete"
274 );
275 }
276 result
277 }
278
279 async fn tool_tunnel_open(&self, params: Value) -> omegon_extension::Result<Value> {
280 let host = Self::extract_host(¶ms)?;
281 let local_port_raw = params
282 .get("local_port")
283 .and_then(|v| v.as_u64())
284 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'local_port'"))?;
285 if local_port_raw > 65535 {
286 return Err(omegon_extension::Error::invalid_params(
287 format!("local_port must be 0-65535 (got {local_port_raw})")
288 ));
289 }
290 let local_port = local_port_raw as u16;
291 let remote_host = params
292 .get("remote_host")
293 .and_then(|v| v.as_str())
294 .unwrap_or("127.0.0.1");
295 let remote_port_raw = params
296 .get("remote_port")
297 .and_then(|v| v.as_u64())
298 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'remote_port'"))?;
299 if remote_port_raw > 65535 {
300 return Err(omegon_extension::Error::invalid_params(
301 format!("remote_port must be 0-65535 (got {remote_port_raw})")
302 ));
303 }
304 let remote_port = remote_port_raw as u16;
305
306 tracing::info!(
307 tool = "ssh_tunnel_open",
308 host,
309 local_port,
310 remote_host,
311 remote_port,
312 "opening tunnel"
313 );
314 let client = self.acquire_client(host).await?;
315 let config = self.config.read().await;
316 self.tunnels
317 .open(
318 host,
319 local_port,
320 remote_host,
321 remote_port,
322 &config.allowed_tunnel_destinations,
323 client,
324 )
325 .await
326 }
327
328 async fn tool_tunnel_close(&self, params: Value) -> omegon_extension::Result<Value> {
329 let tunnel_id = params
330 .get("tunnel_id")
331 .and_then(|v| v.as_str())
332 .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'tunnel_id'"))?;
333 tracing::info!(tool = "ssh_tunnel_close", tunnel_id, "closing tunnel");
334 self.tunnels.close(tunnel_id).await
335 }
336
337 async fn tool_tunnel_list(&self, _params: Value) -> omegon_extension::Result<Value> {
338 self.tunnels.list().await
339 }
340
341 async fn tool_migrate_analyze(&self, params: Value) -> omegon_extension::Result<Value> {
342 tracing::info!(tool = "ssh_migrate_analyze", "scanning ~/.ssh for migration");
343 crate::migrate::ssh_migrate_analyze(¶ms).await
344 }
345
346 async fn tool_ssh_hosts(&self, _params: Value) -> omegon_extension::Result<Value> {
347 let config = self.config.read().await;
348 let hosts: Vec<Value> = config
349 .host_names()
350 .into_iter()
351 .filter_map(|name| {
352 config.resolve_host(name).ok().map(|entry| {
353 json!({
354 "name": name,
355 "address": entry.address,
356 "user": entry.user,
357 "port": entry.port,
358 })
359 })
360 })
361 .collect();
362 Ok(json!({ "hosts": hosts }))
363 }
364
365 async fn tool_ssh_ping(&self, params: Value) -> omegon_extension::Result<Value> {
366 let host = Self::extract_host(¶ms)?;
367 tracing::info!(tool = "ssh_ping", host, "testing connectivity");
368 let start = std::time::Instant::now();
369 match self.acquire_client(host).await {
370 Ok(_client) => {
371 let elapsed = start.elapsed();
372 tracing::info!(tool = "ssh_ping", host, latency_ms = elapsed.as_millis(), "reachable");
373 Ok(json!({
374 "host": host,
375 "reachable": true,
376 "latency_ms": elapsed.as_millis(),
377 }))
378 }
379 Err(e) => {
380 tracing::warn!(tool = "ssh_ping", host, error = %e, "unreachable");
381 Ok(json!({
382 "host": host,
383 "reachable": false,
384 "error": e.to_string(),
385 }))
386 }
387 }
388 }
389}
390
391fn fxhash(data: &[u8]) -> u64 {
393 let mut hash: u64 = 0xcbf29ce484222325;
394 for &b in data {
395 hash = hash.wrapping_mul(0x100000001b3);
396 hash ^= b as u64;
397 }
398 hash
399}
400
401#[async_trait::async_trait]
402impl omegon_extension::Extension for ShuttleExtension {
403 fn name(&self) -> &str {
404 "shuttle"
405 }
406
407 fn version(&self) -> &str {
408 env!("CARGO_PKG_VERSION")
409 }
410
411 async fn handle_rpc(
412 &self,
413 method: &str,
414 params: Value,
415 ) -> omegon_extension::Result<Value> {
416 match method {
417 "initialize" => {
418 let tools = tools::tool_definitions();
419 Ok(json!({
420 "protocol_version": 2,
421 "extension_info": {
422 "name": self.name(),
423 "version": self.version(),
424 "sdk_version": "0.19"
425 },
426 "capabilities": {
427 "tools": true,
428 },
429 "tools": tools,
430 }))
431 }
432
433 "get_tools" | "tools/list" => Ok(json!(tools::tool_definitions())),
434
435 "bootstrap_config" => {
436 let map: HashMap<String, Value> =
437 serde_json::from_value(params).unwrap_or_default();
438 self.on_config(map).await;
439 Ok(json!({ "acknowledged": true }))
440 }
441
442 "execute_tool" | "tools/call" => {
443 let name = params
444 .get("name")
445 .and_then(|v| v.as_str())
446 .unwrap_or("");
447 let args = params.get("arguments").cloned().unwrap_or(json!({}));
448 self.execute_tool(name, args).await
449 }
450
451 _ => Err(omegon_extension::Error::method_not_found(method)),
452 }
453 }
454
455 async fn on_config(&self, config: HashMap<String, Value>) {
456 let mut cfg = self.config.write().await;
457 cfg.apply_rpc_config(&config);
458
459 if let Err(e) = cfg.load_hosts() {
460 tracing::warn!("failed to load hosts: {e}");
461 } else {
462 let host_count = cfg.host_names().len();
463 tracing::info!(
464 hosts = host_count,
465 "hosts loaded from {}",
466 cfg.hosts_file.display()
467 );
468
469 if cfg.allowed_hosts.is_none() {
470 tracing::warn!(
471 "allowed_hosts is not configured — all {} hosts in hosts.toml are \
472 accessible. Set allowed_hosts to restrict agent access.",
473 host_count
474 );
475 }
476 }
477 }
478}