mod auth;
mod command;
mod connect;
mod exec;
mod fs;
mod sshcfg;
mod state;
mod tool;
mod transport;
mod tunnel;
#[cfg(test)]
mod e2e_test;
use crate::{
core::{
extensions::{
DockLine, DockSpan, Extension, ExtensionCommand, ExtensionHook, ExtensionMode,
ExtensionTool, ExtensionUiRequest, OverlayEvent, OverlayView, ToolExecCtx,
UiNotifyLevel, request_ui,
},
provider::AgentMessage,
session_manager::Session,
tools::{ToolError, ToolResult},
},
extensions::{
EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_SSH_REMOTE,
ssh_remote::state::RemoteConfig, tool_search,
},
modes::interactive::handlers::register_slash_command,
};
use futures_util::future::BoxFuture;
use serde_json::Value;
use std::{sync::Arc, time::Duration};
const EXT: &str = "ssh-remote";
const DOCK_MAX_LINES: usize = 4;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static SSH_REMOTE_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_SSH_REMOTE,
make: || Arc::new(SshRemote),
};
pub struct SshRemote;
impl Extension for SshRemote {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Remote SSH toolset: remote_exec/read/ls/grep/sync/pwd plus local port tunnels; built-in tools stay local"
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev]
}
fn prerequisite_tools(&self) -> Vec<String> {
vec![tool_search::TOOL_NAME.to_string()]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
tool::tools()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![command::command(), command::tunnel_command()]
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![
ExtensionHook::Dock,
ExtensionHook::AgentEvent,
ExtensionHook::Overlay,
]
}
fn on_agent_event(&self, event: &Value) {
if event.get("type").and_then(|v| v.as_str()) == Some("turn_start") {
transport::reset_turn_budget();
}
}
fn on_registered(&self) {
register_slash_command(EXT, command::CMD, command::handle);
register_slash_command(EXT, command::TUNNEL_CMD, command::handle_tunnel);
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
transport::disconnect_all();
tunnel::stop_all();
}
}
fn execute_tool_async(
&self,
name: String,
args: Value,
ctx: ToolExecCtx,
) -> BoxFuture<'static, std::result::Result<ToolResult, ToolError>> {
Box::pin(async move { tool::dispatch(&name, &args, &ctx.cwd).await })
}
fn overlay_view(&self, id: u64, cols: u16) -> Option<OverlayView> {
auth::overlay_view(id, cols)
}
fn on_overlay_event(&self, id: u64, ev: &OverlayEvent) -> bool {
auth::on_overlay_event(id, ev)
}
fn dock_lines(&self) -> Vec<DockLine> {
let connected = transport::connected();
let tunnels = tunnel::active();
if connected.is_empty() && tunnels.is_empty() {
return Vec::new();
}
let mut lines: Vec<DockLine> = vec![vec![DockSpan::new("muted", "#999999", "ssh remote")]];
for ep in connected.iter().take(DOCK_MAX_LINES) {
lines.push(vec![DockSpan::new("accent", "#8abeb7", ep.display())]);
}
if !tunnels.is_empty() {
let text = tunnels
.iter()
.map(|t| format!("127.0.0.1:{}", t.local_port))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![DockSpan::plain(format!("tunnels: {text}"))]);
}
lines
}
fn on_session_start(&self, _cwd: &str, _messages: &[AgentMessage], _session: Option<&Session>) {
if RemoteConfig::load().tunnels.is_empty() {
return;
}
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
for (spec, result) in tunnel::restore_all().await {
match result {
Ok(port) => request_ui(ExtensionUiRequest::Notify {
text: format!("SSH tunnel restored: 127.0.0.1:{port} ({spec})"),
level: UiNotifyLevel::Info,
}),
Err(e) => request_ui(ExtensionUiRequest::Notify {
text: format!("SSH tunnel {spec} failed: {e}"),
level: UiNotifyLevel::Warning,
}),
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::ssh_remote::state::TestRootGuard;
#[test]
fn endpoint_json_roundtrip() {
let value = serde_json::json!({
"command": "ssh root@gpu",
"host": "gpu",
"user": "root",
"port": 22,
"note": "H100",
"cwd": "/srv/x"
});
let ep: state::Endpoint = serde_json::from_value(value).unwrap();
assert_eq!(ep.key(), "root@gpu:22");
assert_eq!(ep.cwd, "/srv/x");
assert!(ep.display().contains("H100"));
}
#[test]
fn declares_six_tools_and_two_commands() {
let _l = state::TEST_ROOT_LOCK.blocking_lock();
let _g = TestRootGuard::temp();
let names: Vec<String> = SshRemote.tools().into_iter().map(|t| t.name).collect();
assert_eq!(names.len(), 6);
for expected in [
tool::EXEC,
tool::READ,
tool::LS,
tool::GREP,
tool::SYNC,
tool::PWD,
] {
assert!(names.iter().any(|n| n == expected), "缺少工具 {expected}");
}
let cmds: Vec<String> = SshRemote.commands().into_iter().map(|c| c.name).collect();
assert_eq!(cmds, vec![command::CMD, command::TUNNEL_CMD]);
assert!(!SshRemote.default_enabled(), "必须默认关闭");
assert_eq!(
SshRemote.prerequisite_tools(),
vec!["tool_search"],
"延迟工具的前置工具必须是 tool_search"
);
}
#[test]
fn dock_is_empty_without_connections() {
let _l = state::TEST_ROOT_LOCK.blocking_lock();
let _g = TestRootGuard::temp();
transport::disconnect_all();
tunnel::stop_all();
assert!(SshRemote.dock_lines().is_empty());
}
}