diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a8eb9b..61490fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This file records user-facing changes for Terminal. Unless noted otherwise, vers ## [Unreleased] +- **服务器终端输入与路径补全修复**。`cd ` 一类路径补全不再被 Fig 语料里的隐藏特殊参数(`-`/`~`)抢占——此前菜单只剩这两项,Tab 会把 `cd -` 写进输入行,原生路径补全永远到不了 shell;现在隐藏建议不进候选,本地没有候选时 Tab 原样透传。多候选时 Tab 只补公共前缀,方向键或鼠标明确选中后才接受该项,不再默认替用户填第一项。修复缓存命中后候选编辑范围停留在旧行尾的问题(`git checkout m` 续打成 `ma` 接受 `main` 不再得到 `maina`),以及异步/Worker 形态的补全引擎不调度动态候选的问题。SSH 目标的补全查询现在在终端当前目录执行(此前非空 cwd 一律被拒),切目录只作用于该次查询,不改变交互式会话。Ctrl+W 的行模型对齐 bash readline,保留被删词前的分隔空白(`cd /opt/` → `cd `);原生 Tab、方向键等 shell 自有行改写之后,自动建议与 ghost 暂停到新的一行开始。 + **Server terminal input and path completion fixes.** Path completion such as `cd ` is no longer hijacked by the hidden special arguments in the Fig corpus (`-`/`~`): the menu used to contain only those two, so Tab wrote `cd -` into the input line and native path completion never reached the shell — hidden suggestions are excluded now, and Tab passes straight through when there is no local candidate. With several candidates, Tab only extends the shared prefix; arrow keys or an explicit pointer selection decide which item is accepted instead of silently filling the first one. Also fixed: a cached generator result kept the previous line's edit range (`git checkout m` extended to `ma`, accepting `main`, used to produce `maina`), and asynchronous/Worker-shaped completion engines never scheduled dynamic candidates. Completion queries for SSH targets now run in the terminal's current directory (a non-empty cwd used to be rejected outright), and the directory switch is scoped to that one query without touching the interactive session. The Ctrl+W line model now matches bash readline by keeping the separator before the deleted word (`cd /opt/` → `cd `), and auto-suggestions and ghost text pause after shell-owned line rewrites such as native Tab or arrow keys until a new line starts. + - **定时任务支持本地 Shell 目标与脚本文件**。调度任务编辑器新增两处能力:任务连接可选「本地 Shell」(新连接类型 `io.dbx.ssh.local`——表单只有连接名与可选 Shell 绝对路径,无网络端点、无凭据),让定时/常驻命令直接运行在 DBX 所在机器上(进程级应用环境变量与工作目录,流式日志、取消、超时与 SSH 同契约;取消/超时按进程组 TERM→SIGKILL 清理,本地路径忽略 PTY 选项);两个触发器均新增可选「脚本文件」字段(带本机文件选择器),与「命令」互斥、至少填一个——SSH 连接把脚本上传到远端 `mktemp` 临时文件以 bash(无则 sh)执行、run 结束必删临时文件,本地 Shell 直接执行该文件。本地化覆盖全部既有语言。 **Scheduled tasks gain a local shell target and script files.** The task editor adds two capabilities: task connections may bind a "Local shell" target (new connection type `io.dbx.ssh.local` — the form carries only a display name and an optional absolute shell path, no network endpoint and no credentials) so scheduled and resident commands run on the machine running DBX itself (environment and working directory apply at the process level; streaming logs, cancel and timeout follow the same contract as SSH, with cancel/timeout tearing down the whole process group TERM→SIGKILL; the PTY field is ignored on local targets); and both triggers gain an optional "Script file" field (with a native file picker), mutually exclusive with "Command" and at least one required — SSH targets upload the script to a remote `mktemp` temp file, run it with bash (falling back to sh) and always remove the temp file afterwards, while local-shell targets execute the file directly. Localizations cover every shipped language. diff --git a/backend/src/completion/security.rs b/backend/src/completion/security.rs index ca7a250d..67c16eeb 100644 --- a/backend/src/completion/security.rs +++ b/backend/src/completion/security.rs @@ -70,6 +70,30 @@ pub fn build_remote_command_line(command: &str, args: &[String]) -> String { line } +/// SSH 目标接受的 cwd:绝对 POSIX 路径。`~`、相对路径、Windows 盘符的展开 +/// 规则由远端 Shell 决定,本轮不猜——明确定界而不是静默在错误目录执行。 +pub fn validate_ssh_cwd(cwd: &str) -> Result<(), String> { + if cwd.contains('\0') { + return Err("completion: invalid cwd".to_string()); + } + if !cwd.starts_with('/') { + return Err("completion: cwd must be an absolute POSIX path".to_string()); + } + Ok(()) +} + +/// 「切到 cwd 成功后才执行」的远端命令行:`cd` 走 +/// [`crate::exec::shell_quote`] 按字面量转义(空格/单引号/中文原样保留), +/// `&&` 短路保证目录不可用时 generator 不执行;只抑制 `cd` 自身 stderr—— +/// exec 通道是 stdout+stderr 合并流,路径报错文本否则会被当作候选。 +pub fn build_command_line_in_cwd(command_line: &str, cwd: &str) -> String { + format!( + "cd {} 2>/dev/null && {}", + crate::exec::shell_quote(cwd), + command_line + ) +} + /// 按 `cap` 字节截断 UTF-8 文本(回退到字符边界),返回 `(截断后文本, /// 是否截断)`。SSH 路径拿到的是 exec 合并流 String,上限在 completion /// 层统一施加。 @@ -235,4 +259,36 @@ mod tests { let (cut, _) = truncate_utf8("éz", 1); assert_eq!(cut, ""); } + + #[test] + fn ssh_cwd_accepts_only_absolute_posix_paths() { + const NOT_ABSOLUTE: &str = "completion: cwd must be an absolute POSIX path"; + assert_eq!(validate_ssh_cwd("/home/me/src"), Ok(())); + assert_eq!(validate_ssh_cwd("/"), Ok(())); + assert_eq!(validate_ssh_cwd("src").unwrap_err(), NOT_ABSOLUTE); + assert_eq!(validate_ssh_cwd("./src").unwrap_err(), NOT_ABSOLUTE); + assert_eq!(validate_ssh_cwd("~/src").unwrap_err(), NOT_ABSOLUTE); + assert_eq!(validate_ssh_cwd("C:\\work").unwrap_err(), NOT_ABSOLUTE); + assert_eq!( + validate_ssh_cwd("/tmp/a\0b").unwrap_err(), + "completion: invalid cwd" + ); + } + + #[test] + fn cwd_prefix_quotes_path_and_short_circuits_on_failure() { + let line = build_command_line_in_cwd("git 'branch' '--list'", "/home/me/src"); + assert_eq!( + line, + "cd '/home/me/src' 2>/dev/null && git 'branch' '--list'" + ); + // 只抑制 cd 自身的 stderr:命令本身可诊断。 + assert!(line.contains("2>/dev/null && ")); + } + + #[test] + fn cwd_prefix_keeps_spaces_quotes_and_unicode_verbatim() { + let line = build_command_line_in_cwd("ls", "/tmp/a b'c/目录"); + assert_eq!(line, "cd '/tmp/a b'\\''c/目录' 2>/dev/null && ls"); + } } diff --git a/backend/src/completion/ssh.rs b/backend/src/completion/ssh.rs index 1bf07955..ee27b68c 100644 --- a/backend/src/completion/ssh.rs +++ b/backend/src/completion/ssh.rs @@ -29,13 +29,19 @@ pub async fn execute( { return Err("completion: disabled by the read-only connection setting".to_string()); } - // wave-1 未定义远端 cwd 语义:显式报错而不是悄悄在错误目录执行 - //(git 类 generator 对目录敏感,静默忽略会产生错误结果)。 - if req.cwd.as_deref().is_some_and(|cwd| !cwd.is_empty()) { - return Err("completion: cwd is not supported for ssh targets in wave 1".to_string()); - } + // 远端 cwd(前端跟踪的 OSC 7/633 目录):在本次独立 exec 通道内先切目录 + // 再执行 generator,绝不向交互式 PTY 注入 `cd`——用户当前输入行、Shell + // 历史与会话状态都不受影响。只接受绝对 POSIX 路径:`~`/相对路径/Windows + // 盘符的展开规则依赖远端 Shell,本轮明确报错让前端安全回落,而不是猜。 let exec_id = format!("completion-{}", uuid::Uuid::new_v4()); - let command_line = security::build_remote_command_line(&req.command, &req.args); + let base_line = security::build_remote_command_line(&req.command, &req.args); + let command_line = match req.cwd.as_deref().filter(|cwd| !cwd.is_empty()) { + Some(cwd) => { + security::validate_ssh_cwd(cwd)?; + security::build_command_line_in_cwd(&base_line, cwd) + } + None => base_line, + }; let exec = ssh.exec(session_id, Some(&exec_id), &command_line, false, None); match tokio::time::timeout(Duration::from_millis(req.timeout_ms), exec).await { Ok(Ok(value)) => map_exec_response(&value, req.max_output_bytes), diff --git a/docs/PROTOCOL.zh-CN.md b/docs/PROTOCOL.zh-CN.md index 35af38b9..d7152f24 100644 --- a/docs/PROTOCOL.zh-CN.md +++ b/docs/PROTOCOL.zh-CN.md @@ -1378,7 +1378,7 @@ FIG 补全引擎的专用执行通道(wave-1 lane B):把一条 generator - `target`:`{ kind: "local" | "ssh", sessionId }`(internally tagged)。local 的 `sessionId` 标识发起补全的本地终端会话(wave-1 仅透传);ssh 的 `sessionId` 是既有 SSH 会话 id。 - `command`:generator 程序名(非空、不含 NUL)。 - `args`:参数数组(≤32 个、每个不含 NUL)。 -- `cwd`(可选):工作目录。local target 生效(子进程 `current_dir`);**ssh target wave-1 不支持,非空即报 `completion: cwd is not supported for ssh targets in wave 1`**(远端 cwd 语义留 wave 2 定义,显式失败优于静默在错误目录执行)。 +- `cwd`(可选):工作目录。local target 生效(子进程 `current_dir`);ssh target 仅接受**绝对 POSIX 路径**:在该次独立 exec 通道内先 `cd '' 2>/dev/null &&` 再执行 generator(`&&` 短路:目录不可用时命令不执行;只抑制 `cd` 自身 stderr,避免把路径报错文本当作候选)。`~`/相对路径/Windows 盘符一律报 `completion: cwd must be an absolute POSIX path`(展开规则属远端 Shell,本轮不猜),含 NUL 报 `completion: invalid cwd`。切目录只作用于本次 exec,不向交互式 PTY 注入 `cd`,用户输入行、Shell 历史与会话状态都不受影响。缺省/null/空串保持原有命令拼装语义。 - `timeoutMs`:completion 层超时,clamp 到 [200, 3000],缺省(0/缺字段)1200。 - `maxOutputBytes`:单流输出上限,stdout 与 stderr **各自**截断;缺省(0)或超过 256 KiB 时取 256 KiB。 - `mode`:必须为 `"completion-generator"`(防止普通 RPC 复用本通道)。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 17fd36c0..e82bd8ee 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1000,6 +1000,7 @@ const { completionOpen, completionItems, completionActiveIndex, + activateCompletion, completionAnchor, completionLoading, closeSuggestions, @@ -9583,7 +9584,7 @@ watch(historyScope, () => { :viewport="suggestionViewport" :loading="completionLoading" :t="t" - @activate="(index) => (completionActiveIndex = index)" + @activate="activateCompletion" @accept="acceptCompletionRow" />