Wave 2.2: 流式安装输出、软件内授权四模式、本机环境一键装与总览缓存

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
leefer
2026-08-25 17:11:14 +08:00
co-authored by Cursor
parent a2fd5e369b
commit dd5a9378d2
24 changed files with 2659 additions and 263 deletions
+486 -72
View File
@@ -4,8 +4,9 @@
//! detectCli / previewAction / runAction / readConfig / writeConfig /
//! authStatus / authorize / diagnose。所有命令执行走 argv 数组 + 脱敏。
use std::path::PathBuf;
use std::sync::Arc;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use agentdock_adapter::{Adapter, AdapterAction, DryRunPlan, load_adapters, preview_action};
use agentdock_config as cfg;
@@ -14,9 +15,18 @@ use agentdock_secrets::{SecretStore, redact, service_name};
use serde_json::json;
use crate::error::EngineError;
use crate::process::{run_capture, run_streaming, which_all};
use crate::errors_zh::map_exec_error;
use crate::process::{
resolve_exe, run_capture, run_streaming, run_streaming_cancellable, run_terminal, run_with_stdin,
which_all, RunningProcess,
};
use crate::types::*;
/// 授权会话 key`<cli_id>:<mode>`。
fn session_key(id: &str, mode: &str) -> String {
format!("{id}:{mode}")
}
/// 动作选项(runAction 的 opts)。
#[derive(Debug, Clone, Default)]
pub struct ActionOpts {
@@ -29,11 +39,17 @@ pub struct ActionOpts {
pub struct Engine {
adapters_dir: PathBuf,
secrets: Arc<dyn SecretStore>,
/// 进行中的授权会话(可取消的进程句柄)
auth_sessions: Arc<Mutex<HashMap<String, Arc<RunningProcess>>>>,
}
impl Engine {
pub fn new(adapters_dir: PathBuf, secrets: Arc<dyn SecretStore>) -> Self {
Engine { adapters_dir, secrets }
Engine {
adapters_dir,
secrets,
auth_sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
/// 加载全部适配器(已过 schema 校验)。
@@ -94,9 +110,222 @@ impl Engine {
Ok(auth_status(&adapter, self.secrets.as_ref()))
}
pub fn authorize(&self, id: &str, mode: &str) -> Result<(), EngineError> {
/// 取消正在进行的授权流程(按 cli_id + mode)。
pub fn cancel_authorize(&self, id: &str, mode: &str) {
if let Some(rp) = self.auth_sessions.lock().unwrap().get(&session_key(id, mode)) {
rp.request_cancel();
}
}
/// 软件内授权(Wave 2.2 Req 2):按官方授权方式运行登录命令,
/// 流式回传事件(设备码/浏览器/API Key/本机终端)。
pub fn authorize_stream<F>(&self, id: &str, mode: &str, mut emit: F) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let adapter = self.adapter(id)?;
authorize(&adapter, mode)
let auth = adapter
.authorization
.as_ref()
.ok_or_else(|| EngineError::NotSupported(format!("{} 未声明授权方式", adapter.id)))?;
let target = auth
.modes
.iter()
.find(|m| m.mode == mode)
.ok_or_else(|| EngineError::NotSupported(format!("{} 未声明 {mode} 授权方式", adapter.id)))?;
emit(AuthFlowEvent::started(
&adapter.id,
mode,
format!("开始{}授权…", auth_mode_label_zh(mode)),
));
// API Key 且无官方命令:仅核对密钥库是否已保存
if mode == "api_key" && target.command.is_empty() {
let st = auth_status(&adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, st.status == "authorized"));
return Ok(());
}
let prog = &target.command[0];
let args: Vec<String> = target.command[1..].to_vec();
agentdock_exec::validate_argv(prog, &args)?;
let exe = resolve_exe(prog);
match mode {
"device_code" => self.run_device_code(&adapter, mode, &exe, &args, &mut emit),
"browser_oauth" => self.run_browser_oauth(&adapter, mode, &exe, &args, &mut emit),
"local_tui" => self.run_terminal_flow(&adapter, mode, &exe, &args, &mut emit),
"api_key" => self.run_api_key_flow(&adapter, mode, &exe, &args, &mut emit),
other => Err(EngineError::NotSupported(format!("未支持的授权模式 {other}"))),
}
}
fn register_session(&self, id: &str, mode: &str, rp: Arc<RunningProcess>) {
self.auth_sessions.lock().unwrap().insert(session_key(id, mode), rp);
}
fn unregister_session(&self, id: &str, mode: &str) {
self.auth_sessions.lock().unwrap().remove(&session_key(id, mode));
}
/// 设备码授权:运行官方命令,解析 user_code + verification_url,轮询等待结果。
fn run_device_code<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let rp = Arc::new(RunningProcess::new());
self.register_session(&adapter.id, mode, rp.clone());
let mut captured = String::new();
let mut code_emitted = false;
let result = run_streaming_cancellable(rp.as_ref(), exe, args, |is_err, line| {
let clean = strip_ansi(line);
if !is_err {
captured.push_str(&clean);
captured.push('\n');
}
emit(AuthFlowEvent::line(&adapter.id, mode, redact(&clean)));
if !code_emitted {
if let Some((code, url)) = parse_device_code(&captured) {
code_emitted = true;
emit(AuthFlowEvent::device_code(&adapter.id, mode, code, url));
}
}
});
self.unregister_session(&adapter.id, mode);
if rp.is_cancelled() {
emit(AuthFlowEvent::cancelled(&adapter.id, mode));
return Ok(());
}
match result {
Ok(ok) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("授权命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
/// 浏览器授权:后台运行官方登录命令,等待浏览器确认(可取消)。
fn run_browser_oauth<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let rp = Arc::new(RunningProcess::new());
self.register_session(&adapter.id, mode, rp.clone());
emit(AuthFlowEvent::waiting(
&adapter.id,
mode,
"正在等待浏览器确认…(完成后会自动刷新授权状态)",
));
let result = run_streaming_cancellable(rp.as_ref(), exe, args, |_is_err, line| {
let clean = strip_ansi(line);
emit(AuthFlowEvent::line(&adapter.id, mode, redact(&clean)));
});
self.unregister_session(&adapter.id, mode);
if rp.is_cancelled() {
emit(AuthFlowEvent::cancelled(&adapter.id, mode));
return Ok(());
}
match result {
Ok(ok) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("授权命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
/// API Key 授权(有官方命令时):从密钥库读 key,经 stdin 注入官方登录命令。
fn run_api_key_flow<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let key = self
.secrets
.get(&service_name(&adapter.id), "api_key")
.map_err(|_| {
EngineError::Secrets("未找到已保存的 API Key:请先在「授权 / 登录」里保存 API Key".into())
})?;
emit(AuthFlowEvent::waiting(
&adapter.id,
mode,
"正在用系统密钥库中保存的 API Key 调用官方登录命令…",
));
match run_with_stdin(Path::new(exe), args, &key) {
Ok((ok, _text)) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("登录命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
/// 本机终端授权(local_tui):一次性终端窗口跑官方命令,结束只回传成败状态。
fn run_terminal_flow<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
emit(AuthFlowEvent::waiting(
&adapter.id,
mode,
"已打开本机终端窗口运行官方登录命令,完成后窗口会自动关闭…",
));
match run_terminal(exe, args) {
Ok(ok) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("终端命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
// ---- 诊断 ----
@@ -106,6 +335,16 @@ impl Engine {
Ok(diagnose(&adapter, env, self.secrets.as_ref()))
}
/// 批量诊断全部已装工具(总览「立即诊断」入口用)。
pub fn diagnose_all(&self, env: &agentdock_platform::model::PlatformEnv) -> Result<Vec<diag::DiagnosticReport>, EngineError> {
let adapters = self.adapters()?;
Ok(adapters
.iter()
.filter(|a| detect_one(a).status == "installed")
.map(|a| diagnose(a, env, self.secrets.as_ref()))
.collect())
}
// ---- 执行 ----
pub fn run<F>(&self, id: &str, action: AdapterAction, opts: &ActionOpts, mut emit: F) -> Result<(), EngineError>
@@ -610,42 +849,98 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
}
}
/// 执行授权命令(交互式,流式输出)。
pub fn authorize(adapter: &Adapter, mode: &str) -> Result<(), EngineError> {
let auth = adapter
.authorization
.as_ref()
.ok_or_else(|| EngineError::NotSupported(format!("{} 未声明授权方式", adapter.id)))?;
let target = auth
.modes
.iter()
.find(|m| m.mode == mode)
.or_else(|| auth.modes.first())
.ok_or_else(|| EngineError::NotSupported(format!("{} 无可用授权模式", adapter.id)))?;
if target.command.is_empty() {
return Err(EngineError::NotSupported(format!(
"{}{mode} 授权本波由配置表单完成(API Key)",
adapter.id
)));
// =====================================================================
// 授权辅助:模式中文名 / 设备码解析 / ANSI 去色
// =====================================================================
/// 授权模式中文名。
pub fn auth_mode_label_zh(mode: &str) -> &'static str {
match mode {
"browser_oauth" => "浏览器账号授权",
"device_code" => "设备码",
"api_key" => "API Key",
"local_tui" => "本机终端授权",
_ => "授权",
}
let prog = &target.command[0];
let args: Vec<String> = target.command[1..].to_vec();
agentdock_exec::validate_argv(prog, &args)?;
let exe = which_all(prog)
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| prog.clone());
let ok = run_streaming(&exe, &args, |is_err, line| {
let r = redact(line);
// authorize 不吐日志到文件,仅进程内消费;这里仅占位
let _ = (is_err, r);
})?;
if !ok {
return Err(EngineError::Exec(format!("{mode} 授权命令退出码非 0")));
}
Ok(())
}
/// 去除终端 ANSI 转义序列(颜色/光标控制),保留可读文本。
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
// CSI 序列:ESC [ ... 终字母
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&n) = chars.peek() {
if n.is_ascii_digit() || matches!(n, ';' | '?' | '=' | '!') {
chars.next();
} else {
break;
}
}
if let Some(&n) = chars.peek() {
if n.is_ascii_alphabetic() {
chars.next();
}
}
} else if chars.peek() == Some(&']') {
// OSC 序列:ESC ] ... BEL 或 ESC \
chars.next();
for n in chars.by_ref() {
if n == '\u{7}' || n == '\u{1b}' {
break;
}
}
}
continue;
}
out.push(c);
}
out
}
/// 从命令输出解析设备码 + 验证链接(device_code 流程)。
/// 返回 (user_code, verification_url)。
pub fn parse_device_code(text: &str) -> Option<(String, String)> {
let url = find_verification_url(text)?;
let code = find_device_code(text)?;
Some((code, url))
}
/// 从输出里挑验证链接:优先含 device/auth/login 关键字的 URL,否则取首个 URL。
fn find_verification_url(text: &str) -> Option<String> {
let re = regex::Regex::new(r#"https?://[^\s<>"'\]]+"#).ok()?;
let mut first: Option<String> = None;
for m in re.find_iter(text) {
let u = m.as_str().trim_end_matches(['.', ',', ')']);
if first.is_none() {
first = Some(u.to_string());
}
let lower = u.to_lowercase();
if lower.contains("device") || lower.contains("auth") || lower.contains("login") {
return Some(u.to_string());
}
}
first
}
/// 从输出里解析设备码:优先 `XXXX-XXXX` 带连字符形态(各段 4–8 位大写字母数字),
/// 其次 6–10 位大写字母数字。
fn find_device_code(text: &str) -> Option<String> {
let dashed = regex::Regex::new(r"\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b").ok()?;
if let Some(m) = dashed.find(text) {
return Some(m.as_str().to_string());
}
let plain = regex::Regex::new(r"\b[A-Z0-9]{6,10}\b").ok()?;
plain.find(text).map(|m| m.as_str().to_string())
}
// =====================================================================
// 授权(旧同步入口移除;交互式授权走 Engine::authorize_stream
// =====================================================================
// =====================================================================
// 诊断
// =====================================================================
@@ -814,18 +1109,22 @@ where
let args: Vec<String> = cmd[1..].to_vec();
agentdock_exec::validate_argv(prog, &args)?;
// Windows 上解析到 npm.cmd / winget.exe 等实际路径再执行(避免 CreateProcess 找不到无扩展名脚本)
let exe = which_all(prog)
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| prog.clone());
let exe = resolve_exe(prog);
emit(ActionEvent::step_phase(
"exec",
format!("执行命令:{} {}", prog, args.join(" ")),
));
// 实时流式输出(stdout/stderr 并发上屏);同时保留 stderr 尾部用于失败人话化
let mut stderr_tail = String::new();
let result = run_streaming(&exe, &args, |is_err, line| {
let r = redact(line);
if is_err {
stderr_tail.push_str(&r);
stderr_tail.push('\n');
if stderr_tail.len() > 4000 {
stderr_tail.drain(..stderr_tail.len() - 4000);
}
emit(ActionEvent::stderr(r));
} else {
emit(ActionEvent::stdout(r));
@@ -835,20 +1134,16 @@ where
let ok = match result {
Ok(ok) => ok,
Err(e) => {
let hint = diag_hint_for_install_failure(adapter, &e.to_string());
emit(ActionEvent::error(format!(
"{label}失败:无法启动命令({e})。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令启动失败: {e}")));
let hint = map_exec_error(prog, Some(&e), &stderr_tail);
emit(ActionEvent::error_hint(&hint));
return Err(EngineError::Exec(hint.friendly_zh.clone()));
}
};
if !ok {
let hint = diag_hint_for_install_failure(adapter, "");
emit(ActionEvent::error(format!(
"{label}失败:命令退出码非 0(原始错误见上方 stderr 输出)。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令退出码非 0")));
let hint = map_exec_error(prog, None, &stderr_tail);
emit(ActionEvent::error_hint(&hint));
return Err(EngineError::Exec(hint.friendly_zh.clone()));
}
}
@@ -864,25 +1159,6 @@ where
Ok(())
}
/// 安装失败时的中文诊断建议(按适配器声明的依赖/渠道给针对性提示,不吞原始错误)。
fn diag_hint_for_install_failure(adapter: &Adapter, spawn_err: &str) -> String {
let mut parts: Vec<String> = Vec::new();
if !spawn_err.is_empty() {
parts.push("请确认所需命令已安装并加入 PATH(可到「诊断」页排查)".into());
}
let deps: Vec<&str> = adapter
.runtime_deps
.iter()
.filter(|d| d.required_for.iter().any(|r| r == "install" || r == "run"))
.map(|d| d.id.as_str())
.collect();
if !deps.is_empty() {
parts.push(format!("依赖检查:{}", deps.join("")));
}
parts.push("常见原因:网络不通 / 依赖版本不符 / 需要管理员权限,可在「诊断」页查看详细结论。".into());
format!("{}", parts.join(""))
}
// =====================================================================
// 测试
// =====================================================================
@@ -1337,4 +1613,142 @@ configuration:
println!("重装后 opencode: status={} version={:?}", after.status, after.version);
assert_eq!(after.status, "installed", "重装后应恢复为已安装");
}
// ---- Wave 2.2:设备码解析 / ANSI 去色 / 错误人话化 ----
#[test]
fn strip_ansi_removes_color_codes() {
let raw = "code \u{1b}[94mBFEZ-121LZ\u{1b}[0m end";
assert_eq!(strip_ansi(raw), "code BFEZ-121LZ end");
}
#[test]
fn parse_device_code_extracts_code_and_url() {
let text = "\n1. Open this link in your browser and sign in to your account\n https://auth.openai.com/codex/device\n\n2. Enter this one-time code (expires in 15 minutes)\n BFEZ-121LZ\n";
let (code, url) = parse_device_code(text).expect("应解析出设备码与链接");
assert_eq!(code, "BFEZ-121LZ");
assert!(url.contains("auth.openai.com"));
}
#[test]
fn parse_device_code_handles_ansi() {
let text = "\u{1b}[94mhttps://auth.openai.com/codex/device\u{1b}[0m\n\u{1b}[94mBFEZ-121LZ\u{1b}[0m\n";
let (code, url) = parse_device_code(&strip_ansi(text)).expect("去色后应能解析");
assert_eq!(code, "BFEZ-121LZ");
assert!(url.contains("auth.openai.com"));
}
#[test]
fn error_event_carries_hint_data() {
use crate::errors_zh::map_exec_error;
let e = std::io::Error::new(std::io::ErrorKind::NotFound, "program not found");
let hint = map_exec_error("npm", Some(&e), "");
let ev = ActionEvent::error_hint(&hint);
assert_eq!(ev.kind, "error");
assert!(ev.message.to_lowercase().contains("node"));
let data = ev.data.expect("error_hint 应携带 data");
assert_eq!(data["code"], "program_not_found");
assert_eq!(data["missing_runtime"], "node");
}
// ---- Wave 2.2 真机自测(--ignored):实时滚动留证 / 设备码到出码步骤留证 ----
/// 实时滚动留证:运行交错输出 stdout/stderr 的命令,断言 stderr 行在
/// stdout 行之间到达(证明是并发实时上屏,而非「先 stdout 后 stderr」的结束回放)。
/// 运行:cargo test -p agentdock-core real_machine_streaming_evidence -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_streaming_evidence() {
#[cfg(windows)]
let (exe, args) = (
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-Command".to_string(),
"1..4 | ForEach-Object { Write-Output ('out'+$_); [Console]::Error.WriteLine('err'+$_); Start-Sleep -Milliseconds 400 }".to_string(),
],
);
#[cfg(not(windows))]
let (exe, args) = (
"sh".to_string(),
vec![
"-c".to_string(),
"for i in 1 2 3 4; do echo out$i; echo err$i 1>&2; sleep 0.4; done".to_string(),
],
);
let mut sequence: Vec<(bool, String, u128)> = Vec::new();
let start = std::time::Instant::now();
let ok = run_streaming(&exe, &args, |is_err, line| {
sequence.push((is_err, line.to_string(), start.elapsed().as_millis()));
})
.expect("流式命令应可执行");
assert!(ok);
for (is_err, line, ms) in &sequence {
println!(" [{:>5}ms] {} {}", ms, if *is_err { "stderr" } else { "stdout" }, line);
}
// 关键断言:首个 stderr 行必须早于最后一个 stdout 行(即交错而非结尾回放)
let first_stderr_idx = sequence.iter().position(|(e, _, _)| *e);
let last_stdout_idx = sequence.iter().rposition(|(e, _, _)| !*e);
if let (Some(fe), Some(lo)) = (first_stderr_idx, last_stdout_idx) {
assert!(fe < lo, "stderr 应在 stdout 结束前交错到达(实时),而非全部延迟到结尾");
}
assert!(sequence.len() >= 8, "应收到全部 8 行");
}
/// 软件内授权留证(设备码):真机跑 codex login --device-auth,解析出设备码 +
/// 验证链接后取消(不要求真实账号完成最后一步,走到「等待用户确认/出码」即达标)。
/// 运行:cargo test -p agentdock-core real_machine_device_code_evidence -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_device_code_evidence() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let codex = adapters.iter().find(|a| a.id == "codex").expect("缺少 codex");
let target = codex
.authorization
.as_ref()
.and_then(|a| a.modes.iter().find(|m| m.mode == "device_code"))
.expect("codex 应声明 device_code 授权");
if target.command.is_empty() {
println!("codex 未声明 device_code 命令,跳过");
return;
}
let exe = resolve_exe(&target.command[0]);
let args: Vec<String> = target.command[1..].to_vec();
let rp = RunningProcess::new();
let found: Arc<Mutex<Option<(String, String)>>> = Arc::new(Mutex::new(None));
let found2 = found.clone();
let cancel = rp.cancel_flag();
// 8 秒后自动取消(走到出码即可,不等真实登录)
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(8));
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
});
let mut captured = String::new();
let _ = run_streaming_cancellable(&rp, &exe, &args, |is_err, line| {
let clean = strip_ansi(line);
if !is_err {
captured.push_str(&clean);
captured.push('\n');
}
if found2.lock().unwrap().is_none() {
if let Some((code, url)) = parse_device_code(&captured) {
*found2.lock().unwrap() = Some((code, url));
}
}
});
let (code, url) = found.lock().unwrap().clone().expect("应在 8 秒内解析出设备码");
println!("=== codex 设备码流程(到出码步骤) ===");
println!("验证链接: {url}");
println!("设备码: {code}");
assert!(url.contains("openai.com") || url.contains("auth.openai"), "验证链接应指向官方域名: {url}");
assert!(!code.is_empty(), "设备码不应为空");
}
}
+149
View File
@@ -0,0 +1,149 @@
//! 执行失败的错误人话化映射(Wave 2.2 Req 1
//!
//! 把「program not found / 网络超时 / 权限不足 / 磁盘不足」等原始错误
//! 映射为中文建议 + 结构化 code + 可直达安装的缺失运行时 id。
//! 原始报错保留在 `raw`(界面折叠展示),`friendly_zh` 默认展示。
use std::io::ErrorKind;
use crate::types::ErrorHint;
/// 由可执行文件名反推对应的运行时 id(用于「未找到 npm → 去装 Node.js」联动)。
pub fn runtime_for_prog(prog: &str) -> Option<&'static str> {
match prog.to_ascii_lowercase().as_str() {
"npm" | "node" | "node.exe" => Some("node"),
"python" | "python3" | "py" | "pip" => Some("python"),
"git" => Some("git"),
"winget" => Some("winget"),
"uv" => Some("uv"),
"choco" => Some("choco"),
"scoop" => Some("scoop"),
_ => None,
}
}
/// 主入口:结合 spawn 错误与 stderr 尾部文本,产出结构化错误提示。
/// `spawn_err` 为 `Command::spawn` 的 io::ErrorNone 表示进程已启动但退出非 0)。
pub fn map_exec_error(prog: &str, spawn_err: Option<&std::io::Error>, stderr_tail: &str) -> ErrorHint {
let raw = spawn_err.map(|e| e.to_string()).unwrap_or_default();
// 1. 命令不存在(最常见:未安装 / 不在 PATH)
if let Some(e) = spawn_err {
match e.kind() {
ErrorKind::NotFound => {
let missing = runtime_for_prog(prog);
let friendly = match missing {
Some(r) => format!("未找到 {prog} 命令:需要先安装 {r}(可在下方「本机环境」区一键安装)"),
None => format!("未找到 {prog} 命令:请确认 {prog} 已安装并加入 PATH"),
};
return ErrorHint {
code: "program_not_found".into(),
friendly_zh: friendly,
raw,
missing_runtime: missing.map(|s| s.to_string()),
};
}
ErrorKind::PermissionDenied => {
return ErrorHint {
code: "permission_denied".into(),
friendly_zh: "权限不足:需要管理员权限,或目标目录不可写。".into(),
raw,
missing_runtime: None,
};
}
_ => {}
}
}
// 2. 网络 / 磁盘等常见错误(从 stderr 尾部判断)
let lower = stderr_tail.to_lowercase();
let network_pats: &[(&str, &str, &str)] = &[
("timed out", "network_timeout", "网络连接超时:请检查网络或代理设置后重试。"),
("etimedout", "network_timeout", "网络连接超时:请检查网络或代理设置后重试。"),
("econnrefused", "network_timeout", "连接被拒绝:目标服务不可达,请稍后重试。"),
("getaddrinfo", "network_dns", "域名解析失败:请检查网络与 DNS 设置。"),
("enotfound", "network_dns", "域名解析失败:请检查网络与 DNS 设置。"),
("certificate", "network_tls", "证书校验失败:网络可能被代理/防火墙干扰。"),
("enospc", "disk_full", "磁盘空间不足:请清理磁盘后重试。"),
("no space left", "disk_full", "磁盘空间不足:请清理磁盘后重试。"),
("eacces", "permission_denied", "权限不足:需要管理员权限,或目标目录不可写。"),
("eperm", "permission_denied", "权限不足:需要管理员权限,或目标目录不可写。"),
];
for (pat, code, msg) in network_pats {
if lower.contains(pat) {
return ErrorHint {
code: (*code).into(),
friendly_zh: (*msg).into(),
raw: raw.clone(),
missing_runtime: None,
};
}
}
// 3. 兜底:通用失败
ErrorHint {
code: "exec_failed".into(),
friendly_zh: "命令执行失败,详见上方原始输出。".into(),
raw: if raw.is_empty() { stderr_tail.to_string() } else { raw },
missing_runtime: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn not_found() -> std::io::Error {
std::io::Error::new(ErrorKind::NotFound, "program not found")
}
#[test]
fn maps_program_not_found_to_node_for_npm() {
let h = map_exec_error("npm", Some(&not_found()), "");
assert_eq!(h.code, "program_not_found");
assert_eq!(h.missing_runtime.as_deref(), Some("node"));
assert!(h.friendly_zh.contains("Node") || h.friendly_zh.contains("node"));
}
#[test]
fn maps_program_not_found_for_plain_cli() {
let h = map_exec_error("gemini", Some(&not_found()), "");
assert_eq!(h.code, "program_not_found");
assert_eq!(h.missing_runtime, None);
assert!(h.friendly_zh.contains("gemini"));
}
#[test]
fn maps_permission_denied() {
let e = std::io::Error::new(ErrorKind::PermissionDenied, "access denied");
let h = map_exec_error("npm", Some(&e), "");
assert_eq!(h.code, "permission_denied");
}
#[test]
fn maps_network_timeout_from_stderr() {
let h = map_exec_error("npm", None, "npm ERR! code ETIMEDOUT");
assert_eq!(h.code, "network_timeout");
assert!(h.friendly_zh.contains("网络"));
}
#[test]
fn maps_disk_full_from_stderr() {
let h = map_exec_error("npm", None, "ENOSPC: no space left on device");
assert_eq!(h.code, "disk_full");
}
#[test]
fn fallback_is_generic() {
let h = map_exec_error("npm", None, "something weird happened");
assert_eq!(h.code, "exec_failed");
}
#[test]
fn runtime_lookup() {
assert_eq!(runtime_for_prog("npm"), Some("node"));
assert_eq!(runtime_for_prog("python"), Some("python"));
assert_eq!(runtime_for_prog("git"), Some("git"));
assert_eq!(runtime_for_prog("opencode"), None);
}
}
+13 -3
View File
@@ -6,12 +6,22 @@
pub mod engine;
pub mod error;
pub mod errors_zh;
pub mod process;
pub mod runtime_install;
pub mod types;
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, verify_config, write_config};
pub use engine::{
ActionOpts, Engine, auth_mode_label_zh, auth_status, detect_one, diagnose, parse_device_code,
parse_version, read_config, run_action, strip_ansi, verify_config, write_config,
};
pub use error::EngineError;
pub use errors_zh::{map_exec_error, runtime_for_prog};
pub use process::{open_with_shell, resolve_exe, run_capture, run_streaming, run_terminal, run_with_stdin, which_all, RunningProcess};
pub use runtime_install::{
download_with_curl, is_url_host_allowed, source_for, supported_runtimes, RuntimeSource,
};
pub use types::{
ActionEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, ConfigVerifyResult, now_secs,
ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState,
DetectResult, EnvFieldState, ErrorHint, WriteResult, ConfigVerifyResult, now_secs,
};
+254 -14
View File
@@ -2,10 +2,16 @@
//!
//! 所有命令经 `agentdock-exec::validate_argv` 校验后执行,禁止 shell 拼接。
//! 探测类命令(`--version`)隐藏控制台窗口(Windows CREATE_NO_WINDOW)。
//! 流式执行(`run_streaming` / `run_streaming_cancellable`)用两条读取线程
//! **并发**消费 stdout 与 stderr,再经 mpsc 通道按到达顺序回调——保证实时滚动,
//! 避免「先读 stdout 再读 stderr」造成的 stderr 延迟到进程结束时才出现的旧行为。
use std::io::BufRead;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
/// 按平台分隔符拆分 PATH。
fn path_entries() -> Vec<String> {
@@ -37,6 +43,14 @@ pub fn which_all(program: &str) -> Vec<PathBuf> {
found
}
/// 在 PATH 中解析可执行文件为可运行路径(找不到时回退原 prog,让 spawn 报错)。
pub fn resolve_exe(program: &str) -> String {
which_all(program)
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| program.to_string())
}
/// 运行只读探测命令并返回 (exit_ok, stdout+stderr 合并文本)。
pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String)> {
let mut cmd = Command::new(exe);
@@ -54,9 +68,84 @@ pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String
Ok((out.status.success(), text))
}
/// 流式执行命令,逐行回调(stdout 与 stderr 均按行输出)。返回是否成功。
/// 顺序读:先 stdoutstderr;对长任务足够,且实现简单可靠、无闭包 Send 负担
pub fn run_streaming<F>(exe: &str, args: &[String], mut on_line: F) -> std::io::Result<bool>
/// 运行命令并把一段文本写入其 stdin(API Key 经 stdin 注入官方登录命令用),
/// 返回 (exit_ok, stdout+stderr 合并文本)。输入内容绝不明文落日志
pub fn run_with_stdin(exe: &Path, args: &[String], stdin_text: &str) -> std::io::Result<(bool, String)> {
let mut cmd = Command::new(exe);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_text.as_bytes());
// 关闭 stdin,通知子进程输入结束
}
let out = child.wait_with_output()?;
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
if text.trim().is_empty() {
text = String::from_utf8_lossy(&out.stderr).to_string();
}
Ok((out.status.success(), text))
}
/// 可取消的进程句柄:`cancel` 置位后流式读取循环会终止子进程;
/// 授权等交互式流程需要取消时调用 `request_cancel`。
#[derive(Default)]
pub struct RunningProcess {
cancel: Arc<AtomicBool>,
child: Arc<Mutex<Option<Child>>>,
}
impl RunningProcess {
pub fn new() -> Self {
RunningProcess::default()
}
/// 请求取消(安全幂等)。
pub fn request_cancel(&self) {
self.cancel.store(true, Ordering::SeqCst);
}
/// 取取消标志的共享句柄(供其它线程定时/条件取消)。
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
self.cancel.clone()
}
pub fn is_cancelled(&self) -> bool {
self.cancel.load(Ordering::SeqCst)
}
fn kill(&self) {
if let Some(mut child) = self.child.lock().unwrap().take() {
kill_child_tree(&mut child);
}
}
}
/// 流式执行命令,逐行回调(stdout 与 stderr 并发、按到达顺序实时输出)。
/// 返回是否成功。
pub fn run_streaming<F>(exe: &str, args: &[String], on_line: F) -> std::io::Result<bool>
where
F: FnMut(bool, &str),
{
let rp = RunningProcess::new();
run_streaming_cancellable(&rp, exe, args, on_line)
}
/// 可取消的流式执行:stdout/stderr 并发读取,主线程按行回调;
/// 取消置位时终止子进程并结束。
pub fn run_streaming_cancellable<F>(
rp: &RunningProcess,
exe: &str,
args: &[String],
mut on_line: F,
) -> std::io::Result<bool>
where
F: FnMut(bool, &str),
{
@@ -71,19 +160,170 @@ where
}
let mut child = cmd.spawn()?;
if let Some(stdout) = child.stdout.take() {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
on_line(false, &line);
}
let stdout = child.stdout.take();
let stderr = child.stderr.take();
*rp.child.lock().unwrap() = Some(child);
let (tx, rx) = mpsc::channel::<(bool, String)>();
// stdout 读取线程
if let Some(out) = stdout {
let tx = tx.clone();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(out);
for line in reader.lines().map_while(Result::ok) {
if tx.send((false, line)).is_err() {
break;
}
}
});
}
if let Some(stderr) = child.stderr.take() {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
on_line(true, &line);
// stderr 读取线程
if let Some(err) = stderr {
let tx = tx.clone();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(err);
for line in reader.lines().map_while(Result::ok) {
if tx.send((true, line)).is_err() {
break;
}
}
});
}
drop(tx); // 主线程持有的发送端关闭,两线程结束后通道自然关闭
// 主线程按到达顺序实时回调(recv_timeout 兜底:静默期也能响应取消)
loop {
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
Ok((is_err, line)) => {
on_line(is_err, &line);
if rp.is_cancelled() {
rp.kill();
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if rp.is_cancelled() {
rp.kill();
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let status = child.wait()?;
// 等待子进程退出并取状态
let status = rp
.child
.lock()
.unwrap()
.take()
.and_then(|mut c| c.wait().ok())
.map(|s| s.success())
.unwrap_or(false);
Ok(status)
}
/// 终止子进程(Windows 用 taskkill /T 杀掉整棵进程树,避免 .cmd 包装的 node 等子进程变孤儿)。
fn kill_child_tree(child: &mut Child) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let pid = child.id();
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.creation_flags(0x0800_0000)
.status();
let _ = child.kill();
let _ = child.wait();
}
#[cfg(not(windows))]
{
let _ = child.kill();
let _ = child.wait();
}
}
/// 在**独立控制台窗口**中运行命令(一次性本机终端,PRD FR-06 设计),
/// 结束后窗口自动关闭。仅回传退出成功与否,不回传输出。
/// 用于必须在终端里交互的登录流程(local_tui 等)。
#[cfg(windows)]
pub fn run_terminal(exe: &str, args: &[String]) -> std::io::Result<bool> {
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(exe);
cmd.args(args);
cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE
let status = cmd.status()?;
Ok(status.success())
}
/// 非 Windows 平台:终端窗口回退为普通前台执行。
#[cfg(not(windows))]
pub fn run_terminal(exe: &str, args: &[String]) -> std::io::Result<bool> {
let status = Command::new(exe).args(args).status()?;
Ok(status.success())
}
/// 打开本机默认程序/浏览器(文件用默认处理器打开、URL 用默认浏览器打开)。
/// 仅接受已通过来源白名单校验的目标,不做 shell 拼接。
#[cfg(windows)]
pub fn open_with_shell(target: &str) -> std::io::Result<()> {
// explorer.exe 同时能打开文件(默认处理器)与 URL(默认浏览器)
let status = Command::new("explorer.exe").arg(target).status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"explorer 打开目标失败",
))
}
}
#[cfg(not(windows))]
pub fn open_with_shell(target: &str) -> std::io::Result<()> {
let status = Command::new("xdg-open").arg(target).status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(std::io::ErrorKind::Other, "xdg-open 打开目标失败"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_streaming_reports_stdout_in_order() {
// 用 echo 输出多行,验证 stdout 被逐行实时捕获
#[cfg(windows)]
let (exe, args) = (
"cmd.exe".to_string(),
vec!["/C".to_string(), "echo line1 & echo line2 & echo line3".to_string()],
);
#[cfg(not(windows))]
let (exe, args) = ("sh".to_string(), vec!["-c".to_string(), "echo line1; echo line2; echo line3".to_string()]);
let mut lines = Vec::new();
let ok = run_streaming(&exe, &args, |is_err, l| {
if !is_err {
lines.push(l.to_string());
}
})
.unwrap();
assert!(ok);
assert!(lines.iter().any(|l| l.contains("line1")));
assert!(lines.iter().any(|l| l.contains("line3")));
}
#[test]
fn running_process_cancel_is_idempotent() {
let rp = RunningProcess::new();
assert!(!rp.is_cancelled());
rp.request_cancel();
assert!(rp.is_cancelled());
rp.request_cancel();
assert!(rp.is_cancelled());
}
}
@@ -0,0 +1,205 @@
//! 本机环境运行时的一键安装来源表(Wave 2.2 Req 3
//!
//! 仅收录官方渠道,所有下载 URL 都经 `allowed_hosts` 白名单校验(架构 §3.1 安全红线)。
//! 只做「下载官方安装包 → 打开安装向导」,不静默安装;应用本身不提权。
//! 下载失败或来源不可直接安装时,兜底提供「打开官方下载页」。
use serde::{Deserialize, Serialize};
/// 单个运行时的官方安装来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSource {
/// 运行时 idnode / python / git / winget / uv
pub runtime: String,
/// 中文名(Node.js / Python / Git / winget / uv
pub label_zh: String,
/// 官方直接安装包/脚本地址(可被直接下载)
pub download_url: Option<String>,
/// 官方下载页(兜底打开)
pub download_page: String,
/// 大概体积(人话)
pub size_approx: String,
/// 是否需要管理员权限
pub elevate_needed: bool,
/// 允许的下载主机(白名单)
pub allowed_hosts: Vec<String>,
/// 是否可直接下载安装包(false 则只提供「打开官方下载页」)
pub direct_installable: bool,
/// 来源说明(展示给用户)
pub source_label: String,
}
/// 内置运行时来源表(仅官方渠道)。
fn table() -> Vec<RuntimeSource> {
vec![
RuntimeSource {
runtime: "node".into(),
label_zh: "Node.js".into(),
download_url: Some("https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi".into()),
download_page: "https://nodejs.org/en/download".into(),
size_approx: "约 31 MB".into(),
elevate_needed: true,
allowed_hosts: vec!["nodejs.org".into()],
direct_installable: true,
source_label: "Node.js 官方(nodejs.org".into(),
},
RuntimeSource {
runtime: "python".into(),
label_zh: "Python".into(),
download_url: Some("https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe".into()),
download_page: "https://www.python.org/downloads/".into(),
size_approx: "约 27 MB".into(),
elevate_needed: false,
allowed_hosts: vec!["python.org".into(), "www.python.org".into()],
direct_installable: true,
source_label: "Python 官方(python.org".into(),
},
RuntimeSource {
runtime: "git".into(),
label_zh: "Git".into(),
download_url: Some(
"https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1.2-64-bit.exe".into(),
),
download_page: "https://git-scm.com/download/win".into(),
size_approx: "约 64 MB".into(),
elevate_needed: false,
allowed_hosts: vec!["github.com".into(), "git-scm.com".into()],
direct_installable: true,
source_label: "Git for Windows 官方(git-scm.com".into(),
},
RuntimeSource {
runtime: "winget".into(),
label_zh: "winget".into(),
download_url: Some("https://aka.ms/getwinget".into()),
download_page: "https://learn.microsoft.com/windows/package-manager/winget/".into(),
size_approx: "约 60 MBApp 安装程序)".into(),
elevate_needed: false,
allowed_hosts: vec!["aka.ms".into(), "microsoft.com".into(), "learn.microsoft.com".into()],
direct_installable: true,
source_label: "微软官方(aka.ms/getwinget".into(),
},
RuntimeSource {
runtime: "uv".into(),
label_zh: "uv".into(),
download_url: None,
download_page: "https://docs.astral.sh/uv/getting-started/installation/".into(),
size_approx: "约 15 MB".into(),
elevate_needed: false,
allowed_hosts: vec!["astral.sh".into(), "docs.astral.sh".into()],
direct_installable: false,
source_label: "uv 官方(docs.astral.sh".into(),
},
]
}
/// 按 id 取运行时来源。
pub fn source_for(runtime: &str) -> Option<RuntimeSource> {
table().into_iter().find(|s| s.runtime == runtime)
}
/// 全部受支持的运行时 id(前端「可一键安装」判定用)。
pub fn supported_runtimes() -> Vec<String> {
table().into_iter().map(|s| s.runtime).collect()
}
/// 判断 URL 的主机是否在白名单内(安全红线:所有下载仅限白名单)。
pub fn is_url_host_allowed(url: &str, allowed_hosts: &[String]) -> bool {
let host = extract_host(url);
match host {
Some(h) => allowed_hosts.iter().any(|a| h == *a || h.ends_with(&format!(".{a}"))),
None => false,
}
}
/// 用系统 `curl.exe` 下载 URL 到目标文件(HTTPS、跟随重定向、失败即报错)。
/// 仅应配合 `is_url_host_allowed` 白名单校验后使用。
pub fn download_with_curl(url: &str, dest: &std::path::Path) -> std::io::Result<()> {
let mut cmd = std::process::Command::new("curl.exe");
cmd.args(["-L", "--fail", "--silent", "--show-error", "-o"]);
cmd.arg(dest);
cmd.arg(url);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"curl 下载失败(网络错误或来源不可用)",
))
}
}
/// 极简 URL 主机提取(仅用于白名单比对,不解析完整 URL)。
fn extract_host(url: &str) -> Option<String> {
let s = url.trim();
let s = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://"))?;
let host = s.split(['/', '?', '#']).next()?;
let host = host.rsplit('@').next()?; // 剔除可能的 userinfo
let host = host.trim().trim_matches('.');
let host = if let Some((h, _)) = host.split_once(':') { h } else { host };
if host.is_empty() {
None
} else {
Some(host.to_ascii_lowercase())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sources_have_allowed_hosts_and_pages() {
for s in table() {
assert!(!s.download_page.is_empty());
assert!(!s.allowed_hosts.is_empty(), "{} 应有白名单", s.runtime);
}
}
#[test]
fn node_source_is_installable() {
let node = source_for("node").unwrap();
assert!(node.direct_installable);
assert_eq!(node.label_zh, "Node.js");
}
#[test]
fn uv_falls_back_to_page() {
let uv = source_for("uv").unwrap();
assert!(!uv.direct_installable);
assert!(uv.download_url.is_none());
}
#[test]
fn host_allowlist_matches_exact_and_subdomain() {
assert!(is_url_host_allowed(
"https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi",
&["nodejs.org".to_string()]
));
assert!(is_url_host_allowed(
"https://www.python.org/ftp/python/3.12.10/python.exe",
&["python.org".to_string()]
));
assert!(!is_url_host_allowed(
"https://evil.example.com/node.msi",
&["nodejs.org".to_string()]
));
}
#[test]
fn host_allowlist_rejects_non_https_host() {
assert!(!is_url_host_allowed("https://nodejs.org.evil.com/x.msi", &["nodejs.org".to_string()]));
}
#[test]
fn extract_host_handles_port_and_path() {
assert_eq!(extract_host("https://a.com:8443/x"), Some("a.com".into()));
assert_eq!(extract_host("https://a.com/x?y=1"), Some("a.com".into()));
assert_eq!(extract_host("not a url"), None);
}
}
+119
View File
@@ -163,6 +163,125 @@ impl ActionEvent {
pub fn error(msg: impl Into<String>) -> Self {
ActionEvent { kind: "error".into(), message: msg.into(), phase: None, data: None }
}
/// 携带结构化错误提示(人话化 + code + 缺失运行时)的错误事件。
pub fn error_hint(hint: &ErrorHint) -> Self {
ActionEvent {
kind: "error".into(),
message: hint.friendly_zh.clone(),
phase: None,
data: Some(serde_json::to_value(hint).unwrap_or(serde_json::Value::Null)),
}
}
}
/// 安装/执行失败的人话化错误提示(errors_zh 映射产出,随 error 事件的 data 回传)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorHint {
/// program_not_found | permission_denied | network_timeout | network_dns | disk_full | exec_failed
pub code: String,
/// 人话版中文建议(默认展示)
pub friendly_zh: String,
/// 原始错误文本(折叠保留)
pub raw: String,
/// 缺失的运行时 id(如 node / npm / python / git / winget / uv),供界面一键直达安装
pub missing_runtime: Option<String>,
}
/// 授权流程事件(authorize 流式回传)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthFlowEvent {
pub cli_id: String,
pub mode: String,
/// started | line | device_code | waiting | done | error | cancelled
pub kind: String,
/// 已脱敏的说明文本 / 输出行
pub message: String,
/// 设备码(device_code 模式解析出的 user_code
pub user_code: Option<String>,
/// 验证链接(device_code 模式解析出的 verification_url
pub verification_url: Option<String>,
/// 终态时是否已授权(done 事件)
pub authorized: Option<bool>,
}
impl AuthFlowEvent {
pub fn started(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "started".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn line(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "line".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn device_code(cli_id: &str, mode: &str, user_code: String, verification_url: String) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "device_code".into(),
message: "请在浏览器打开验证链接并输入设备码".into(),
user_code: Some(user_code),
verification_url: Some(verification_url),
authorized: None,
}
}
pub fn waiting(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "waiting".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn done(cli_id: &str, mode: &str, authorized: bool) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "done".into(),
message: if authorized { "授权完成".into() } else { "授权流程结束,未检测到登录".into() },
user_code: None,
verification_url: None,
authorized: Some(authorized),
}
}
pub fn error(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "error".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn cancelled(cli_id: &str, mode: &str) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "cancelled".into(),
message: "已取消授权流程".into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
}
/// 当前 Unix 秒。