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.
+
+
+
+
**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 客户端处理。
+
+
+
+
**性能与正确性**
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");