Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
56 changes: 56 additions & 0 deletions backend/src/completion/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// 层统一施加。
Expand Down Expand Up @@ -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");
}
}
18 changes: 12 additions & 6 deletions backend/src/completion/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion docs/PROTOCOL.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<cwd>' 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 复用本通道)。
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,7 @@ const {
completionOpen,
completionItems,
completionActiveIndex,
activateCompletion,
completionAnchor,
completionLoading,
closeSuggestions,
Expand Down Expand Up @@ -9583,7 +9584,7 @@ watch(historyScope, () => {
:viewport="suggestionViewport"
:loading="completionLoading"
:t="t"
@activate="(index) => (completionActiveIndex = index)"
@activate="activateCompletion"
@accept="acceptCompletionRow"
/>
<!-- 终端行内 ghost 自动建议(对标 Warp/fish):灰色剩余文本盖在光标右侧,
Expand Down
221 changes: 221 additions & 0 deletions frontend/src/composables/useCommandSuggestions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
// 结构化补全菜单的按键归属(T1 / §4.1):本文件不验证「候选数组长什么样」,
// 而是驱动真实 fig 语料 + 真实 composable 键盘处理器,观察最终写入终端的
// 字节与行缓冲——菜单显示 ≠ 键盘所有权,验收只看输入行变成了什么。
import { describe, expect, it } from "vitest";
import { createApp, defineComponent, h, ref } from "vue";
import { useCommandSuggestions } from "./useCommandSuggestions";

interface Harness {
pending: { value: string };
written: string[];
completionOpen: { value: boolean };
completionItems: { value: Array<{ id: string; label: string; kind: string; edit: unknown }> };
completionLoading: { value: boolean };
activationIndex: { value: number };
openManually: () => void;
key: (key: string) => boolean;
setTrusted: (trusted: boolean) => void;
setLine: (line: string) => void;
}

function harness(): Harness {
const pending = { value: "" };
const written: string[] = [];
let trusted = true;
const commandRunning = ref(false);
const terminal = { focus: () => undefined, buffer: { active: { type: "normal" as const } } };
let api!: ReturnType<typeof useCommandSuggestions>;
const app = createApp(
defineComponent({
setup() {
api = useCommandSuggestions({
terminal: () => terminal as never,
getTerminalHost: () => null,
sendTerminalBytes: (bytes: Uint8Array) => written.push(new TextDecoder().decode(bytes)),
getPendingTerminalInput: () => pending.value,
isInputLineTrusted: () => trusted,
setPendingTerminalInput: (value: string) => {
pending.value = value;
},
commandRunning,
isTerminalTransferBusy: () => false,
commandHistory: ref([]),
quickCommands: ref([]),
suggestionsEnabledState: ref(true),
suggestionMinCharsState: ref(1),
suggestionMaxCharsState: ref(200),
terminalCwd: ref(""),
session: ref(undefined),
getSessionId: () => "session-a",
getLocalSessionId: () => undefined,
persistCommandHistory: () => undefined,
isHistoryPanelOpen: () => false,
syncHistoryPanelAnchor: () => undefined,
updateHistoryPanelFilter: () => undefined,
pushTerminalCommandHistory: () => undefined,
inlineGhostActive: () => false,
isInteractivePromptPending: () => false,
});
return () => h("div");
},
}),
);
app.mount(document.createElement("div"));
return {
pending,
written,
completionOpen: api.completionOpen as unknown as Harness["completionOpen"],
completionItems: api.completionItems as unknown as Harness["completionItems"],
completionLoading: api.completionLoading as unknown as Harness["completionLoading"],
activationIndex: api.completionActiveIndex as unknown as Harness["activationIndex"],
openManually: api.openCompletionsManually,
key: (key: string) => api.handleCompletionKey({ type: "keydown", key } as KeyboardEvent),
setTrusted: (next: boolean) => {
trusted = next;
},
setLine: (line: string) => {
pending.value = line;
},
};
}

describe("completion menu key ownership(字节级)", () => {
// A01:cd 的本地候选面为空(fig 语料的 `-`/`~` 是 hidden 建议、目录生成器
// 是 custom 回调),Tab 必须原样交给 shell——此前会写入 `\u0015cd -`。
it("cd + Tab:无本地候选时不写任何字节,Tab 透传", () => {
const h = harness();
h.setLine("cd ");
h.openManually();

expect(h.completionOpen.value).toBe(false);
const consumed = h.key("Tab");

expect(consumed).toBe(false);
expect(h.written).toEqual([]);
expect(h.pending.value).toBe("cd ");
});

it("cd pro + Tab:路径前缀同样原样透传", () => {
const h = harness();
h.setLine("cd pro");
h.openManually();

expect(h.key("Tab")).toBe(false);
expect(h.written).toEqual([]);
expect(h.pending.value).toBe("cd pro");
});

// A02:相对/绝对/带空格与中文的路径都必须逐字透传,不能被本地候选改写。
it.each(["cd ./", "cd ../src", "cd /usr/local", "cd 我的 目录", "cd 'a b'"])("%s + Tab:原样透传", (line) => {
const h = harness();
h.setLine(line);
h.openManually();

expect(h.key("Tab")).toBe(false);
expect(h.written).toEqual([]);
expect(h.pending.value).toBe(line);
});

// A04:多候选、用户还没选 → 只推进公共前缀;到公共边界后既不填第一项,
// 也不改行,把选择权留给方向键/点击。
it("多候选未选择 + Tab:只推进公共前缀,到边界后不再改动输入", () => {
const h = harness();
h.setLine("git ch");
h.openManually();
expect(h.completionOpen.value).toBe(true);
expect(h.completionItems.value.length).toBeGreaterThan(1);
const labels = h.completionItems.value.map((item) => item.label);
expect(labels).toContain("checkout");
expect(labels.every((label) => label.startsWith("ch"))).toBe(true);

// 公共前缀 "che" 比当前 token "ch" 更长 → 只填前缀,菜单保持。
expect(h.key("Tab")).toBe(true);
expect(h.written).toEqual(["\u0015git che"]);
expect(h.pending.value).toBe("git che");
expect(h.completionOpen.value).toBe(true);

// 已在公共前缀边界:再按 Tab 不得填空、不得填第一项、不得关闭菜单。
expect(h.key("Tab")).toBe(true);
expect(h.written).toEqual(["\u0015git che"]);
expect(h.pending.value).toBe("git che");
expect(h.completionOpen.value).toBe(true);
});

// A05:方向键是「明确选择」,之后 Tab 接受所选项(而不是公共前缀)。
it("方向键选中后 Tab:接受所选项并写入整行", () => {
const h = harness();
h.setLine("git ch");
h.openManually();
const items = h.completionItems.value;

h.key("ArrowDown");
const selected = items[h.activationIndex.value];
expect(h.activationIndex.value).toBe(1);
expect(selected.label).not.toBe(items[0].label);

expect(h.key("Tab")).toBe(true);

expect(h.pending.value).toBe(`git ${selected.label} `);
expect(h.written).toEqual([`\u0015git ${selected.label} `]);
// 接受候选不得附带回车。
expect(h.written.join("")).not.toContain("\r");
});

it("唯一候选 + Tab:直接填充(单候选才算唯一)", () => {
const h = harness();
h.setLine("git ch");
h.openManually();
expect(h.completionOpen.value).toBe(true);
const only = h.completionItems.value[0];
h.completionItems.value = [only];

expect(h.key("Tab")).toBe(true);

expect(h.pending.value).toBe(`git ${only.label} `);
expect(h.written).toEqual([`\u0015git ${only.label} `]);
});

// A17:模型不可信(原生 Tab/中间编辑后)时不拿旧前缀改写输入行。
it("输入模型不可信 + Tab:不替换本地候选,Tab 交还 shell", () => {
const h = harness();
h.setLine("git ch");
h.openManually();
expect(h.completionOpen.value).toBe(true);
h.setTrusted(false);

expect(h.key("Tab")).toBe(false);

expect(h.written).toEqual([]);
expect(h.pending.value).toBe("git ch");
expect(h.completionOpen.value).toBe(false);
});

it("行已变化(菜单过期)+ Tab:不回填旧行", () => {
const h = harness();
h.setLine("git ch");
h.openManually();
expect(h.completionOpen.value).toBe(true);
h.setLine("git ch --");

expect(h.key("Tab")).toBe(false);

expect(h.written).toEqual([]);
expect(h.pending.value).toBe("git ch --");
});

it("Escape 关闭菜单不改行;Enter 透传(执行当前行)", () => {
const h = harness();
h.setLine("git ch");
h.openManually();

expect(h.key("Escape")).toBe(true);
expect(h.completionOpen.value).toBe(false);
expect(h.written).toEqual([]);

h.openManually();
expect(h.key("Enter")).toBe(false);
expect(h.written).toEqual([]);
expect(h.pending.value).toBe("git ch");
});
});
Loading
Loading