diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1f9b3..d2dd269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,18 +32,22 @@ Every release entry is provided in English and Simplified Chinese. ### SSH -- **SSH inside the configured default shell** — saved hosts now open a normal Nebula pane rooted in the selected default shell and run SSH inside its PTY, eliminating the external `ssh.exe` console window. - **中文:** 保存的 SSH host 现在先创建用户设置的默认 Shell pane,再在其 PTY 内运行 SSH,不再弹出外部 `ssh.exe` 黑窗口。 +- **Native Rust SSH transport** — saved hosts now connect directly to a remote PTY channel without a wrapper shell, injected command, or external `ssh.exe` console window. + **中文:** 保存的 SSH host 现在通过 Rust SSH 传输直接连接远端 PTY,不再依赖包装 Shell、命令注入或外部 `ssh.exe` 黑窗口。 +- **Complete authentication chain** — resolves aliases, users, ports and identity files from `~/.ssh/config`, then supports private keys, OpenSSH certificates, encrypted-key passphrases, Windows OpenSSH Agent, Pageant, saved or prompted passwords, and keyboard-interactive/MFA. + **中文:** 从 `~/.ssh/config` 解析别名、用户、端口和 IdentityFile,并支持私钥、OpenSSH 证书、加密密钥口令、Windows OpenSSH Agent、Pageant、已保存/现场输入密码以及 keyboard-interactive/MFA。 +- **Connection reuse** — authenticated sessions are pooled by `user@host:port`, so additional SSH tabs open a new shell channel without repeating transport setup and authentication. + **中文:** 已认证连接按 `user@host:port` 复用;后续 SSH 标签页直接创建新 Shell channel,无需重复传输握手和认证。 +- **Standard host-key verification** — verifies and learns host keys through the standard `known_hosts` store, prompts on first connection, and rejects changed keys with a security warning. + **中文:** 使用标准 `known_hosts` 校验和保存主机密钥;首次连接会确认,密钥变化时会拒绝连接并显示安全警告。 +- **Authenticated remote Hook bridge** — remote AI lifecycle envelopes can travel through a private OSC protected by a random per-channel token; pane identity is always assigned locally before notifications are dispatched. + **中文:** 远端 AI 生命周期信封可通过每通道随机令牌保护的私有 OSC 返回;通知分发前始终由本地分配 Pane 身份。 - **Built-in host editor** — the `SSH HOSTS` header has an add button and an internal form for `user@host`, optional non-default ports, and passwords. **中文:** `SSH HOSTS` 标题新增添加按钮和内部编辑面板,可输入 `user@host`、非默认端口和密码。 - **Secure credential persistence** — passwords are saved only with explicit consent and are stored in Windows Credential Manager, never in Nebula settings, command arguments, shell history, or logs. **中文:** 密码仅在用户明确选择保存时写入 Windows Credential Manager,绝不会进入 Nebula 设置、命令参数、Shell 历史或日志。 -- **Host deletion** — SSH host rows expose a tab-style close button; removing a saved record also clears its pin and stored Windows credential. - **中文:** SSH host 行新增与标签页一致的关闭按钮;删除保存记录时同步清理置顶状态和 Windows 凭据。 -- **OpenSSH AskPass integration** — supports automatic login with stored passwords, invalid-password recovery, host-key confirmation, and one-time authentication-state cleanup. - **中文:** OpenSSH AskPass 支持已保存密码自动登录、密码失效后重新询问、host key 确认和一次性认证状态清理。 -- **Shell-specific command injection** — PowerShell, CMD, Git Bash, Nushell, and WSL use dedicated quoting and environment propagation. - **中文:** PowerShell、CMD、Git Bash、Nushell 和 WSL 均使用各自的安全转义与环境传递方式注入 SSH 命令。 +- **Host deletion and cleaner right-click behavior** — SSH rows keep their tab-style delete button and credential cleanup, while right-click no longer silently pins or reorders a host. + **中文:** SSH host 行保留标签页式删除按钮和凭据清理;右键不再静默置顶或改变主机顺序。 ### Session And Rendering / 会话与渲染 diff --git a/Cargo.lock b/Cargo.lock index f86e442..6672772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1786,6 +1786,7 @@ dependencies = [ "crossfont", "dirs", "embed-resource", + "getrandom 0.3.3", "gl_generator", "global-hotkey", "glutin", diff --git a/README.md b/README.md index dd9b844..b1c7c47 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,24 @@ experience that works without extra setup. notifier keeps firing. - **Fallback signals** — OSC 133 command tracking + BEL still cover every other CLI: long builds toast on completion, with their duration. -- **AI-aware over SSH** — `nebula ssh user@host` bootstraps Nebula's shell - integration on the *remote* host, so a `claude` / `vim` / `cargo` running on - the server shows the same sidebar icon, spinner and cwd as if it were local. - SSH is a transparent pipe: the remote shell's OSC escapes travel back and are - parsed exactly like local ones. Forwarding and query forms - (`ssh -N -L …`, `-W`, `-G`, an explicit remote command) pass straight through - untouched — including settings resolved from `~/.ssh/config`. v1 targets Linux - remotes with bash/zsh; anything else falls back to a plain login shell (the - connection is never lost to a bootstrap problem). To make plain `ssh` - transparent, alias it in your Nebula shell: `alias ssh='nebula ssh'`. +- **Native SSH sessions** — saved hosts open directly through Nebula's Rust SSH + transport: no wrapper shell and no external console window. Host aliases, + usernames, ports and identity files are resolved from `~/.ssh/config`; + authentication supports standard private keys and certificates, encrypted-key + passphrases, Windows OpenSSH Agent, Pageant, Windows Credential Manager + passwords, and keyboard-interactive/MFA. Accepted host keys use the standard + `known_hosts` store, and additional tabs reuse an already authenticated + connection to the same `user@host:port` for a faster second shell. +- **AI-aware over SSH** — remote Hook envelopes can return through a + per-channel, randomly authenticated private OSC bridge. Nebula validates the + channel token, replaces any remote pane identifier with the local pane, and + routes the event through the existing sidebar and Windows notification path. + The `nebula ssh` compatibility command remains available for forwarding, + query and explicit-command forms that need the system SSH client. + +

+ Nebula native SSH session +

**Performance & correctness** @@ -231,14 +239,20 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU `--chain` 包装而非顶掉:原有通知程序照常触发。 - **兜底信号** — OSC 133 命令跟踪 + BEL 覆盖其余所有 CLI:长构建完成也 弹通知,并带耗时。 -- **SSH 里也 AI 感知** — `nebula ssh user@host` 会把 Nebula 的 shell 集成 - 引导到*远程*主机,让服务器上跑的 `claude` / `vim` / `cargo` 像在本地一样 - 显示侧栏图标、转圈和 cwd。原理:SSH 是透明管道,远程 shell 发的 OSC 转义 - 原样穿回本地,被同一套解析器处理。转发与查询形态(`ssh -N -L …`、`-W`、 - `-G`、显式远程命令)原样透传不注入 —— 包括从 `~/.ssh/config` 解析出的 - 设置。v1 面向 Linux + bash/zsh 远端;其余降级为普通登录 shell(连接绝不 - 因引导失败而丢)。想让直接打的 `ssh` 也透明,在 Nebula 的 shell 里加个 - 别名:`alias ssh='nebula ssh'`。 +- **原生 SSH 会话** — 保存的主机现在直接通过 Nebula 的 Rust SSH 传输连接, + 不再经过包装 Shell,也不会弹出外部控制台窗口。主机别名、用户名、端口和 + IdentityFile 会从 `~/.ssh/config` 解析;认证覆盖标准私钥与证书、加密密钥 + 口令、Windows OpenSSH Agent、Pageant、Windows 凭据管理器密码以及 + keyboard-interactive/MFA。主机密钥写入标准 `known_hosts`;同一 + `user@host:port` 再开标签页会复用已认证连接,让第二个 Shell 更快出现。 +- **SSH 里也 AI 感知** — 远端 Hook 信封可通过每通道随机认证的私有 OSC 桥 + 返回。Nebula 会校验通道令牌,用本地 Pane 身份覆盖远端字段,再复用现有侧栏 + 状态和 Windows 通知链路。需要端口转发、配置查询或显式远程命令时,仍可使用 + `nebula ssh` 兼容命令交给系统 SSH 客户端处理。 + +

+ Nebula 原生 SSH 会话 +

**性能与正确性** diff --git a/docs/screenshots/ssh.gif b/docs/screenshots/ssh.gif new file mode 100644 index 0000000..ee14487 Binary files /dev/null and b/docs/screenshots/ssh.gif differ diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index f692679..f85c3bd 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -60,6 +60,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } unicode-width = "0.2.0" winit = { version = "0.30.9", default-features = false, features = ["rwh_06", "serde"] } global-hotkey = "0.8.0" +getrandom = "0.3.3" [build-dependencies] gl_generator = "0.14.0" diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 788013a..710bdef 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -162,6 +162,28 @@ fn parse_envelope(bytes: &[u8]) -> Option { Some(AiHookEvent { pane, source, kind, message }) } +/// 远端会话只能提交事件语义,Pane 身份始终由本地 SSH 通道覆盖, +/// 防止远端载荷把通知路由到同一窗口中的其他标签页。 +pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option) -> Option { + let mut event = parse_envelope(bytes)?; + event.pane = pane; + Some(event) +} + +#[cfg(test)] +mod remote_tests { + use super::{AiHookKind, parse_remote_envelope}; + + #[test] + fn remote_envelope_uses_local_pane_identity() { + let raw = b"nebula-hook/1 source=codex pane=999\n{\"type\":\"agent-turn-complete\",\"last-assistant-message\":\"done\"}"; + let event = parse_remote_envelope(raw, Some(7)).unwrap(); + assert_eq!(event.pane, Some(7)); + assert_eq!(event.kind, AiHookKind::TurnDone); + assert_eq!(event.message.as_deref(), Some("done")); + } +} + /// Char-boundary-safe cut with an ellipsis (toast bodies are small). fn truncate(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 760ff1d..fc6d761 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -2617,6 +2617,7 @@ impl input::Processor> { ); } }, + TerminalEvent::AiHookEnvelope(_) => (), TerminalEvent::Bell => { // Claude Code / Codex ring BEL when a turn finishes, so // an unfocused bell is the primary "AI task done" @@ -2933,6 +2934,12 @@ impl EventProxy { impl EventListener for EventProxy { fn send_event(&self, event: TerminalEvent) { + if let TerminalEvent::AiHookEnvelope(envelope) = event { + if let Some(hook) = crate::ai_hook::parse_remote_envelope(&envelope, self.tab_id) { + self.send_event(EventType::AiHook(hook)); + } + return; + } let _ = self.proxy.send_event(Event { window_id: Some(self.target()), tab_id: self.tab_id, diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index e929f3b..21f77b8 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -1215,8 +1215,9 @@ impl> Processor { // Right-clicking the sidebar "+" opens the quick-launch profile menu // (Windows Terminal's profile dropdown); left-click keeps opening the - // default shell. Right-clicking a tab or SSH host row pins it to the - // top of its section (a second right-click on a host unpins). + // default shell. Tab and SSH context menus will replace the old + // reorder/pin shortcuts; until that menu lands, SSH right-click is + // consumed without changing the saved-host order. if button == MouseButton::Right { let x = self.ctx.mouse().x as f32; let y = self.ctx.mouse().y as f32; @@ -1237,9 +1238,7 @@ impl> Processor { self.ctx.mark_dirty(); return; }, - crate::display::ChromeHit::Host(index) => { - self.ctx.display().pin_host(index); - self.ctx.mark_dirty(); + crate::display::ChromeHit::Host(_) => { return; }, _ => {}, diff --git a/nebula_app/src/ssh_credentials.rs b/nebula_app/src/ssh_credentials.rs index 50a8f5b..49a9b5f 100644 --- a/nebula_app/src/ssh_credentials.rs +++ b/nebula_app/src/ssh_credentials.rs @@ -309,6 +309,15 @@ pub fn load_stored_password(destination: &str) -> std::io::Result windows_store::load_password(destination) } +#[cfg(windows)] +pub fn prompt_password( + destination: &str, + initial: Option<&[u8]>, + allow_save: bool, +) -> std::io::Result, bool)>> { + windows_store::prompt_password(destination, initial, allow_save) +} + #[cfg(not(windows))] pub fn load_stored_password(_destination: &str) -> std::io::Result>> { Ok(None) diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 55415e6..4ccdb2c 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -3,28 +3,41 @@ //! 远端 Pane 不创建本地伪终端,但继续使用统一的输入、缩放和关闭消息协议, //! 从而让渲染与键盘处理保持传输层无关。 +use std::collections::HashMap; use std::io; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::mpsc::Receiver; +use std::sync::{Arc, OnceLock}; use std::time::Duration; -use log::{error, warn}; +use log::{error, info, warn}; use nebula_terminal::event::{Event as TerminalEvent, WindowSize}; use nebula_terminal::event_loop::{EventLoopSender, Msg, StreamProcessor}; use nebula_terminal::sync::FairMutex; use nebula_terminal::term::Term; -use russh::client; +use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse}; +use russh::keys::agent::AgentIdentity; +use russh::keys::agent::client::AgentClient; use russh::keys::ssh_key; -use russh::{ChannelMsg, Disconnect}; +use russh::keys::{HashAlg, PrivateKeyWithHashAlg}; +use russh::ChannelMsg; +use tokio::io::{AsyncRead, AsyncWrite}; use crate::event::EventProxy; +type SessionError = Box; +type ClientSession = client::Handle; +type SharedSession = Arc>; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SshDestination { pub original: String, pub user: String, pub host: String, pub port: u16, + identity_files: Vec, + proxy_jump: Option, } impl SshDestination { @@ -32,44 +45,154 @@ impl SshDestination { let original = value.trim().to_owned(); let address = original.strip_prefix("ssh://").unwrap_or(&original).to_owned(); let (user, host_port) = address.rsplit_once('@').ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "SSH destination requires user@host") + io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址需要包含 user@host") })?; if user.is_empty() || host_port.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "SSH destination is incomplete", - )); + return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址不完整")); } - let (host, port) = if let Some(rest) = host_port.strip_prefix('[') { - let (host, suffix) = rest.split_once(']').ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "invalid bracketed SSH address") - })?; - let port = suffix - .strip_prefix(':') - .map(str::parse) - .transpose() - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SSH port"))? - .unwrap_or(22); - (host.to_owned(), port) - } else if let Some((host, port)) = host_port.rsplit_once(':') { - if host.contains(':') { - (host_port.to_owned(), 22) - } else { - let port = port - .parse() - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SSH port"))?; - (host.to_owned(), port) - } - } else { - (host_port.to_owned(), 22) - }; - if host.is_empty() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH host is empty")); - } - - Ok(Self { original, user: user.to_owned(), host, port }) + let (host, port) = parse_host_port(host_port)?; + Ok(Self { + original, + user: user.to_owned(), + host, + port, + identity_files: Vec::new(), + proxy_jump: None, + }) } + + /// 使用系统 SSH 的离线配置展开能力解析别名、用户名、端口和 IdentityFile。 + /// 这能保持用户现有 `~/.ssh/config` 行为,同时网络连接仍完全由 Rust 传输层承担。 + fn resolve(value: &str) -> io::Result { + let original = value.trim().to_owned(); + if original.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址为空")); + } + + let output = Command::new(find_ssh()).arg("-G").arg("--").arg(&original).output(); + if let Ok(output) = output { + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + if let Some(destination) = parse_resolved_config(&original, &text) { + return Ok(destination); + } + } + } + + if let Ok(destination) = Self::parse(&original) { + return Ok(destination); + } + + let address = original.strip_prefix("ssh://").unwrap_or(&original); + let (host, port) = parse_host_port(address)?; + let user = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无法确定 SSH 用户名"))?; + Ok(Self { + original, + user, + host, + port, + identity_files: default_identity_files(), + proxy_jump: None, + }) + } + + fn pool_key(&self) -> String { + format!("{}@{}:{}", self.user, self.host.to_ascii_lowercase(), self.port) + } +} + +fn parse_host_port(host_port: &str) -> io::Result<(String, u16)> { + let (host, port) = if let Some(rest) = host_port.strip_prefix('[') { + let (host, suffix) = rest.split_once(']').ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "无效的 IPv6 SSH 地址") + })?; + let port = suffix + .strip_prefix(':') + .map(str::parse) + .transpose() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无效的 SSH 端口"))? + .unwrap_or(22); + (host.to_owned(), port) + } else if let Some((host, port)) = host_port.rsplit_once(':') { + if host.contains(':') { + (host_port.to_owned(), 22) + } else { + let port = port + .parse() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无效的 SSH 端口"))?; + (host.to_owned(), port) + } + } else { + (host_port.to_owned(), 22) + }; + if host.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 主机为空")); + } + Ok((host, port)) +} + +fn parse_resolved_config(original: &str, text: &str) -> Option { + let mut user = None; + let mut host = None; + let mut port = None; + let mut identity_files = Vec::new(); + let mut proxy_jump = None; + for line in text.lines() { + let (key, value) = line.split_once(char::is_whitespace)?; + let value = value.trim(); + match key.to_ascii_lowercase().as_str() { + "user" if user.is_none() => user = Some(value.to_owned()), + "hostname" if host.is_none() => host = Some(value.to_owned()), + "port" if port.is_none() => port = value.parse().ok(), + "identityfile" => identity_files.push(expand_home(value)), + "proxyjump" if !value.eq_ignore_ascii_case("none") => { + proxy_jump = Some(value.to_owned()); + }, + _ => {}, + } + } + Some(SshDestination { + original: original.to_owned(), + user: user?, + host: host?, + port: port.unwrap_or(22), + identity_files, + proxy_jump, + }) +} + +fn find_ssh() -> PathBuf { + if let Some(root) = std::env::var_os("SystemRoot") { + let path = PathBuf::from(root).join("System32").join("OpenSSH").join("ssh.exe"); + if path.is_file() { + return path; + } + } + PathBuf::from("ssh") +} + +fn expand_home(value: &str) -> PathBuf { + let value = value.trim_matches('"'); + if let Some(rest) = value.strip_prefix("~/").or_else(|| value.strip_prefix("~\\")) { + if let Some(home) = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(value) +} + +fn default_identity_files() -> Vec { + ["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .filter_map(|name| { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(|home| PathBuf::from(home).join(".ssh").join(name)) + }) + .collect() } struct ClientHandler { @@ -94,7 +217,7 @@ impl client::Handler for ClientHandler { self.port, server_public_key, ) { - warn!("Failed to store SSH host key: {err}"); + warn!("保存 SSH 主机密钥失败: {err}"); } Ok(true) } else { @@ -102,7 +225,7 @@ impl client::Handler for ClientHandler { } }, Err(err) => { - warn!("SSH host key verification failed: {err}"); + warn!("SSH 主机密钥验证失败: {err}"); show_host_key_changed(&self.host, self.port, &err.to_string()); Ok(false) }, @@ -110,90 +233,73 @@ impl client::Handler for ClientHandler { } } -/// 启动密码认证的 SSH Pane。返回 `Unsupported` 时保留兼容路径, -/// 避免尚未覆盖的认证方式在迁移期间失效。 -pub fn spawn_password_session( - destination: SshDestination, +fn runtime() -> io::Result<&'static tokio::runtime::Runtime> { + static RUNTIME: OnceLock> = OnceLock::new(); + match RUNTIME.get_or_init(|| { + let workers = std::thread::available_parallelism() + .map(|count| count.get().clamp(2, 4)) + .unwrap_or(2); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(workers) + .thread_name("nebula-ssh") + .build() + .map_err(|err| err.to_string()) + }) { + Ok(runtime) => Ok(runtime), + Err(err) => Err(io::Error::other(format!("SSH Runtime 初始化失败: {err}"))), + } +} + +fn connection_pool() -> &'static tokio::sync::Mutex> { + static POOL: OnceLock>> = OnceLock::new(); + POOL.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) +} + +/// 启动 SSH Pane。地址解析和认证在共享 Runtime 中执行,避免阻塞窗口线程。 +pub fn spawn_session( + destination: String, initial_size: WindowSize, terminal: Arc>>, event_proxy: EventProxy, ) -> io::Result { - let Some(password) = crate::ssh_credentials::load_stored_password(&destination.original)? - else { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "no stored password for direct SSH authentication", - )); - }; let (sender, receiver) = EventLoopSender::standalone()?; - std::thread::Builder::new().name(format!("SSH {}", destination.host)).spawn(move || { - run_session(destination, password, initial_size, terminal, event_proxy, receiver) - })?; - Ok(sender) -} - -fn run_session( - destination: SshDestination, - mut password: Vec, - initial_size: WindowSize, - terminal: Arc>>, - event_proxy: EventProxy, - receiver: Receiver, -) { - let runtime = - match tokio::runtime::Builder::new_multi_thread().enable_all().worker_threads(2).build() { - Ok(runtime) => runtime, - Err(err) => { - render_error(&terminal, &event_proxy, &format!("SSH runtime failed: {err}")); - return; + runtime()?.spawn(async move { + let raw = destination.clone(); + let resolved = tokio::task::spawn_blocking(move || SshDestination::resolve(&raw)).await; + let result = match resolved { + Ok(Ok(destination)) => { + run_session_async(destination, initial_size, terminal.clone(), event_proxy.clone(), receiver).await }, + Ok(Err(err)) => Err(err.into()), + Err(err) => Err(format!("SSH 地址解析任务失败: {err}").into()), }; - runtime.block_on(async move { - if let Err(err) = run_session_async( - &destination, - &password, - initial_size, - terminal.clone(), - event_proxy.clone(), - receiver, - ) - .await - { - error!("Direct SSH session failed for {}: {err}", destination.original); - render_error(&terminal, &event_proxy, &format!("SSH connection failed: {err}")); + if let Err(err) = result { + error!("直连 SSH 会话失败 {destination}: {err}"); + render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}")); } - password.fill(0); terminal.lock().exit(); event_proxy.send_event(TerminalEvent::Wakeup.into()); }); + Ok(sender) } async fn run_session_async( - destination: &SshDestination, - password: &[u8], + destination: SshDestination, initial_size: WindowSize, terminal: Arc>>, event_proxy: EventProxy, receiver: Receiver, -) -> Result<(), Box> { - let config = Arc::new(client::Config { - inactivity_timeout: Some(Duration::from_secs(30)), - keepalive_interval: Some(Duration::from_secs(5)), - keepalive_max: 6, - ..Default::default() - }); - let handler = ClientHandler { host: destination.host.clone(), port: destination.port }; - let mut session = - client::connect(config, (destination.host.as_str(), destination.port), handler).await?; - - let password = String::from_utf8(password.to_vec())?; - let auth = session.authenticate_password(destination.user.clone(), password).await?; - if !auth.success() { - let _ = crate::ssh_credentials::forget_password(&destination.original); - return Err("password authentication was rejected".into()); +) -> Result<(), SessionError> { + if let Some(proxy_jump) = destination.proxy_jump.as_deref() { + return Err(format!("当前直连模式尚未接入跳板机 {proxy_jump}").into()); } - let mut channel = session.channel_open_session().await?; + let session = authenticated_session(&destination).await?; + let mut channel = { + let session = session.lock().await; + session.channel_open_session().await? + }; channel .request_pty( true, @@ -205,6 +311,10 @@ async fn run_session_async( &[], ) .await?; + let hook_token = remote_hook_token()?; + channel + .set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone()) + .await?; channel.request_shell(true).await?; let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -218,7 +328,9 @@ async fn run_session_async( let mut stream = StreamProcessor::default(); stream.resize(initial_size); + stream.set_remote_hook_token(hook_token); loop { + let sync_deadline = stream.next_sync_timeout(); tokio::select! { message = input_rx.recv() => match message { Some(Msg::Input(bytes)) => channel.data(bytes.as_ref()).await?, @@ -241,15 +353,293 @@ async fn run_session_async( stream.feed(&mut *terminal.lock(), &event_proxy, data.as_ref()); event_proxy.send_event(TerminalEvent::Wakeup.into()); }, - Some(ChannelMsg::ExitStatus { .. }) | None => break, + Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::Eof) | None => break, _ => {}, }, + _ = wait_for_sync(sync_deadline), if sync_deadline.is_some() => { + stream.stop_sync(&mut *terminal.lock()); + event_proxy.send_event(TerminalEvent::Wakeup.into()); + }, } } - let _ = session.disconnect(Disconnect::ByApplication, "", "English").await; Ok(()) } +async fn wait_for_sync(deadline: Option) { + if let Some(deadline) = deadline { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + } +} + +async fn authenticated_session(destination: &SshDestination) -> Result { + let key = destination.pool_key(); + if let Some(existing) = connection_pool().lock().await.get(&key).cloned() { + if !existing.lock().await.is_closed() { + info!("复用已认证 SSH 连接: {key}"); + return Ok(existing); + } + connection_pool().lock().await.remove(&key); + } + + let config = Arc::new(client::Config { + inactivity_timeout: None, + keepalive_interval: Some(Duration::from_secs(15)), + keepalive_max: 3, + ..Default::default() + }); + let handler = ClientHandler { host: destination.host.clone(), port: destination.port }; + let mut session = client::connect( + config, + (destination.host.as_str(), destination.port), + handler, + ) + .await?; + authenticate(&mut session, destination).await?; + + let session = Arc::new(tokio::sync::Mutex::new(session)); + let mut pool = connection_pool().lock().await; + if let Some(existing) = pool.get(&key).cloned() { + if !existing.lock().await.is_closed() { + return Ok(existing); + } + } + pool.insert(key, session.clone()); + Ok(session) +} + +async fn authenticate( + session: &mut ClientSession, + destination: &SshDestination, +) -> Result<(), SessionError> { + if session.authenticate_none(&destination.user).await?.success() { + return Ok(()); + } + + for path in &destination.identity_files { + if try_private_key(session, destination, path).await? { + return Ok(()); + } + } + + if try_windows_agents(session, &destination.user).await { + return Ok(()); + } + + let mut reusable_password = crate::ssh_credentials::load_stored_password(&destination.original)?; + if let Some(password) = reusable_password.as_ref() { + if authenticate_password(session, &destination.user, password).await? { + clear_secret(&mut reusable_password); + return Ok(()); + } + crate::ssh_credentials::forget_password(&destination.original)?; + clear_secret(&mut reusable_password); + } + + if let Some((mut password, save)) = prompt_secret(destination.original.clone(), None, true).await? + { + let accepted = authenticate_password(session, &destination.user, &password).await?; + if accepted { + if save { + crate::ssh_credentials::store_password(&destination.original, &password)?; + } + password.fill(0); + return Ok(()); + } + reusable_password = Some(password); + } + + if try_keyboard_interactive(session, destination, reusable_password.as_deref()).await? { + clear_secret(&mut reusable_password); + return Ok(()); + } + clear_secret(&mut reusable_password); + Err("服务器拒绝了所有可用的 SSH 认证方式".into()) +} + +async fn try_private_key( + session: &mut ClientSession, + destination: &SshDestination, + path: &Path, +) -> Result { + if !path.is_file() { + return Ok(false); + } + let target = format!("密钥口令: {}", path.display()); + let mut stored = crate::ssh_credentials::load_stored_password(&target)?; + let mut key = stored + .as_deref() + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .and_then(|passphrase| russh::keys::load_secret_key(path, Some(passphrase)).ok()) + .or_else(|| russh::keys::load_secret_key(path, None).ok()); + + if key.is_none() { + clear_secret(&mut stored); + crate::ssh_credentials::forget_password(&target)?; + if let Some((mut passphrase, save)) = prompt_secret(target.clone(), None, true).await? { + let text = String::from_utf8_lossy(&passphrase).into_owned(); + key = russh::keys::load_secret_key(path, Some(&text)).ok(); + if key.is_some() && save { + crate::ssh_credentials::store_password(&target, &passphrase)?; + } + passphrase.fill(0); + } + } + clear_secret(&mut stored); + let Some(key) = key else { return Ok(false) }; + + let key = Arc::new(key); + let cert_path = PathBuf::from(format!("{}-cert.pub", path.display())); + if cert_path.is_file() { + if let Ok(certificate) = russh::keys::load_openssh_certificate(&cert_path) { + if session + .authenticate_openssh_cert(&destination.user, key.clone(), certificate) + .await? + .success() + { + return Ok(true); + } + } + } + + let hash = rsa_hash_for(session, key.algorithm().is_rsa()).await; + let key = PrivateKeyWithHashAlg::new(key, hash); + Ok(session.authenticate_publickey(&destination.user, key).await?.success()) +} + +async fn try_windows_agents(session: &mut ClientSession, user: &str) -> bool { + const OPENSSH_AGENT: &str = r"\\.\pipe\openssh-ssh-agent"; + if let Ok(mut agent) = AgentClient::connect_named_pipe(OPENSSH_AGENT).await { + if authenticate_agent(session, user, &mut agent).await.unwrap_or(false) { + return true; + } + } + if let Ok(mut agent) = AgentClient::connect_pageant().await { + if authenticate_agent(session, user, &mut agent).await.unwrap_or(false) { + return true; + } + } + false +} + +async fn authenticate_agent( + session: &mut ClientSession, + user: &str, + agent: &mut AgentClient, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let identities = agent.request_identities().await?; + for identity in identities.into_iter().take(5) { + let is_rsa = identity.public_key().algorithm().is_rsa(); + let hash = rsa_hash_for(session, is_rsa).await; + let result: AuthResult = match identity { + AgentIdentity::PublicKey { key, .. } => { + session.authenticate_publickey_with(user, key, hash, agent).await? + }, + AgentIdentity::Certificate { certificate, .. } => { + session.authenticate_certificate_with(user, certificate, hash, agent).await? + }, + }; + if result.success() { + return Ok(true); + } + } + Ok(false) +} + +async fn rsa_hash_for(session: &ClientSession, rsa: bool) -> Option { + if !rsa { + return None; + } + match session.best_supported_rsa_hash().await { + Ok(Some(hash)) => hash, + _ => Some(HashAlg::Sha512), + } +} + +async fn authenticate_password( + session: &mut ClientSession, + user: &str, + password: &[u8], +) -> Result { + let password = String::from_utf8(password.to_vec())?; + Ok(session.authenticate_password(user, password).await?.success()) +} + +async fn try_keyboard_interactive( + session: &mut ClientSession, + destination: &SshDestination, + password: Option<&[u8]>, +) -> Result { + let mut state = session + .authenticate_keyboard_interactive_start(&destination.user, None::) + .await?; + for _ in 0..8 { + match state { + KeyboardInteractiveAuthResponse::Success => return Ok(true), + KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), + KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => { + let mut responses = Vec::with_capacity(prompts.len()); + for prompt in prompts { + if !prompt.echo + && prompt.prompt.to_ascii_lowercase().contains("password") + && password.is_some() + { + responses.push(String::from_utf8_lossy(password.unwrap()).into_owned()); + continue; + } + let label = format!( + "{} - {} {} {}", + destination.original, + name, + instructions, + prompt.prompt + ); + let Some((mut response, _)) = prompt_secret(label, None, false).await? else { + return Ok(false); + }; + responses.push(String::from_utf8_lossy(&response).into_owned()); + response.fill(0); + } + state = session.authenticate_keyboard_interactive_respond(responses).await?; + }, + } + } + Ok(false) +} + +async fn prompt_secret( + destination: String, + initial: Option>, + allow_save: bool, +) -> io::Result, bool)>> { + tokio::task::spawn_blocking(move || { + crate::ssh_credentials::prompt_password(&destination, initial.as_deref(), allow_save) + }) + .await + .map_err(|err| io::Error::other(format!("凭据输入任务失败: {err}")))? +} + +fn clear_secret(secret: &mut Option>) { + if let Some(secret) = secret.as_mut() { + secret.fill(0); + } + *secret = None; +} + +fn remote_hook_token() -> io::Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|err| io::Error::other(format!("生成 SSH Hook 令牌失败: {err}")))?; + let mut token = String::with_capacity(32); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(token, "{byte:02x}"); + } + Ok(token) +} + fn render_error( terminal: &Arc>>, event_proxy: &EventProxy, @@ -283,11 +673,6 @@ fn confirm_new_host(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool { } } -#[cfg(not(windows))] -fn confirm_new_host(_host: &str, _port: u16, _key: &ssh_key::PublicKey) -> bool { - false -} - #[cfg(windows)] fn show_host_key_changed(host: &str, port: u16, detail: &str) { use std::ptr::null_mut; @@ -308,9 +693,6 @@ fn show_host_key_changed(host: &str, port: u16, detail: &str) { } } -#[cfg(not(windows))] -fn show_host_key_changed(_host: &str, _port: u16, _detail: &str) {} - #[cfg(windows)] fn wide(value: &str) -> Vec { value.encode_utf16().chain(std::iter::once(0)).collect() @@ -318,7 +700,7 @@ fn wide(value: &str) -> Vec { #[cfg(test)] mod tests { - use super::SshDestination; + use super::{SshDestination, parse_resolved_config}; #[test] fn parses_saved_destinations() { @@ -337,4 +719,14 @@ mod tests { let ipv6 = SshDestination::parse("ssh://root@[2001:db8::1]:2222").unwrap(); assert_eq!((ipv6.host.as_str(), ipv6.port), ("2001:db8::1", 2222)); } + + #[test] + fn parses_resolved_ssh_config() { + let config = "user deploy\nhostname server.internal\nport 2200\nidentityfile ~/.ssh/id_ed25519\n"; + let destination = parse_resolved_config("prod", config).unwrap(); + assert_eq!(destination.user, "deploy"); + assert_eq!(destination.host, "server.internal"); + assert_eq!(destination.port, 2200); + assert_eq!(destination.identity_files.len(), 1); + } } diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index cc52a9c..5392e41 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -552,7 +552,7 @@ impl WindowContext { config: &UiConfig, proxy: &EventLoopProxy, pane_id: PaneId, - destination: crate::ssh_session::SshDestination, + destination: String, ) -> Result> { let window_route = Arc::new(AtomicU64::new(window_id.into())); let event_proxy = EventProxy::new_tab(proxy.clone(), window_route.clone(), pane_id); @@ -561,7 +561,7 @@ impl WindowContext { size_info, event_proxy.clone(), ))); - let sender = crate::ssh_session::spawn_password_session( + let sender = crate::ssh_session::spawn_session( destination, (*size_info).into(), terminal.clone(), @@ -900,7 +900,7 @@ impl WindowContext { /// Nebula's ConPTY instead of becoming the pane's GUI-subsystem root. fn spawn_tab_ssh(&mut self, host: String) { #[cfg(windows)] - if let Ok(destination) = crate::ssh_session::SshDestination::parse(&host) { + { let pane_id = self.next_pane_id; match Self::create_ssh_pane( &self.display.size_info, @@ -908,7 +908,7 @@ impl WindowContext { &self.config, &self.proxy, pane_id, - destination, + host.clone(), ) { Ok(pane) => { self.next_pane_id += 1; @@ -926,13 +926,6 @@ impl WindowContext { self.dirty = true; return; }, - Err(err) - if err - .downcast_ref::() - .is_some_and(|err| err.kind() == std::io::ErrorKind::Unsupported) => - { - log::info!("直连 SSH 暂无可用认证方式,回退到兼容连接路径"); - }, Err(err) => { error!("创建直连 SSH Pane 失败: {err}"); return; @@ -940,6 +933,8 @@ impl WindowContext { } } + #[cfg(not(windows))] + { let Ok(exe) = std::env::current_exe() else { error!("Cannot locate nebula.exe for the SSH AskPass helper"); return; @@ -978,6 +973,7 @@ impl WindowContext { pane.notifier.notify(launch.command); } } + } } /// Open `path` in a read-only document viewer tab. A tab already viewing diff --git a/nebula_hook/src/main.rs b/nebula_hook/src/main.rs index bca8712..5c4c99a 100644 --- a/nebula_hook/src/main.rs +++ b/nebula_hook/src/main.rs @@ -61,11 +61,12 @@ fn run() { _ => args.last().cloned().unwrap_or_default().into_bytes(), }; - // Constraint 2: outside Nebula there is no pipe variable — do nothing. + let pane = std::env::var("NEBULA_PANE_ID").unwrap_or_default(); + let mut message = format!("nebula-hook/1 source={source} pane={pane}\n").into_bytes(); + message.extend_from_slice(&payload); + + // 本地 Pane 使用命名管道;远端 Pane 没有本地管道时,把同一信封写入控制终端的私有 OSC。 if let Some(pipe) = std::env::var_os("NEBULA_NOTIFY_PIPE") { - let pane = std::env::var("NEBULA_PANE_ID").unwrap_or_default(); - let mut message = format!("nebula-hook/1 source={source} pane={pane}\n").into_bytes(); - message.extend_from_slice(&payload); // The server accepts one connection at a time and re-creates the pipe // instance in between, so a raced connect fails for microseconds. @@ -79,6 +80,21 @@ fn run() { Err(_) => std::thread::sleep(std::time::Duration::from_millis(5)), } } + } else if let Ok(token) = std::env::var("NEBULA_REMOTE_HOOK_TOKEN") { + if token.len() == 32 + && token.bytes().all(|byte| byte.is_ascii_hexdigit()) + && message.len() <= 64 * 1024 + { + let osc = format!( + "\x1b]777;nebula-hook;{};{}\x07", + token, + base64_encode(&message) + ); + if let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") { + let _ = tty.write_all(osc.as_bytes()); + let _ = tty.flush(); + } + } } // Chain mode: keep a pre-existing codex notifier working. Runs even @@ -91,3 +107,39 @@ fn run() { } } } + +fn base64_encode(input: &[u8]) -> String { + const TABLE: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let a = chunk[0]; + let b = chunk.get(1).copied().unwrap_or(0); + let c = chunk.get(2).copied().unwrap_or(0); + output.push(TABLE[(a >> 2) as usize] as char); + output.push(TABLE[(((a & 0x03) << 4) | (b >> 4)) as usize] as char); + output.push(if chunk.len() > 1 { + TABLE[(((b & 0x0f) << 2) | (c >> 6)) as usize] as char + } else { + '=' + }); + output.push(if chunk.len() > 2 { + TABLE[(c & 0x3f) as usize] as char + } else { + '=' + }); + } + output +} + +#[cfg(test)] +mod tests { + use super::base64_encode; + + #[test] + fn encodes_remote_hook_payload() { + assert_eq!(base64_encode(b"abc"), "YWJj"); + assert_eq!(base64_encode(b"ab"), "YWI="); + assert_eq!(base64_encode(b"a"), "YQ=="); + } +} diff --git a/nebula_terminal/src/event.rs b/nebula_terminal/src/event.rs index 63dd4b8..c49d46c 100644 --- a/nebula_terminal/src/event.rs +++ b/nebula_terminal/src/event.rs @@ -45,6 +45,9 @@ pub enum Event { /// OSC 9 — free-text notification from a program (iTerm style). Notify(String), + /// 已通过 SSH 通道令牌校验的远端 AI Hook 原始信封。 + AiHookEnvelope(Vec), + /// Request to store a text string in the clipboard. ClipboardStore(ClipboardType, String), @@ -98,6 +101,9 @@ impl Debug for Event { Event::CommandStart => write!(f, "CommandStart"), Event::CommandDone => write!(f, "CommandDone"), Event::Notify(text) => write!(f, "Notify({text})"), + Event::AiHookEnvelope(envelope) => { + write!(f, "AiHookEnvelope({} bytes)", envelope.len()) + }, Event::CursorBlinkingChange => write!(f, "CursorBlinkingChange"), Event::MouseCursorDirty => write!(f, "MouseCursorDirty"), Event::ResetTitle => write!(f, "ResetTitle"), diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index fe222a5..ec21dad 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -35,6 +35,7 @@ pub struct StreamProcessor { parser: ansi::Processor, cwd_sniffer: crate::osc_cwd::CwdSniffer, window_size: Option, + remote_hook_token: Option, } impl StreamProcessor { @@ -42,6 +43,10 @@ impl StreamProcessor { self.window_size = Some(window_size); } + pub fn set_remote_hook_token(&mut self, token: String) { + self.remote_hook_token = Some(token); + } + pub fn next_sync_timeout(&self) -> Option { self.parser.sync_timeout().sync_timeout() } @@ -69,6 +74,11 @@ impl StreamProcessor { OscEvent::CommandStart => event_proxy.send_event(Event::CommandStart), OscEvent::CommandDone => event_proxy.send_event(Event::CommandDone), OscEvent::Notify(text) => event_proxy.send_event(Event::Notify(text)), + OscEvent::RemoteHook { token, envelope } => { + if self.remote_hook_token.as_deref() == Some(token.as_str()) { + event_proxy.send_event(Event::AiHookEnvelope(envelope)); + } + }, OscEvent::PromptMark => { self.parser.advance(terminal, &bytes[advanced..offset]); advanced = offset; diff --git a/nebula_terminal/src/osc_cwd.rs b/nebula_terminal/src/osc_cwd.rs index 0b48ad3..f9891aa 100644 --- a/nebula_terminal/src/osc_cwd.rs +++ b/nebula_terminal/src/osc_cwd.rs @@ -29,6 +29,8 @@ const MAX_PAYLOAD: usize = 4096; /// Cap for OSC 1337 inline-image payloads (base64 PNG). Anything larger is /// dropped rather than buffered forever. const MAX_IMAGE_PAYLOAD: usize = 12 * 1024 * 1024; +/// 远端 Hook JSON 使用 Base64 传输;上限阻止伪造 OSC 长时间占用内存。 +const MAX_HOOK_PAYLOAD: usize = 96 * 1024; /// An OSC event recognized by the sniffer. #[derive(Debug, Clone, PartialEq, Eq)] @@ -43,6 +45,8 @@ pub enum OscEvent { CommandDone, /// OSC 9 — free-text program notification (iTerm style). Notify(String), + /// Nebula 远端 Hook 私有 OSC:随机通道令牌 + 原始 Hook 信封。 + RemoteHook { token: String, envelope: Vec }, /// OSC 1337 `File=...inline=1:` — an iTerm2 inline image (PNG). /// `width`/`height` come from the PNG header, in pixels. InlineImage { png: Vec, width: u32, height: u32 }, @@ -143,7 +147,13 @@ impl CwdSniffer { return; } // Inline images are the one legitimately huge OSC we buffer. - let cap = if self.payload.starts_with(b"1337;") { MAX_IMAGE_PAYLOAD } else { MAX_PAYLOAD }; + let cap = if self.payload.starts_with(b"1337;") { + MAX_IMAGE_PAYLOAD + } else if self.payload.starts_with(b"777;nebula-hook;") { + MAX_HOOK_PAYLOAD + } else { + MAX_PAYLOAD + }; if self.payload.len() >= cap { self.interested = false; return; @@ -169,6 +179,9 @@ impl CwdSniffer { if let Some(rest) = self.payload.strip_prefix(b"1337;") { return parse_osc1337_image(rest); } + if let Some(rest) = self.payload.strip_prefix(b"777;nebula-hook;") { + return parse_remote_hook(rest); + } if let Some(rest) = self.payload.strip_prefix(b"133;") { // Semantic prompt zones (FinalTerm). `A` may carry kitty-style // `;key=value` params — accept those too. B (command start being @@ -205,8 +218,26 @@ fn prefix_could_match(payload: &[u8]) -> bool { const B: &[u8] = b"9;"; const C: &[u8] = b"133;"; const D: &[u8] = b"1337;"; + const E: &[u8] = b"777;"; let matches = |target: &[u8]| target.starts_with(payload) || payload.starts_with(target); - matches(A) || matches(B) || matches(C) || matches(D) + matches(A) || matches(B) || matches(C) || matches(D) || matches(E) +} + +fn parse_remote_hook(rest: &[u8]) -> Option { + use base64::Engine as _; + + let separator = rest.iter().position(|byte| *byte == b';')?; + let token = std::str::from_utf8(&rest[..separator]).ok()?.trim(); + if token.len() != 32 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + let envelope = base64::engine::general_purpose::STANDARD + .decode(&rest[separator + 1..]) + .ok()?; + if envelope.is_empty() || envelope.len() > 64 * 1024 { + return None; + } + Some(OscEvent::RemoteHook { token: token.to_owned(), envelope }) } /// Parse an OSC 1337 body (`File=key=value;...:`) into an inline @@ -423,6 +454,27 @@ mod tests { ); } + #[test] + fn remote_hook_requires_valid_token_and_base64() { + use base64::Engine as _; + let envelope = b"nebula-hook/1 source=codex pane=999\n{\"type\":\"agent-turn-complete\"}"; + let encoded = base64::engine::general_purpose::STANDARD.encode(envelope); + let seq = format!( + "\x1b]777;nebula-hook;0123456789abcdef0123456789abcdef;{encoded}\x07" + ); + assert_eq!( + events(seq.as_bytes()), + vec![( + seq.len(), + OscEvent::RemoteHook { + token: "0123456789abcdef0123456789abcdef".into(), + envelope: envelope.to_vec(), + } + )] + ); + assert!(events(b"\x1b]777;nebula-hook;short;AAAA\x07").is_empty()); + } + #[test] fn cwd_and_mark_interleaved() { let ev = events(b"\x1b]7;file:///C:/w\x07\x1b]133;A\x07");