feat: release OpsXShell 2.1.1
This commit is contained in:
1 parent
a3dba0c6ed
commit
494ef6b9d0
37 files changed
+2039
-370
No files matched your search
@@ -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()),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user