- 新增 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 条目
433 lines
15 KiB
Rust
433 lines
15 KiB
Rust
//! 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('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
}
|
||
|
||
#[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 & b");
|
||
assert_eq!(xml_escape("<tag>"), "<tag>");
|
||
}
|
||
}
|