From 8878f0a71e0201b0d2f69d69ddf725d5684ea50a Mon Sep 17 00:00:00 2001 From: lly Date: Wed, 2 Sep 2026 16:33:58 +0800 Subject: [PATCH 1/3] feat(terminal): restore last working directory on SSH reconnect --- src-tauri/src/config/settings/terminal.rs | 15 +++ src/App.tsx | 45 +++++++- src/components/settings/TerminalTab.tsx | 14 +++ src/components/terminal/XTerminal.tsx | 36 ++++++- src/context/AppProvider.tsx | 1 + src/context/ChildAppProvider.tsx | 1 + src/i18n/locales/en.json | 2 + src/i18n/locales/ko.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/lib/terminalSessionCwd.test.ts | 125 ++++++++++++++++++++++ src/lib/terminalSessionCwd.ts | 69 ++++++++++++ src/types/global.d.ts | 1 + 13 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 src/lib/terminalSessionCwd.test.ts create mode 100644 src/lib/terminalSessionCwd.ts diff --git a/src-tauri/src/config/settings/terminal.rs b/src-tauri/src/config/settings/terminal.rs index 686aa44e..a800db68 100644 --- a/src-tauri/src/config/settings/terminal.rs +++ b/src-tauri/src/config/settings/terminal.rs @@ -90,6 +90,8 @@ pub struct TerminalSettings { pub show_multi_line_paste_dialog: bool, #[serde(default = "default_true")] pub paste_image_as_path: bool, + #[serde(default = "default_true")] + pub reconnect_restore_cwd: bool, } fn default_scrollback() -> u32 { @@ -156,6 +158,7 @@ impl Default for TerminalSettings { timestamp_format: default_timestamp_format(), show_multi_line_paste_dialog: true, paste_image_as_path: true, + reconnect_restore_cwd: true, } } } @@ -176,6 +179,18 @@ mod tests { assert_eq!(settings.keep_alive_interval, 60); } + #[test] + fn missing_reconnect_restore_cwd_defaults_to_true() { + let settings: TerminalSettings = serde_json::from_value(serde_json::json!({ + "scrollback_lines": 5000, + "keep_alive_interval": 60 + })) + .expect("legacy terminal settings deserialize"); + + assert!(settings.reconnect_restore_cwd); + assert!(TerminalSettings::default().reconnect_restore_cwd); + } + #[test] fn missing_timestamp_format_defaults_to_seconds() { let settings: TerminalSettings = diff --git a/src/App.tsx b/src/App.tsx index c2c059fa..e2bd74c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,10 @@ import { type StartupCommandRequest, sendStartupCommandToSession, } from "./lib/appSessionFactory"; +import { + buildReconnectCwdStartupCommand, + carryOverSessionCwd, +} from "./lib/terminalSessionCwd"; import { buildPanelOpenUpdate, canCreateSessionFromPane, @@ -2455,6 +2459,20 @@ function App() { toast.info(t("tabCtx.fileSessionInUse")); }, [t]); + const buildPaneReconnectCwdStartupCommand = useCallback( + (pane: Pick) => + appSettings.terminal.reconnect_restore_cwd ?? true + ? buildReconnectCwdStartupCommand( + pane.sessionId, + appSettings.interaction.duplicate_session_command_delay_ms, + ) + : undefined, + [ + appSettings.terminal.reconnect_restore_cwd, + appSettings.interaction.duplicate_session_command_delay_ms, + ], + ); + const handleReconnectSession = useCallback( async (tab: Tab) => { const pane = getActivePane(tab); @@ -2473,12 +2491,18 @@ function App() { }).catch(() => {}); } const reconnectContent = capturePaneReconnectContent(pane); + const reconnectCwdStartupCommand = buildPaneReconnectCwdStartupCommand(pane); const closed = await closePaneBackendSession(pane); if (!closed) { throw new Error("close_session_failed"); } - const newSessionId = await createSessionForPane(pane); + const newSessionId = await createSessionForPane( + pane, + undefined, + reconnectCwdStartupCommand, + ); + carryOverSessionCwd(pane.sessionId, newSessionId); if (!hasPane(tab.id, pane.id)) { await closeStaleCreatedSession(newSessionId); return; @@ -2515,6 +2539,7 @@ function App() { } }, [ + buildPaneReconnectCwdStartupCommand, closePaneBackendSession, hasFileDocumentDependency, hasPane, @@ -2566,12 +2591,18 @@ function App() { }).catch(() => {}); } const reconnectContent = capturePaneReconnectContent(pane); + const reconnectCwdStartupCommand = buildPaneReconnectCwdStartupCommand(pane); const closed = await closePaneBackendSession(pane); if (!closed) { throw new Error("close_session_failed"); } - const newSessionId = await createSessionForPane(pane); + const newSessionId = await createSessionForPane( + pane, + undefined, + reconnectCwdStartupCommand, + ); + carryOverSessionCwd(pane.sessionId, newSessionId); if (!hasPane(tab.id, pane.id)) { await closeStaleCreatedSession(newSessionId); return; @@ -2608,6 +2639,7 @@ function App() { } }, [ + buildPaneReconnectCwdStartupCommand, closePaneBackendSession, hasFileDocumentDependency, hasPane, @@ -2738,12 +2770,18 @@ function App() { }).catch(() => {}); } const reconnectContent = capturePaneReconnectContent(pane); + const reconnectCwdStartupCommand = buildPaneReconnectCwdStartupCommand(pane); const closed = await closePaneBackendSession(pane); if (!closed) { throw new Error("close_session_failed"); } - const newSessionId = await createSessionForPane(pane); + const newSessionId = await createSessionForPane( + pane, + undefined, + reconnectCwdStartupCommand, + ); + carryOverSessionCwd(pane.sessionId, newSessionId); if (!hasPane(tabId, paneId)) { await closeStaleCreatedSession(newSessionId); return; @@ -2781,6 +2819,7 @@ function App() { } }, [ + buildPaneReconnectCwdStartupCommand, closePaneBackendSession, hasFileDocumentDependency, hasPane, diff --git a/src/components/settings/TerminalTab.tsx b/src/components/settings/TerminalTab.tsx index 61f8440d..c09037e0 100644 --- a/src/components/settings/TerminalTab.tsx +++ b/src/components/settings/TerminalTab.tsx @@ -240,6 +240,20 @@ export function TerminalTab() { } /> + + + updateAppSettings({ + terminal: { ...appSettings.terminal, reconnect_restore_cwd: v }, + }) + } + /> + + { + const unlisten = listen(`cwd-changed-${sessionId}`, (event) => { + recordSessionCwd(sessionId, event.payload); + }); + return () => { + unlisten.then((fn) => fn()); + }; + }, [sessionId]); + useEffect(() => { return () => { clearHibernateTimer(); @@ -1071,6 +1086,15 @@ export default function XTerminal({ const createReconnectedSession = () => { const connectionId = connectionIdRef.current; const temporaryConfig = temporaryConfigRef.current; + const settingsSnapshot = terminalAppSettingsRef.current; + const restoreCwdStartupCommand = + sessionTypeRef.current === "SSH" && + (settingsSnapshot.terminal.reconnect_restore_cwd ?? true) + ? buildReconnectCwdStartupCommand( + sessionIdRef.current, + settingsSnapshot.interaction.duplicate_session_command_delay_ms, + ) + : undefined; switch (sessionTypeRef.current) { case "Local": @@ -1108,16 +1132,23 @@ export default function XTerminal({ return invoke("create_serial_session", { connectionId }); default: if (connectionId) { - return invoke("create_ssh_session", { connectionId }); + return invoke("create_ssh_session", { + connectionId, + startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), + }); } assertTemporaryConfigMatchesSessionType(); if (temporaryConfig?.protocol === "ssh") { const { protocol: _protocol, ...sshConfig } = temporaryConfig; return invoke("create_temporary_ssh_session", { config: sshConfig, + startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), }); } - return invoke("create_ssh_session", { connectionId }); + return invoke("create_ssh_session", { + connectionId, + startupCommand: buildStartupCommandPayload(restoreCwdStartupCommand), + }); } }; @@ -2075,6 +2106,7 @@ export default function XTerminal({ preservedReconnectContentRef.current = reconnectSnapshot; snapshotRestoreController.begin(reconnectSnapshot); const oldSessionId = sessionIdRef.current; + carryOverSessionCwd(oldSessionId, newSessionId); disconnectedRef.current = false; disconnectedNoticeShownRef.current = false; disconnectedCloseRequestedRef.current = false; diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx index 316c94e0..7e5542a9 100644 --- a/src/context/AppProvider.tsx +++ b/src/context/AppProvider.tsx @@ -151,6 +151,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { timestamp_format: "[HH:mm:ss]", show_multi_line_paste_dialog: true, paste_image_as_path: true, + reconnect_restore_cwd: true, }, interaction: { copy_on_select: false, diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index c44c9572..ac07ad86 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -111,6 +111,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = { timestamp_format: "[HH:mm:ss]", show_multi_line_paste_dialog: true, paste_image_as_path: true, + reconnect_restore_cwd: true, }, interaction: { copy_on_select: false, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 16499c36..678e3195 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2219,6 +2219,8 @@ "rdpCertificateVerifyNew": "New RDP certificate. Verify the fingerprint below.", "rdpCertificateVerifyTitle": "RDP Certificate Verification", "rdpCertificateVerifyWarning": "WARNING: The RDP certificate changed. Accept only if you trust this server.", + "reconnectRestoreCwd": "Restore last working directory on reconnect", + "reconnectRestoreCwdDesc": "After an SSH session reconnects, run cd back to the last known working directory. Requires shell integration (bash/zsh/fish); falls back to the default behavior when no directory was recorded.", "recordingAutoStart": "Auto-record new sessions", "recordingAutoStartDesc": "Automatically start writing a recording file after each session starts. Recording files keep growing until the session ends or recording is stopped manually.", "recordingDefaultMode": "Default Mode", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index a54c7241..74565cae 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2218,6 +2218,8 @@ "rdpCertificateVerifyNew": "새 RDP 인증서입니다. 아래 지문을 확인하세요.", "rdpCertificateVerifyTitle": "RDP 인증서 검증", "rdpCertificateVerifyWarning": "경고: RDP 인증서가 변경되었습니다. 이 서버를 신뢰할 때만 수락하세요.", + "reconnectRestoreCwd": "재연결 시 마지막 작업 디렉터리 복원", + "reconnectRestoreCwdDesc": "SSH 세션이 재연결되면 연결이 끊어지기 전 작업 디렉터리로 cd 합니다. 셸 통합(bash/zsh/fish)이 필요하며 기록이 없으면 기존 동작을 유지합니다.", "recordingAutoStart": "새 세션 자동 녹화", "recordingAutoStartDesc": "각 세션이 시작된 후 자동으로 녹화 파일 작성을 시작합니다. 녹화 파일은 세션이 끝나거나 수동으로 녹화를 중지할 때까지 계속 커집니다.", "recordingDefaultMode": "기본 모드", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3e2ded98..5c414c3f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2218,6 +2218,8 @@ "rdpCertificateVerifyNew": "新的 RDP 证书,请验证以下指纹。", "rdpCertificateVerifyTitle": "RDP 证书验证", "rdpCertificateVerifyWarning": "警告:RDP 证书已更改。请仅在你信任此服务器时接受。", + "reconnectRestoreCwd": "重连后恢复上次工作目录", + "reconnectRestoreCwdDesc": "SSH 会话重连成功后自动 cd 回断开前的工作目录。需要 shell 集成(bash/zsh/fish)支持,无记录时保持原有行为。", "recordingAutoStart": "自动录制新会话", "recordingAutoStartDesc": "每次会话启动成功后自动开始写入录制文件。录制文件会持续增长,直到会话结束或手动停止。", "recordingDefaultMode": "默认模式", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 12863cb1..945b57c4 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2213,6 +2213,8 @@ "rdpCertificateVerifyNew": "新的 RDP 憑證,請驗證以下指紋。", "rdpCertificateVerifyTitle": "RDP 憑證驗證", "rdpCertificateVerifyWarning": "警告:RDP 憑證已更改。請僅在你信任此伺服器時接受。", + "reconnectRestoreCwd": "重連後恢復上次工作目錄", + "reconnectRestoreCwdDesc": "SSH 工作階段重連成功後自動 cd 回中斷前的工作目錄。需要 shell 整合(bash/zsh/fish)支援,無記錄時保持原有行為。", "recordingAutoStart": "自動錄製新工作階段", "recordingAutoStartDesc": "每次工作階段啟動成功後自動開始寫入錄製檔案。錄製檔案會持續增長,直到工作階段結束或手動停止。", "recordingDefaultMode": "預設模式", diff --git a/src/lib/terminalSessionCwd.test.ts b/src/lib/terminalSessionCwd.test.ts new file mode 100644 index 00000000..4061e283 --- /dev/null +++ b/src/lib/terminalSessionCwd.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + buildReconnectCwdCommand, + buildReconnectCwdStartupCommand, + carryOverSessionCwd, + getSessionCwd, + recordSessionCwd, +} from "./terminalSessionCwd"; + +describe("buildReconnectCwdCommand", () => { + it("returns null for an empty string", () => { + expect(buildReconnectCwdCommand("")).toBeNull(); + }); + + it("returns null for a blank string", () => { + expect(buildReconnectCwdCommand(" ")).toBeNull(); + }); + + it("returns null for paths with control characters", () => { + expect(buildReconnectCwdCommand("/tmp/a\nb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\rb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\0b")).toBeNull(); + }); + + it("wraps a plain path in single quotes", () => { + expect(buildReconnectCwdCommand("/home/user")).toBe("cd '/home/user'"); + }); + + it("keeps spaces inside the quotes", () => { + expect(buildReconnectCwdCommand("/home/my dir")).toBe("cd '/home/my dir'"); + }); + + it("escapes single quotes the POSIX way", () => { + expect(buildReconnectCwdCommand("/home/o'brien")).toBe("cd '/home/o'\\''brien'"); + }); + + it("keeps dollar signs as literals", () => { + expect(buildReconnectCwdCommand("/home/a$b")).toBe("cd '/home/a$b'"); + }); + + it("keeps backslashes as literals", () => { + expect(buildReconnectCwdCommand("/home/a\\b")).toBe("cd '/home/a\\b'"); + }); + + it("supports unicode paths", () => { + expect(buildReconnectCwdCommand("/home/用户")).toBe("cd '/home/用户'"); + }); +}); + +describe("recordSessionCwd / getSessionCwd", () => { + it("round-trips a recorded cwd without consuming it", () => { + recordSessionCwd("cwd-roundtrip", "/var/log"); + expect(getSessionCwd("cwd-roundtrip")).toBe("/var/log"); + expect(getSessionCwd("cwd-roundtrip")).toBe("/var/log"); + }); + + it("removes the record when the payload is empty", () => { + recordSessionCwd("cwd-clear", "/var/log"); + recordSessionCwd("cwd-clear", ""); + expect(getSessionCwd("cwd-clear")).toBeNull(); + }); + + it("removes the record when the payload is blank", () => { + recordSessionCwd("cwd-clear-blank", "/var/log"); + recordSessionCwd("cwd-clear-blank", " "); + expect(getSessionCwd("cwd-clear-blank")).toBeNull(); + }); + + it("returns null for an unknown session", () => { + expect(getSessionCwd("cwd-unknown")).toBeNull(); + }); +}); + +describe("carryOverSessionCwd", () => { + it("copies the cwd to a session without a record", () => { + recordSessionCwd("carry-from", "/opt"); + carryOverSessionCwd("carry-from", "carry-to"); + expect(getSessionCwd("carry-to")).toBe("/opt"); + }); + + it("keeps the target session's own cwd", () => { + recordSessionCwd("carry-from-own", "/opt"); + recordSessionCwd("carry-to-own", "/srv"); + carryOverSessionCwd("carry-from-own", "carry-to-own"); + expect(getSessionCwd("carry-to-own")).toBe("/srv"); + }); + + it("does nothing when the source has no record", () => { + carryOverSessionCwd("carry-from-missing", "carry-to-missing"); + expect(getSessionCwd("carry-to-missing")).toBeNull(); + }); + + it("does nothing for identical session ids", () => { + recordSessionCwd("carry-same", "/opt"); + carryOverSessionCwd("carry-same", "carry-same"); + expect(getSessionCwd("carry-same")).toBe("/opt"); + }); +}); + +describe("buildReconnectCwdStartupCommand", () => { + it("returns undefined when no cwd was recorded", () => { + expect(buildReconnectCwdStartupCommand("startup-unknown", 1000)).toBeUndefined(); + }); + + it("returns undefined when the recorded cwd cannot be replayed", () => { + recordSessionCwd("startup-invalid", " "); + expect(buildReconnectCwdStartupCommand("startup-invalid", 1000)).toBeUndefined(); + }); + + it("builds the startup command with the passed delay", () => { + recordSessionCwd("startup-ok", "/opt/app"); + expect(buildReconnectCwdStartupCommand("startup-ok", 1000)).toEqual({ + command: "cd '/opt/app'", + delayMs: 1000, + }); + }); + + it("passes a zero delay through unchanged", () => { + recordSessionCwd("startup-zero-delay", "/opt/app"); + expect(buildReconnectCwdStartupCommand("startup-zero-delay", 0)).toEqual({ + command: "cd '/opt/app'", + delayMs: 0, + }); + }); +}); diff --git a/src/lib/terminalSessionCwd.ts b/src/lib/terminalSessionCwd.ts new file mode 100644 index 00000000..874babe9 --- /dev/null +++ b/src/lib/terminalSessionCwd.ts @@ -0,0 +1,69 @@ +import type { StartupCommandRequest } from "@/lib/appSessionFactory"; + +const store = (() => { + const globalStore = globalThis as typeof globalThis & { + __nyatermTerminalSessionCwd?: Map; + }; + + globalStore.__nyatermTerminalSessionCwd ??= new Map(); + + return globalStore.__nyatermTerminalSessionCwd; +})(); + +/** + * Records the last known working directory reported by shell integration + * (OSC 7) for a session. An empty/blank payload means the backend cleared + * the cwd (shell integration failed), so the record is removed to avoid + * replaying a stale guess on the next reconnect. + */ +export function recordSessionCwd(sessionId: string, cwd: string) { + if (!cwd.trim()) { + store.delete(sessionId); + return; + } + store.set(sessionId, cwd); +} + +/** Non-destructive read of the last known working directory. */ +export function getSessionCwd(sessionId: string) { + return store.get(sessionId) ?? null; +} + +/** + * Carries the last known cwd over to a reconnected session id + * (copy-if-absent: a cwd reported by the new session itself always wins). + */ +export function carryOverSessionCwd(fromSessionId: string, toSessionId: string) { + if (fromSessionId === toSessionId) return; + if (store.has(toSessionId)) return; + const cwd = store.get(fromSessionId); + if (cwd === undefined) return; + store.set(toSessionId, cwd); +} + +/** + * Builds a `cd ''` command for the given cwd. Returns null for + * empty/blank values or paths containing control characters so reconnect + * falls back to the default behavior instead of replaying unsafe input. + */ +export function buildReconnectCwdCommand(cwd: string) { + if (!cwd.trim()) return null; + if (/[\r\n\0]/.test(cwd)) return null; + return `cd '${cwd.replace(/'/g, "'\\''")}'`; +} + +/** + * Combines the stored cwd lookup with command building. Returns null when + * nothing should be restored; delayMs is passed through as-is (clamping is + * handled by buildStartupCommandPayload). + */ +export function buildReconnectCwdStartupCommand( + sessionId: string, + delayMs: number, +): StartupCommandRequest | undefined { + const cwd = getSessionCwd(sessionId); + if (cwd === null) return undefined; + const command = buildReconnectCwdCommand(cwd); + if (command === null) return undefined; + return { command, delayMs }; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 0ede2d54..2ac72a14 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1359,6 +1359,7 @@ export interface TerminalSettings { timestamp_format: string; show_multi_line_paste_dialog: boolean; paste_image_as_path: boolean; + reconnect_restore_cwd: boolean; } export interface TransferSettings { From b97f759f2557acb9f0d282c9f2fbeb943686e036 Mon Sep 17 00:00:00 2001 From: lly Date: Thu, 3 Sep 2026 09:49:31 +0800 Subject: [PATCH 2/3] fix(terminal): harden reconnect cwd restore per review --- src/components/terminal/XTerminal.tsx | 16 +++++++++++++++- src/lib/terminalSessionCwd.test.ts | 14 ++++++++++++++ src/lib/terminalSessionCwd.ts | 10 +++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index d493d66e..be12387a 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -507,11 +507,25 @@ export default function XTerminal({ }, [hibernated, requestWake, sessionId]); useEffect(() => { + let disposed = false; + let cwdEventObserved = false; const unlisten = listen(`cwd-changed-${sessionId}`, (event) => { + cwdEventObserved = true; recordSessionCwd(sessionId, event.payload); }); + void unlisten.then(() => { + if (disposed) return; + void invoke("try_get_terminal_cwd", { sessionId }).then( + (cwd) => { + if (disposed || cwdEventObserved || cwd === null) return; + recordSessionCwd(sessionId, cwd); + }, + () => {}, + ); + }); return () => { - unlisten.then((fn) => fn()); + disposed = true; + void unlisten.then((fn) => fn()); }; }, [sessionId]); diff --git a/src/lib/terminalSessionCwd.test.ts b/src/lib/terminalSessionCwd.test.ts index 4061e283..70ca11b3 100644 --- a/src/lib/terminalSessionCwd.test.ts +++ b/src/lib/terminalSessionCwd.test.ts @@ -22,6 +22,20 @@ describe("buildReconnectCwdCommand", () => { expect(buildReconnectCwdCommand("/tmp/a\0b")).toBeNull(); }); + it("returns null for C0 controls beyond \\r\\n\\0", () => { + expect(buildReconnectCwdCommand("/tmp/\x15touch /tmp/pwn #")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x01b")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x1bb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\tb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x1f b")).toBeNull(); + }); + + it("returns null for DEL and C1 control characters", () => { + expect(buildReconnectCwdCommand("/tmp/a\x7fb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x9fb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a\x85b")).toBeNull(); + }); + it("wraps a plain path in single quotes", () => { expect(buildReconnectCwdCommand("/home/user")).toBe("cd '/home/user'"); }); diff --git a/src/lib/terminalSessionCwd.ts b/src/lib/terminalSessionCwd.ts index 874babe9..2ec384c9 100644 --- a/src/lib/terminalSessionCwd.ts +++ b/src/lib/terminalSessionCwd.ts @@ -41,14 +41,18 @@ export function carryOverSessionCwd(fromSessionId: string, toSessionId: string) store.set(toSessionId, cwd); } +/** C0 controls, DEL, and C1 controls — never replayed as terminal input. */ +const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/; + /** * Builds a `cd ''` command for the given cwd. Returns null for - * empty/blank values or paths containing control characters so reconnect - * falls back to the default behavior instead of replaying unsafe input. + * empty/blank values or paths containing any C0/C1/DEL control characters + * (terminal line editing could otherwise erase the quoted prefix and inject + * commands) so reconnect falls back to the default behavior. */ export function buildReconnectCwdCommand(cwd: string) { if (!cwd.trim()) return null; - if (/[\r\n\0]/.test(cwd)) return null; + if (CONTROL_CHARS.test(cwd)) return null; return `cd '${cwd.replace(/'/g, "'\\''")}'`; } From 0faa75dc0f2a8b3d664b709091a01f3853e90c4c Mon Sep 17 00:00:00 2001 From: lly Date: Thu, 3 Sep 2026 16:36:44 +0800 Subject: [PATCH 3/3] fix(terminal): harden SSH reconnect cwd restoration --- src-tauri/src/core/ssh/bash_persistent.sh | 3 +- src-tauri/src/core/ssh/io.rs | 58 ++++++++++++++++++++--- src-tauri/src/core/ssh/osc.rs | 30 ++++++++++-- src/components/terminal/XTerminal.tsx | 5 +- src/lib/terminalSessionCwd.test.ts | 41 ++++++++++++++++ src/lib/terminalSessionCwd.ts | 22 ++++++--- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/core/ssh/bash_persistent.sh b/src-tauri/src/core/ssh/bash_persistent.sh index ed15f394..2edde819 100644 --- a/src-tauri/src/core/ssh/bash_persistent.sh +++ b/src-tauri/src/core/ssh/bash_persistent.sh @@ -53,7 +53,8 @@ __nyaterm_prompt(){ unset NYATERM_READY_PENDING printf '%s' "${NYATERM_READY_MARKER-}" fi - printf '\033]7;file://%s%s\007' "$(__nyaterm_host)" "$PWD" + local cwd="${PWD//%/%25}" + printf '\033]7;file://%s%s\007' "$(__nyaterm_host)" "$cwd" return "$status" } diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index 8e311f86..44b3c039 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -594,7 +594,20 @@ struct PendingStartupCommand { delay_ms: u64, } +/// Build renderer-supplied startup input without allowing terminal controls. pub(super) fn build_startup_command_input(command: &str) -> Option> { + if command.trim().is_empty() || contains_terminal_control(command) { + return None; + } + + let mut input = command.as_bytes().to_vec(); + if !input.ends_with(b"\r") { + input.push(b'\r'); + } + Some(input) +} + +fn build_post_login_command_input(command: &str) -> Option> { if command.trim().is_empty() { return None; } @@ -607,6 +620,12 @@ pub(super) fn build_startup_command_input(command: &str) -> Option> { Some(input) } +fn contains_terminal_control(value: &str) -> bool { + value + .chars() + .any(|ch| matches!(ch, '\u{0000}'..='\u{001F}' | '\u{007F}'..='\u{009F}')) +} + fn arm_post_login_timer( phase: &IoPhase, pending_post_login: &Option, @@ -911,7 +930,7 @@ pub(super) async fn ssh_io_loop( let mut initial_remote_data_logged = false; let mut injection_sent_at: Option = None; let mut pending_post_login = post_login.and_then(|config| { - build_startup_command_input(&config.command).map(|input| PendingStartupCommand { + build_post_login_command_input(&config.command).map(|input| PendingStartupCommand { input, delay_ms: config.delay_ms, }) @@ -1408,9 +1427,9 @@ mod tests { use super::{ INITIAL_INJECT_DELAY_MS, INJECT_TIMEOUT_SECS, InjectionEvent, InjectionTimeoutEvent, IoPhase, PendingStartupCommand, SUPPRESSED_VISIBLE_FALLBACK_MAX_BYTES, - append_suppressed_visible_and_take_passthrough, build_startup_command_input, - discard_suppressed_output, handle_injection_result, handle_injection_timeout, - open_shell_channel, should_send_initial_injection, + append_suppressed_visible_and_take_passthrough, build_post_login_command_input, + build_startup_command_input, discard_suppressed_output, handle_injection_result, + handle_injection_timeout, open_shell_channel, should_send_initial_injection, }; use crate::config::SftpCwdFollowMode; use crate::core::ssh::osc::{OscResult, OscStripper, build_ready_marker}; @@ -1551,21 +1570,46 @@ mod tests { #[test] fn post_login_input_normalizes_line_endings_and_adds_enter() { - let input = build_startup_command_input("cd /opt/app\nclear").expect("input"); + let input = build_post_login_command_input("cd /opt/app\nclear").expect("input"); assert_eq!(input, b"cd /opt/app\rclear\r"); } #[test] fn post_login_input_preserves_existing_trailing_enter() { - let input = build_startup_command_input("uptime\r").expect("input"); + let input = build_post_login_command_input("uptime\r").expect("input"); assert_eq!(input, b"uptime\r"); } #[test] fn post_login_input_ignores_blank_commands() { - assert!(build_startup_command_input(" \n\t ").is_none()); + assert!(build_post_login_command_input(" \n\t ").is_none()); + } + + #[test] + fn startup_input_rejects_c0_del_and_c1_controls() { + for code in 0x00..=0x1f { + let command = format!("cd /tmp/a{}b", char::from_u32(code).expect("C0 code point")); + assert!( + build_startup_command_input(&command).is_none(), + "C0 control U+{code:04X} must be rejected" + ); + } + for code in 0x7f..=0x9f { + let command = format!("cd /tmp/a{}b", char::from_u32(code).expect("C1 code point")); + assert!( + build_startup_command_input(&command).is_none(), + "DEL/C1 control U+{code:04X} must be rejected" + ); + } + } + + #[test] + fn startup_input_adds_enter_to_a_safe_command() { + let input = build_startup_command_input("cd '/opt/app'").expect("input"); + + assert_eq!(input, b"cd '/opt/app'\r"); } fn osc_result(ready: bool, visible_after_ready: &str) -> OscResult { diff --git a/src-tauri/src/core/ssh/osc.rs b/src-tauri/src/core/ssh/osc.rs index f6bd0b2f..07e3f6af 100644 --- a/src-tauri/src/core/ssh/osc.rs +++ b/src-tauri/src/core/ssh/osc.rs @@ -123,7 +123,7 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " }};", " __nyaterm_prompt(){{", " local status=$?; __nyaterm_prune_history; __nyaterm_emit_command;", - " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\";", + " local cwd=\"${{PWD//%/%25}}\"; printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$cwd\";", " return \"$status\";", " }};", " __nyaterm_prompt_state_writable(){{ local name decl; for name in __nyaterm_saved_prompt_command __nyaterm_extra_prompt_commands __nyaterm_exported_prompt_fallback; do decl=\"$(declare -p \"$name\" 2>/dev/null || true)\"; [[ ! \"$decl\" =~ ^declare\\ -[^[:space:]]*r ]] || return 1; done; }};", @@ -174,7 +174,7 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " __nyaterm_host(){{ hostname 2>/dev/null || printf localhost; }};", " __nyaterm_ready_failed(){{ [ -n \"${{__nyaterm_failure_reported:-}}\" ] || {{ __nyaterm_failure_reported=1; printf '%s' \"${{NYATERM_READY_FAILED_MARKER-}}\"; }}; }};", " __nyaterm_emit(){{", - " local saved_status=$?; printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"; return \"$saved_status\";", + " local saved_status=$?; local cwd=\"${{PWD//%/%25}}\"; printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$cwd\"; return \"$saved_status\";", " }};", " __nyaterm_preexec(){{", " local saved_status=$?; if [ -n \"$1\" ]; then", @@ -209,7 +209,7 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " set -g NYATERM_COMMAND_MARKER \"{}\";", " set -g NYATERM_READY_FAILED_MARKER (printf '{}');", " function __nyaterm_emit --on-event fish_prompt;", - " set -l saved_status $status; printf '\\033]7;file://%s%s\\007' (hostname) $PWD; return $saved_status;", + " set -l saved_status $status; set -l cwd (string replace -a '%' '%25' -- $PWD); printf '\\033]7;file://%s%s\\007' (hostname) $cwd; return $saved_status;", " end;", " function __nyaterm_preexec --on-event fish_preexec;", " set -l saved_status $status; if test -n \"$argv[1]\";", @@ -353,7 +353,8 @@ const ZSH_PERSISTENT_SCRIPT: &str = concat!( "__nyaterm_emit(){\n", " local saved_status=$?\n", " if [ -n \"${NYATERM_READY_PENDING:-}\" ]; then unset NYATERM_READY_PENDING; printf '%s' \"${NYATERM_READY_MARKER-}\"; fi\n", - " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$PWD\"\n", + " local cwd=\"${PWD//%/%25}\"\n", + " printf '\\033]7;file://%s%s\\007' \"$(__nyaterm_host)\" \"$cwd\"\n", " return \"$saved_status\"\n", "}\n", "__nyaterm_preexec(){\n", @@ -387,7 +388,8 @@ const FISH_PERSISTENT_SCRIPT: &str = concat!( " set -e NYATERM_READY_PENDING\n", " printf '%s' \"$NYATERM_READY_MARKER\"\n", " end\n", - " printf '\\033]7;file://%s%s\\007' (hostname) $PWD\n", + " set -l cwd (string replace -a '%' '%25' -- $PWD)\n", + " printf '\\033]7;file://%s%s\\007' (hostname) $cwd\n", " return $saved_status\n", "end\n", "function __nyaterm_preexec\n", @@ -1214,6 +1216,24 @@ eval "$PROMPT_COMMAND" 2>/dev/null || true for script in [&bash, &zsh, &fish] { assert!(script.contains("NyaTermReadyFailed:session-1")); } + assert!(bash.contains("${PWD//%/%25}")); + assert!(zsh.contains("${PWD//%/%25}")); + assert!(fish.contains("string replace -a '%' '%25' -- $PWD")); + assert!( + persistent_script(ShellKind::Bash) + .expect("bash persistent script") + .contains("${PWD//%/%25}") + ); + assert!( + persistent_script(ShellKind::Zsh) + .expect("zsh persistent script") + .contains("${PWD//%/%25}") + ); + assert!( + persistent_script(ShellKind::Fish) + .expect("fish persistent script") + .contains("string replace -a '%' '%25' -- $PWD") + ); assert!(bash.contains("local status=$?")); assert!(bash.contains("__nyaterm_run_saved_prompt_command")); assert!(bash.contains("__nyaterm_repair_prompt")); diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index be12387a..898877a6 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -517,8 +517,9 @@ export default function XTerminal({ if (disposed) return; void invoke("try_get_terminal_cwd", { sessionId }).then( (cwd) => { - if (disposed || cwdEventObserved || cwd === null) return; - recordSessionCwd(sessionId, cwd); + if (disposed || cwdEventObserved) return; + // A successful null snapshot is authoritative and clears a carried cwd. + recordSessionCwd(sessionId, cwd ?? ""); }, () => {}, ); diff --git a/src/lib/terminalSessionCwd.test.ts b/src/lib/terminalSessionCwd.test.ts index 70ca11b3..48ec521b 100644 --- a/src/lib/terminalSessionCwd.test.ts +++ b/src/lib/terminalSessionCwd.test.ts @@ -59,6 +59,47 @@ describe("buildReconnectCwdCommand", () => { it("supports unicode paths", () => { expect(buildReconnectCwdCommand("/home/用户")).toBe("cd '/home/用户'"); }); + + it("decodes percent-encoded paths before replay", () => { + expect(buildReconnectCwdCommand("/opt/my%20dir")).toBe("cd '/opt/my dir'"); + }); + + it("decodes percent-encoded unicode paths before replay", () => { + expect(buildReconnectCwdCommand("/home/%E7%94%A8%E6%88%B7")).toBe( + "cd '/home/用户'", + ); + }); + + it("keeps a raw path when a percent sequence is malformed", () => { + expect(buildReconnectCwdCommand("/opt/100%")).toBe("cd '/opt/100%'"); + expect(buildReconnectCwdCommand("/opt/a%zzb")).toBe("cd '/opt/a%zzb'"); + }); + + it("preserves literal percent sequences from the NyaTerm emitter", () => { + expect(buildReconnectCwdCommand("/opt/100%25")).toBe("cd '/opt/100%'"); + expect(buildReconnectCwdCommand("/opt/my%2520dir")).toBe( + "cd '/opt/my%20dir'", + ); + }); + + it("returns null when decoding leaves a blank value", () => { + expect(buildReconnectCwdCommand("%20")).toBeNull(); + }); + + it("escapes quotes revealed by percent-decoding", () => { + expect(buildReconnectCwdCommand("/home/o%27brien")).toBe( + "cd '/home/o'\\''brien'", + ); + }); + + it("returns null for control characters revealed by percent-decoding", () => { + expect(buildReconnectCwdCommand("/tmp/a%00b")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%0ab")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%0db")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%15b")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%7fb")).toBeNull(); + expect(buildReconnectCwdCommand("/tmp/a%C2%85b")).toBeNull(); + }); }); describe("recordSessionCwd / getSessionCwd", () => { diff --git a/src/lib/terminalSessionCwd.ts b/src/lib/terminalSessionCwd.ts index 2ec384c9..89ec137c 100644 --- a/src/lib/terminalSessionCwd.ts +++ b/src/lib/terminalSessionCwd.ts @@ -45,15 +45,25 @@ export function carryOverSessionCwd(fromSessionId: string, toSessionId: string) const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/; /** - * Builds a `cd ''` command for the given cwd. Returns null for - * empty/blank values or paths containing any C0/C1/DEL control characters - * (terminal line editing could otherwise erase the quoted prefix and inject - * commands) so reconnect falls back to the default behavior. + * Builds a `cd ''` command for the given cwd. The payload may arrive + * percent-encoded (legacy OSC 7 shell integrations) or raw (NyaTerm's own + * emitter escapes literal '%' characters), so it is best-effort decoded once + * before replay; a malformed percent sequence keeps the raw value. Returns + * null for empty/blank values or paths containing any C0/C1/DEL control + * characters after decoding (terminal line editing could otherwise erase the + * quoted prefix and inject commands) so reconnect falls back to the default + * behavior. */ export function buildReconnectCwdCommand(cwd: string) { if (!cwd.trim()) return null; - if (CONTROL_CHARS.test(cwd)) return null; - return `cd '${cwd.replace(/'/g, "'\\''")}'`; + let decoded = cwd; + try { + decoded = decodeURIComponent(cwd); + } catch { + // Malformed percent sequence: treat the payload as a raw path. + } + if (!decoded.trim() || CONTROL_CHARS.test(decoded)) return null; + return `cd '${decoded.replace(/'/g, "'\\''")}'`; } /**