feat: release OpsXShell 2.1.1

This commit is contained in:
Logan.Li committed 2026-07-31 01:37:01 +08:00
1 parent a3dba0c6ed
commit 494ef6b9d0
37 files changed
+2039 -370

No files matched your search

+8 -3
View File
@@ -41,22 +41,27 @@ pub async fn ssh_exec(
) -> Result<ExecResponse, String> {
let service = state.ssh.clone();
Ok(
match tauri::async_runtime::spawn_blocking(move || service.exec(&connection_id, &command))
.await
match tauri::async_runtime::spawn_blocking(move || {
service.exec_with_status(&connection_id, &command)
})
.await
{
Ok(Ok(result)) => ExecResponse {
Ok(Ok((result, exit_code))) => ExecResponse {
success: true,
result: Some(result),
exit_code: Some(exit_code),
error: None,
},
Ok(Err(error)) => ExecResponse {
success: false,
result: None,
exit_code: None,
error: Some(error),
},
Err(error) => ExecResponse {
success: false,
result: None,
exit_code: None,
error: Some(error.to_string()),
},
},
+11
View File
@@ -108,6 +108,8 @@ pub struct ExecResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
@@ -171,6 +173,15 @@ pub struct ShellDataEvent {
pub data: Vec<u8>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ShellClosedEvent {
pub connection_id: String,
pub shell_id: String,
pub reason: String,
pub idle_timeout: bool,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionClosedEvent {
+4 -2
View File
@@ -363,10 +363,12 @@ fn system_prompt(action: &str, capability: &str) -> String {
let action_rule = match action {
"explain" => "解释选中的终端输出,说明原因和下一步。",
"command" => "根据任务生成最少、可验证的 shell 命令。",
"execute" => "为受控执行工具准备一条命令。只返回一个 bash 代码块,代码块内只能有一行可直接执行的命令;不要包含提示符、说明文字或 kubectl exec 外壳。",
"execute" => "你是通过 ssh-exec 反复执行、观察并收敛的轻量 Agent。需要下一步时只返回一个 bash 代码块,块内只能有一行可直接执行的远程命令;已完成或无需命令时以 FINAL: 开头返回简短结论且不带代码块。不要包含提示符或 kubectl exec 外壳。",
_ => "基于终端现状给出简洁的排障判断和检查步骤。",
};
let plan_rule = if capability == "plan" {
let plan_rule = if action == "execute" {
"在内部规划并且每轮只选择一个最有信息量的动作,不要输出长篇计划。"
} else if capability == "plan" {
"先给出不超过 4 步的计划,再给命令。"
} else {
"避免冗长推理,直接给出结论。"
+120 -29
View File
@@ -12,7 +12,8 @@ use tauri::{AppHandle, Emitter};
use uuid::Uuid;
use crate::domain::ssh::{
ConnectionClosedEvent, ExecResponse, ForwardStatus, ShellDataEvent, SshConnectRequest,
ConnectionClosedEvent, ExecResponse, ForwardStatus, ShellClosedEvent, ShellDataEvent,
SshConnectRequest,
};
use super::tunnel::TunnelManager;
@@ -98,7 +99,11 @@ impl SshService {
Ok(())
}
pub fn exec(&self, connection_id: &str, command: &str) -> Result<String, String> {
pub fn exec_with_status(
&self,
connection_id: &str,
command: &str,
) -> Result<(String, i32), String> {
let connection = self.connection(connection_id)?;
let mut channel = connection
.session
@@ -114,10 +119,11 @@ impl SshService {
let mut stderr = String::new();
let _ = channel.stderr().read_to_string(&mut stderr);
let _ = channel.wait_close();
let exit_code = channel.exit_status().unwrap_or(-1);
if !stderr.is_empty() {
output.push_str(&stderr);
}
Ok(output)
Ok((output, exit_code))
}
pub fn keepalive(&self, connection_id: &str) -> Result<(), String> {
@@ -146,7 +152,7 @@ impl SshService {
}
pub fn create_shell(
&self,
self: &std::sync::Arc<Self>,
app: AppHandle,
connection_id: &str,
cols: u32,
@@ -178,7 +184,13 @@ impl SshService {
channel,
},
);
spawn_shell_reader(app, connection_id.to_string(), shell_id.clone(), reader);
spawn_shell_reader(
std::sync::Arc::clone(self),
app,
connection_id.to_string(),
shell_id.clone(),
reader,
);
Ok(shell_id)
}
@@ -299,6 +311,7 @@ pub(super) fn establish_session(config: &SshConnectRequest) -> Result<Session, S
}
fn spawn_shell_reader(
service: std::sync::Arc<SshService>,
app: AppHandle,
connection_id: String,
shell_id: String,
@@ -306,12 +319,14 @@ fn spawn_shell_reader(
) {
thread::spawn(move || {
let mut buffer = [0_u8; 8192];
let mut disconnect_reason = None;
let mut output_tail = Vec::with_capacity(SHELL_OUTPUT_TAIL_LIMIT);
let mut read_error = None;
loop {
match channel.read(&mut buffer) {
Ok(0) if channel.eof() => break,
Ok(0) => thread::sleep(Duration::from_millis(8)),
Ok(length) => {
append_output_tail(&mut output_tail, &buffer[..length]);
let _ = app.emit(
"ssh:shell-data",
ShellDataEvent {
@@ -325,25 +340,75 @@ fn spawn_shell_reader(
thread::sleep(Duration::from_millis(8));
}
Err(error) => {
disconnect_reason = Some(format!("远程 Shell 读取失败: {error}"));
read_error = Some(error.to_string());
break;
}
}
}
// A normal channel EOF (logout/Ctrl+D) only closes this shell. It must
// not tear down the pooled SSH connection or unrelated split panes.
if let Some(reason) = disconnect_reason {
let _ = app.emit(
"ssh:connection-closed",
ConnectionClosedEvent {
connection_id,
reason,
},
);
// disconnect() removes the shell before closing its channel. In that
// case the connection-closed event is authoritative and a second
// shell-closed event would briefly show a misleading recovery prompt.
let was_active = service
.shells
.lock()
.map(|mut shells| shells.remove(&shell_id).is_some())
.unwrap_or(false);
if !was_active {
return;
}
// Interactive shells use a dedicated SSH session. EOF, Ctrl+D, logout
// and server-side TMOUT close only this shell; the pooled SSH
// connection remains available to SFTP, tunnels and sibling panes.
let (reason, idle_timeout) = classify_shell_close(&output_tail, read_error.as_deref());
let _ = app.emit(
"ssh:shell-closed",
ShellClosedEvent {
connection_id,
shell_id,
reason,
idle_timeout,
},
);
});
}
const SHELL_OUTPUT_TAIL_LIMIT: usize = 4096;
fn append_output_tail(tail: &mut Vec<u8>, chunk: &[u8]) {
if chunk.len() >= SHELL_OUTPUT_TAIL_LIMIT {
tail.clear();
tail.extend_from_slice(&chunk[chunk.len() - SHELL_OUTPUT_TAIL_LIMIT..]);
return;
}
let overflow = tail
.len()
.saturating_add(chunk.len())
.saturating_sub(SHELL_OUTPUT_TAIL_LIMIT);
if overflow > 0 {
tail.drain(..overflow);
}
tail.extend_from_slice(chunk);
}
fn classify_shell_close(output_tail: &[u8], read_error: Option<&str>) -> (String, bool) {
let output = String::from_utf8_lossy(output_tail).to_ascii_lowercase();
let idle_timeout = output.contains("timed out waiting for input")
|| output.contains("auto-logout")
|| output.contains("auto logout");
if idle_timeout {
return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true);
}
if let Some(error) = read_error {
return (format!("远端 Shell 读取失败: {error}"), false);
}
(
"远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(),
false,
)
}
fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> {
let mut written = 0;
while written < data.len() {
@@ -441,11 +506,13 @@ fn _exec_response(result: Result<String, String>) -> ExecResponse {
Ok(output) => ExecResponse {
success: true,
result: Some(output),
exit_code: None,
error: None,
},
Err(error) => ExecResponse {
success: false,
result: None,
exit_code: None,
error: Some(error),
},
}
@@ -462,6 +529,32 @@ mod tests {
use super::sftp::{copy_with_progress, download_file, local_partial_path, upload_file};
use super::*;
#[test]
fn detects_remote_shell_idle_timeout_from_recent_output() {
let (reason, idle_timeout) =
classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None);
assert!(idle_timeout);
assert!(reason.contains("TMOUT"));
}
#[test]
fn shell_output_tail_is_bounded_and_keeps_latest_marker() {
let mut tail = vec![b'x'; SHELL_OUTPUT_TAIL_LIMIT];
append_output_tail(&mut tail, b"auto-logout");
assert_eq!(tail.len(), SHELL_OUTPUT_TAIL_LIMIT);
assert!(tail.ends_with(b"auto-logout"));
}
#[test]
fn reports_normal_shell_eof_without_marking_connection_dead() {
let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None);
assert!(!idle_timeout);
assert!(reason.contains("Shell 已退出"));
}
#[test]
fn resumes_copy_from_existing_offset() {
let mut source = io::Cursor::new(b"abcdef".to_vec());
@@ -503,12 +596,11 @@ mod tests {
keep_alive: Some(true),
})
.expect("SSH connect");
assert_eq!(
service
.exec(&connection_id, "printf OPSX_TAURI_OK")
.unwrap(),
"OPSX_TAURI_OK"
);
let (output, exit_code) = service
.exec_with_status(&connection_id, "printf OPSX_TAURI_OK")
.unwrap();
assert_eq!(output, "OPSX_TAURI_OK");
assert_eq!(exit_code, 0);
service
.keepalive(&connection_id)
.expect("SSH protocol keepalive");
@@ -661,11 +753,10 @@ mod tests {
keep_alive: Some(true),
})
.expect("SSH connect through jump host");
assert_eq!(
service
.exec(&connection_id, "printf OPSX_PROXY_JUMP_OK")
.unwrap(),
"OPSX_PROXY_JUMP_OK"
);
let (output, exit_code) = service
.exec_with_status(&connection_id, "printf OPSX_PROXY_JUMP_OK")
.unwrap();
assert_eq!(output, "OPSX_PROXY_JUMP_OK");
assert_eq!(exit_code, 0);
}
}