oudecheng 4ce89f8d82 feat(tools): 新增持久 PTY 会话工具替代管道式交互式 shell
- 新增 PtyTool:spawn/write/read/kill/list 五操作管理真实伪终端会话(portable-pty)

- PtySessionManager:活动续期 TTL 惰性回收、ANSI 剥离、增量读取游标

- kill 走完整 Child::kill() 语义(Unix SIGHUP→宽限→SIGKILL),spawn_blocking 执行

- 修复:write_input 不再持管理器锁跨阻塞 IO;kill 前 drain 管道尾部输出;watcher 改 try_lock

- 移除 shell_session.rs 管道式实现;前端 Tools/Subagents 页补充 PTY 条目
2026-08-21 08:30:44 +08:00

433 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Platform abstraction layer for cross-platform compatibility.
//!
//! This module provides unified interfaces for platform-specific operations,
//! making it easy to add support for new platforms by modifying only this file.
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Supported platform types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Windows,
Unix,
}
impl Platform {
/// Detect the current platform.
pub fn current() -> Self {
if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Unix
}
}
/// Check if running on Windows.
pub fn is_windows() -> bool {
cfg!(target_os = "windows")
}
}
/// Shell information for command execution.
#[derive(Debug, Clone)]
pub struct ShellInfo {
/// Tool name exposed to LLM.
pub name: &'static str,
/// Shell executable name.
pub executable: &'static str,
/// Arguments to pass before the command.
pub args: &'static [&'static str],
}
impl ShellInfo {
/// Get the default shell for the current platform.
pub fn default() -> Self {
Self::for_platform(Platform::current())
}
/// Get shell info for a specific platform.
pub fn for_platform(platform: Platform) -> Self {
match platform {
Platform::Windows => ShellInfo {
name: "shell",
executable: "powershell",
args: &["-Command"],
},
Platform::Unix => ShellInfo {
name: "bash",
executable: "bash",
args: &["-c"],
},
}
}
/// Alternative shells available on the platform.
pub fn available_shells(platform: Platform) -> Vec<ShellInfo> {
match platform {
Platform::Windows => vec![
ShellInfo {
name: "shell",
executable: "powershell",
args: &["-Command"],
},
ShellInfo {
name: "shell",
executable: "cmd",
args: &["/C"],
},
],
Platform::Unix => vec![
ShellInfo {
name: "bash",
executable: "bash",
args: &["-c"],
},
// Future: could add zsh, fish, sh
// ShellInfo { name: "zsh", executable: "zsh", args: &["-c"] },
],
}
}
}
/// Dangerous command patterns for safety guards.
///
/// Returns patterns filtered by the current platform. Platform-specific
/// rules (e.g. `format` on Windows, `rm` on Unix) are only injected on
/// their target platform to avoid false positives.
pub fn dangerous_command_patterns() -> Vec<String> {
dangerous_command_patterns_for_platform(Platform::current())
}
/// Platform-specific dangerous command patterns.
///
/// Exposed primarily for testing. Callers should prefer
/// [`dangerous_command_patterns`] which auto-detects the platform.
pub fn dangerous_command_patterns_for_platform(platform: Platform) -> Vec<String> {
let mut patterns: Vec<String> = Vec::new();
// Cross-platform: fork bomb
patterns.push(r":\(\)\s*\{.*\};\s*:".to_string());
match platform {
Platform::Unix => {
// Unix dangerous commands
patterns.push(r"\brm\s+-[rf]{1,2}\b".to_string());
patterns.push(r"\bchmod\s+-[Rr]".to_string());
patterns.push(r"\bchown\s+-[Rr]".to_string());
}
Platform::Windows => {
// Windows cmd dangerous commands.
// `format` requires a drive letter (`[a-z]:`) somewhere after it,
// so legitimate uses like `dart format lib/` or
// `pytest --format json` (no drive letter) are not matched.
patterns.push(r"\bformat\s+.*[a-z]:".to_string());
patterns.push(r"\bdel\s+/[fq]\b".to_string());
patterns.push(r"\brmdir\s+/s\b".to_string());
// PowerShell dangerous commands. Patterns are lowercase because
// `guard_command` lowercases the command string before matching.
patterns.push(r"\bremove-item\s+.*-recurse".to_string());
patterns.push(r"\bremove-item\s+.*-force".to_string());
}
}
patterns
}
/// Disable Windows console QuickEdit mode.
///
/// With QuickEdit enabled (the default), clicking the console window enters
/// mark/selection mode and silently blocks every console write from this
/// process until the user presses Enter — making the gateway look frozen.
/// Disabling it keeps keyboard input working while preventing mouse-click
/// freezes. No-op on non-Windows platforms or when no console is attached.
///
/// If stdin is redirected (so `GetStdHandle(STD_INPUT_HANDLE)` is not a
/// console handle), falls back to opening `CONIN$` to reach the attached
/// console directly. Note that a manual "Edit → Mark" from the title-bar
/// menu can still freeze console writes; non-blocking log writers in
/// `logging.rs` keep the runtime alive in that case.
pub fn disable_console_quick_edit() {
#[cfg(target_os = "windows")]
windows_disable_console_quick_edit();
}
#[cfg(target_os = "windows")]
fn windows_disable_console_quick_edit() {
const STD_INPUT_HANDLE: i32 = -10;
const ENABLE_QUICK_EDIT_MODE: u32 = 0x0040;
const ENABLE_EXTENDED_FLAGS: u32 = 0x0080;
const GENERIC_READ: u32 = 0x8000_0000;
const GENERIC_WRITE: u32 = 0x4000_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const OPEN_EXISTING: u32 = 3;
const INVALID_HANDLE_VALUE: isize = -1;
#[allow(non_snake_case)]
unsafe extern "system" {
fn GetStdHandle(n_std_handle: i32) -> isize;
fn GetConsoleMode(h_console_handle: isize, lp_mode: *mut u32) -> i32;
fn SetConsoleMode(h_console_handle: isize, dw_mode: u32) -> i32;
fn CreateFileW(
lp_file_name: *const u16,
dw_desired_access: u32,
dw_share_mode: u32,
lp_security_attributes: *const std::ffi::c_void,
dw_creation_disposition: u32,
dw_flags_and_attributes: u32,
h_template_file: isize,
) -> isize;
fn CloseHandle(h_object: isize) -> i32;
}
unsafe {
let mut mode: u32 = 0;
let mut opened_conin = false;
let stdin = GetStdHandle(STD_INPUT_HANDLE);
let console =
if stdin != 0 && stdin != INVALID_HANDLE_VALUE && GetConsoleMode(stdin, &mut mode) != 0
{
stdin
} else {
// stdin is redirected/closed but a console may still be attached
// (e.g. output shown in a window started via `start /B`). Open
// CONIN$ to reach the console input buffer directly.
let name: Vec<u16> = "CONIN$\0".encode_utf16().collect();
let conin = CreateFileW(
name.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
0,
0,
);
if conin == INVALID_HANDLE_VALUE || GetConsoleMode(conin, &mut mode) == 0 {
if conin != INVALID_HANDLE_VALUE {
let _ = CloseHandle(conin);
}
return;
}
opened_conin = true;
conin
};
if mode & ENABLE_QUICK_EDIT_MODE != 0 {
let new_mode = (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS;
let _ = SetConsoleMode(console, new_mode);
}
// The mode change is a property of the console itself, so the
// CONIN$ handle can be released immediately.
if opened_conin {
let _ = CloseHandle(console);
}
}
}
/// Get the user's home directory.
///
/// Supports environment variable overrides for testing:
/// - `HOME` (Unix-style, works on all platforms for testing)
/// - `USERPROFILE` (Windows-specific)
pub fn home_dir() -> Option<PathBuf> {
// Test scenario: support HOME variable override on all platforms
env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| {
// Windows: support USERPROFILE
env::var_os("USERPROFILE").map(PathBuf::from)
})
.or_else(dirs::home_dir)
}
/// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。
///
/// 包装 [`home_dir`] 并内置 fallback消除各模块重复的
/// `dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))` 模式。
pub fn picobot_home_dir() -> PathBuf {
home_dir().unwrap_or_else(|| PathBuf::from("."))
}
/// Atomically rename a file, handling platform differences.
///
/// On Windows, `fs::rename` fails if the destination exists, so we need to
/// remove it first. On Unix, rename is atomic and replaces the destination.
pub fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> {
if Platform::is_windows() && dst.exists() {
fs::remove_file(dst)?;
}
fs::rename(src, dst)
}
/// Convert a filesystem path to a file:// URI.
///
/// Handles platform-specific path formats:
/// - Unix: `/path/to/file` -> `file:///path/to/file`
/// - Windows: `C:\path\to\file` -> `file:///C:/path/to/file`
pub fn path_to_uri(path: &Path) -> String {
let path_str = path.display().to_string();
if Platform::is_windows() {
// Windows paths use backslashes which must be converted to forward slashes
let normalized = path_str.replace('\\', "/");
format!("file:///{}", normalized)
} else {
format!("file://{}", path_str)
}
}
/// XML escape utility.
pub fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_detect() {
let platform = Platform::current();
if cfg!(target_os = "windows") {
assert_eq!(platform, Platform::Windows);
} else {
assert_eq!(platform, Platform::Unix);
}
}
#[test]
fn test_shell_info_default() {
let shell = ShellInfo::default();
if cfg!(target_os = "windows") {
assert_eq!(shell.executable, "powershell");
assert_eq!(shell.args, &["-Command"]);
} else {
assert_eq!(shell.executable, "bash");
assert_eq!(shell.args, &["-c"]);
}
}
#[test]
fn test_shell_info_for_platform() {
let win_shell = ShellInfo::for_platform(Platform::Windows);
assert_eq!(win_shell.executable, "powershell");
let unix_shell = ShellInfo::for_platform(Platform::Unix);
assert_eq!(unix_shell.executable, "bash");
}
#[test]
fn test_path_to_uri() {
let temp_dir = tempfile::tempdir().unwrap();
let test_path = temp_dir.path().join("test.txt");
let uri = path_to_uri(&test_path);
assert!(uri.starts_with("file://"));
assert!(uri.contains("test.txt"));
assert!(!uri.contains('\\')); // No backslashes
}
#[test]
fn test_path_to_uri_windows_format() {
if cfg!(target_os = "windows") {
let win_path = PathBuf::from("C:\\Users\\test\\file.txt");
let uri = path_to_uri(&win_path);
assert!(uri.starts_with("file:///C:/"));
assert_eq!(uri, "file:///C:/Users/test/file.txt");
}
}
#[test]
fn test_atomic_rename() {
let temp_dir = tempfile::tempdir().unwrap();
let src = temp_dir.path().join("source.txt");
let dst = temp_dir.path().join("dest.txt");
fs::write(&src, "content").unwrap();
fs::write(&dst, "old content").unwrap();
atomic_rename(&src, &dst).unwrap();
assert!(!src.exists());
assert!(dst.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "content");
}
#[test]
fn test_dangerous_patterns() {
let patterns = dangerous_command_patterns();
assert!(!patterns.is_empty());
// Cross-platform fork bomb rule is always present
assert!(patterns.iter().any(|p| p.contains(r":\(\)")));
}
#[test]
fn test_dangerous_patterns_unix() {
let patterns = dangerous_command_patterns_for_platform(Platform::Unix);
// Unix-specific rules
assert!(patterns.iter().any(|p| p.contains("rm")));
assert!(patterns.iter().any(|p| p.contains("chmod")));
assert!(patterns.iter().any(|p| p.contains("chown")));
// Windows-specific rules must NOT be present on Unix
assert!(!patterns.iter().any(|p| p.contains("format")));
assert!(!patterns.iter().any(|p| p.contains("del")));
assert!(!patterns.iter().any(|p| p.contains("remove-item")));
}
#[test]
fn test_dangerous_patterns_windows() {
let patterns = dangerous_command_patterns_for_platform(Platform::Windows);
// Windows-specific rules
assert!(patterns.iter().any(|p| p.contains("del")));
assert!(patterns.iter().any(|p| p.contains("format")));
assert!(patterns.iter().any(|p| p.contains("remove-item")));
// Unix-specific rules must NOT be present on Windows.
// Use r"\brm\s" to match rm-as-command without matching rmdir.
assert!(!patterns.iter().any(|p| p.contains(r"\brm\s")));
assert!(!patterns.iter().any(|p| p.contains("chmod")));
assert!(!patterns.iter().any(|p| p.contains("chown")));
}
#[test]
fn test_format_pattern_precision() {
let patterns = dangerous_command_patterns_for_platform(Platform::Windows);
let format_pat = patterns
.iter()
.find(|p| p.contains("format"))
.expect("format pattern should exist on Windows");
let re = regex::Regex::new(format_pat).unwrap();
// Helper: guard_command lowercases before matching, so tests must too.
let m = |cmd: &str| re.is_match(&cmd.to_lowercase());
// Truly dangerous commands — should match
assert!(m("format c:"));
assert!(m("format /q c:"));
assert!(m("format d: /fs:ntfs"));
assert!(m("echo ok | format c:"));
assert!(m("echo ok; format c:"));
// Sub-shell invocation must also be caught
assert!(m(r#"cmd /c "format C:""#));
// Legitimate commands containing literal "format " — should NOT match
assert!(!m("dart format lib/"));
assert!(!m("buf format -w"));
assert!(!m("pytest --format json"));
assert!(!m(r#"echo "please format the disk""#));
assert!(!m(r#"git log --pretty=format:"%h""#));
}
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("a & b"), "a &amp; b");
assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
}
}