From f395da4fb8aaa9b228e3b6990379e38638798a5e Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Tue, 3 Nov 2026 22:06:25 -0800 Subject: [PATCH 01/10] feat(server): open projects in a system terminal - Add Terminal to Open in with platform-specific launch support - Start the terminal in the project or preview file's folder --- .../src/process/externalLauncher.test.ts | 64 ++++++++++++++ apps/server/src/process/externalLauncher.ts | 87 ++++++++++++++++++- apps/web/src/components/chat/OpenInPicker.tsx | 12 ++- apps/web/src/editorLabels.ts | 2 +- apps/web/src/editorPreferences.test.ts | 8 ++ apps/web/src/editorPreferences.ts | 4 +- apps/web/src/fileContextMenu.ts | 4 +- docs/user/terminal.md | 6 ++ packages/contracts/src/editor.ts | 1 + 9 files changed, 183 insertions(+), 5 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 9f975ee209e3d..e644127a5cab2 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -257,6 +257,70 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Needs POSIX stubs; the Windows PowerShell stub is one file named with backslashes. +it.effect.skipIf(windowsHost)("opens the terminal in the target's folder on each platform", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3 it's a repo-" }); + const script = path.join(workspace, "run.sh"); + yield* fileSystem.writeFileString(script, "#!/bin/sh\n"); + const stub = path.join(binDir, "konsole"); + yield* fileSystem.writeFileString(stub, "#!/bin/sh\n"); + yield* fileSystem.chmod(stub, 0o755); + + const launch = (cwd: string, platform: NodeJS.Platform, env: Record) => + Effect.gen(function* () { + const spawned: ChildProcess.StandardCommand[] = []; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + if (editors.includes("terminal")) { + yield* launcher.launchEditor({ editor: "terminal", cwd }); + } + return editors; + }).pipe( + Effect.provide(layerTest({ platform, env, onSpawn: (command) => spawned.push(command) })), + ); + return { available: editors.includes("terminal"), spawned: spawned.at(-1) }; + }); + + for (const cwd of [workspace, script]) { + const mac = yield* launch(cwd, "darwin", { PATH: "/usr/bin" }); + assert.equal(mac.available, true); + assert.equal(mac.spawned?.command, "open"); + assert.deepEqual(mac.spawned?.args, ["-a", "Terminal", workspace]); + + const linux = yield* launch(cwd, "linux", { PATH: binDir, DISPLAY: ":0" }); + assert.equal(linux.spawned?.command, "konsole"); + assert.deepEqual(linux.spawned?.args, []); + assert.equal(linux.spawned?.options.cwd, workspace); + } + + const headless = yield* launch(workspace, "linux", { PATH: binDir }); + assert.equal(headless.available, false); + const noTerminal = yield* launch(workspace, "linux", { PATH: "/nonexistent", DISPLAY: ":0" }); + assert.equal(noTerminal.available, false); + + const systemRoot = path.join(binDir, "Windows"); + yield* fileSystem.writeFileString( + `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, + "", + ); + const windows = yield* launch("C:/it's a repo/src/index.ts", "win32", { + PATH: binDir, + PATHEXT: ".EXE", + SYSTEMROOT: systemRoot, + }); + assert.equal(windows.spawned?.options.detached, false); + assert.equal( + Buffer.from(String(windows.spawned?.args.at(-1)), "base64").toString("utf16le"), + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory 'C:\\it''s a repo\\src'", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.skipIf(windowsHost)( "reveals a WSL file in Windows File Explorer through its UNC path", () => diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 7f4417d1fb67a..63830ffee4516 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -48,6 +48,7 @@ interface EditorLaunch { readonly target: string; readonly command: string; readonly args: ReadonlyArray; + readonly cwd?: string; readonly detached?: boolean; } @@ -94,6 +95,20 @@ const compactEnv = (input: Record>): NodeJS.Proces ), ); +// Each of these opens in its spawn cwd when run bare, so none needs a directory flag. +const LINUX_TERMINAL_COMMANDS = [ + "xdg-terminal-exec", + "x-terminal-emulator", + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "kitty", + "alacritty", + "foot", + "xterm", +] as const; + const BrowserLaunchEnvConfig = Config.all({ SYSTEMROOT: Config.String("SYSTEMROOT").pipe(Config.option), windir: Config.String("windir").pipe(Config.option), @@ -357,6 +372,26 @@ const fileManagerRevealKindForPlatform = Effect.fn( return hasGraphicalLinuxSession(env) ? "files" : undefined; }); +const resolveTerminalCommand = Effect.fn("externalLauncher.resolveTerminalCommand")(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "open"; + if (platform === "win32") { + const powershell = resolvePowerShellPath(env); + return (yield* isCommandAvailable(powershell, { env })) ? powershell : undefined; + } + if (!hasGraphicalLinuxSession(env)) return undefined; + for (const command of LINUX_TERMINAL_COMMANDS) { + if (yield* isCommandAvailable(command, { env })) return command; + } + return undefined; +}); + function resolveWslFileManagerPath(target: string, distroName: string): string { const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; @@ -406,7 +441,11 @@ const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors" for (const editor of EDITORS) { if (editor.commands === null) { - if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { + const command = + editor.id === "terminal" + ? yield* resolveTerminalCommand(platform, env) + : yield* resolveUsableFileManagerCommand(platform, env); + if (command !== undefined) { available.push(editor.id); } continue; @@ -491,6 +530,14 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; } + if (editorDef.id === "terminal") { + const command = yield* resolveTerminalCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + return yield* resolveTerminalLaunch(input.cwd, platform, command); + } + if (editorDef.id !== "file-manager") { return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } @@ -518,6 +565,43 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( }; }); +// Terminal.app runs a file it is asked to open, so a file target must become its folder. +const resolveTerminalLaunch = Effect.fn("resolveTerminalLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + command: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const isDirectory = yield* fileSystem.stat(target).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + + if (platform === "win32") { + const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); + const windowsTarget = normalizeWindowsFileManagerPath(target); + const directory = isDirectory ? windowsTarget : path.dirname(windowsTarget); + return { + editor: "terminal", + target, + command, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64( + `$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory ${escapePowerShellStringLiteral(directory)}`, + ), + ], + detached: WINDOWS_POWERSHELL_DETACHED, + }; + } + + const path = yield* Path.Path; + const directory = isDirectory ? target : path.dirname(target); + return platform === "darwin" + ? { editor: "terminal", target, command, args: ["-a", "Terminal", directory] } + : { editor: "terminal", target, command, args: [], cwd: directory }; +}); + // A replacement manager registers a default Directory shell verb; stock Windows // uses "none". Explorer needs its /select argument passed verbatim through // PowerShell because Node's argument quoting changes how Explorer parses it. @@ -774,6 +858,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu command: spawnCommand.command, args: spawnCommand.args, options: { + ...(launch.cwd === undefined ? {} : { cwd: launch.cwd }), detached: launch.detached ?? true, shell: spawnCommand.shell, env: yield* readGraphicalSessionEnv(), diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index a19491adcd15e..ff7ce99174b86 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -19,7 +19,12 @@ import { useRemoteOpenState, } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; -import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react"; +import { + ChevronDownIcon, + FolderClosedIcon, + SquareArrowOutUpRightIcon, + TerminalIcon, +} from "lucide-react"; import { Group, GroupSeparator } from "../ui/group"; import { @@ -192,6 +197,11 @@ const resolveOpenInOptions = (platform: string, availableEditors: ReadonlyArray< value: "file-manager", kind: isMacPlatform(platform) || isWindowsPlatform(platform) ? "brand" : "generic", }, + { + Icon: TerminalIcon, + value: "terminal", + kind: "generic", + }, ]; const availableEditorSet = new Set(availableEditors); return baseOptions diff --git a/apps/web/src/editorLabels.ts b/apps/web/src/editorLabels.ts index 7d44bc328b74c..951c69505cc53 100644 --- a/apps/web/src/editorLabels.ts +++ b/apps/web/src/editorLabels.ts @@ -13,7 +13,7 @@ export function editorLabelForPlatform(editorId: EditorId, platform: string): st } export function openInEditorMenuLabel(editorId: EditorId | null): string { - return editorId === null || editorId === "file-manager" + return editorId === null || editorId === "file-manager" || editorId === "terminal" ? "Open in editor" : `Open in ${editorLabels.get(editorId) ?? "Editor"}`; } diff --git a/apps/web/src/editorPreferences.test.ts b/apps/web/src/editorPreferences.test.ts index 212eda3304221..ae30dbcd783f3 100644 --- a/apps/web/src/editorPreferences.test.ts +++ b/apps/web/src/editorPreferences.test.ts @@ -67,3 +67,11 @@ it("checks revocation and regrant when a retained editor callback is invoked", a }); expect(state.setPreference).toHaveBeenCalledTimes(1); }); + +it("opens files in an editor without losing a stored terminal preference", async () => { + state.allowed.add(primary); + state.getPreference.mockReturnValue("terminal"); + const open = useOpenInPreferredEditor(primary, ["vscode", "terminal"]); + expect(await open("/work/readme.md:5")).toMatchObject({ _tag: "Success", value: "vscode" }); + expect(state.setPreference).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index e6aacd32b02de..51b77b36fab2d 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -55,12 +55,14 @@ export function usePreferredEditor(availableEditors: ReadonlyArray) { return [effectiveEditor, setLastEditor] as const; } +// These targets are files, which a terminal cannot show, so a stored terminal choice is skipped and kept. function resolveAndPersistPreferredEditor(availableEditors: readonly EditorId[]): EditorId | null { const availableEditorIds = new Set(availableEditors); + availableEditorIds.delete("terminal"); const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); if (stored && availableEditorIds.has(stored)) return stored; const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; - if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); + if (editor && stored !== "terminal") setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); return editor ?? null; } diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 1ea95c96de518..3bb89a1928b1f 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -72,7 +72,9 @@ function buildFileContextMenuItems(input: { icon: "folder-tree", }); } - const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); + const editorIds = input.capabilities.editorIds.filter( + (id) => id !== "file-manager" && id !== "terminal", + ); if (editorIds.length > 0) { items.push({ id: "open-with", diff --git a/docs/user/terminal.md b/docs/user/terminal.md index dd921eb9a9650..b5aacf6812bc9 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -20,6 +20,12 @@ Jump to latest in the context menu returns to the current output. On iPhone and iPad, long-press the terminal to paste or to choose Select Text. Select Text shows the visible output, where you can copy text or tap a link. +To work in a system terminal instead, choose Terminal from the Open in menu. It +opens a new window in the project folder on the machine running T3 Code; from a +file preview it opens in that file's folder. On macOS this is Terminal.app, on +Windows it is PowerShell, and on Linux T3 Code uses the first terminal it finds, +starting with your desktop's default. + ## fish T3 Code starts fish with `no-query-term`, avoiding startup waits for terminal diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75e5aebbc31a4..0f52d2c667743 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -146,6 +146,7 @@ export const EDITORS = [ jetbrainsProductCode: "WS", }, { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, + { id: "terminal", label: "Terminal", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); From 8b38583d8d8134353b491c3c7ed5e0a4434a603b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 4 Nov 2026 08:46:31 +0000 Subject: [PATCH 02/10] fix(server): preserve terminal launch directories --- .../src/process/externalLauncher.test.ts | 57 ++++++++++++++++--- apps/server/src/process/externalLauncher.ts | 34 +++++++++-- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index e644127a5cab2..a5b2f837d9830 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -266,11 +266,16 @@ it.effect.skipIf(windowsHost)("opens the terminal in the target's folder on each const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3 it's a repo-" }); const script = path.join(workspace, "run.sh"); yield* fileSystem.writeFileString(script, "#!/bin/sh\n"); - const stub = path.join(binDir, "konsole"); + const stub = path.join(binDir, "x-terminal-emulator"); yield* fileSystem.writeFileString(stub, "#!/bin/sh\n"); yield* fileSystem.chmod(stub, 0o755); - const launch = (cwd: string, platform: NodeJS.Platform, env: Record) => + const launch = ( + cwd: string, + platform: NodeJS.Platform, + env: Record, + display?: string, + ) => Effect.gen(function* () { const spawned: ChildProcess.StandardCommand[] = []; const editors = yield* Effect.gen(function* () { @@ -281,7 +286,18 @@ it.effect.skipIf(windowsHost)("opens the terminal in the target's folder on each } return editors; }).pipe( - Effect.provide(layerTest({ platform, env, onSpawn: (command) => spawned.push(command) })), + Effect.provide( + layerTest({ + platform, + env, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => + command.command === "busctl" + ? { stdout: JSON.stringify({ data: display ? [`DISPLAY=${display}`] : [] }) } + : undefined, + }), + ), + Effect.provideService(HostProcess.Environment, env), ); return { available: editors.includes("terminal"), spawned: spawned.at(-1) }; }); @@ -293,22 +309,49 @@ it.effect.skipIf(windowsHost)("opens the terminal in the target's folder on each assert.deepEqual(mac.spawned?.args, ["-a", "Terminal", workspace]); const linux = yield* launch(cwd, "linux", { PATH: binDir, DISPLAY: ":0" }); - assert.equal(linux.spawned?.command, "konsole"); - assert.deepEqual(linux.spawned?.args, []); + assert.equal(linux.spawned?.command, "x-terminal-emulator"); + assert.deepEqual(linux.spawned?.args, [ + "-e", + "/bin/sh", + "-c", + 'cd -- "$1" && exec "${SHELL:-/bin/sh}"', + "sh", + workspace, + ]); assert.equal(linux.spawned?.options.cwd, workspace); } const headless = yield* launch(workspace, "linux", { PATH: binDir }); assert.equal(headless.available, false); + const recovered = yield* launch(workspace, "linux", { PATH: binDir }, ":0"); + assert.equal(recovered.available, true); + assert.equal(recovered.spawned?.options.env?.DISPLAY, ":0"); const noTerminal = yield* launch(workspace, "linux", { PATH: "/nonexistent", DISPLAY: ":0" }); assert.equal(noTerminal.available, false); + for (const [command, args] of [ + ["xdg-terminal-exec", [`--dir=${workspace}`]], + ["konsole", ["--workdir", workspace]], + ["kitty", ["--directory", workspace]], + ...["gnome-terminal", "kgx", "xfce4-terminal", "alacritty", "foot"].map( + (command) => [command, [`--working-directory=${workspace}`]] as const, + ), + ] as const) { + const terminalBin = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-terminal-" }); + const executable = path.join(terminalBin, command); + yield* fileSystem.writeFileString(executable, "#!/bin/sh\n"); + yield* fileSystem.chmod(executable, 0o755); + const linux = yield* launch(workspace, "linux", { PATH: terminalBin, DISPLAY: ":0" }); + assert.equal(linux.spawned?.command, command); + assert.deepEqual(linux.spawned?.args, args); + } + const systemRoot = path.join(binDir, "Windows"); yield* fileSystem.writeFileString( `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, "", ); - const windows = yield* launch("C:/it's a repo/src/index.ts", "win32", { + const windows = yield* launch("C:/it's a repo’;calc;#/src/index.ts", "win32", { PATH: binDir, PATHEXT: ".EXE", SYSTEMROOT: systemRoot, @@ -316,7 +359,7 @@ it.effect.skipIf(windowsHost)("opens the terminal in the target's folder on each assert.equal(windows.spawned?.options.detached, false); assert.equal( Buffer.from(String(windows.spawned?.args.at(-1)), "base64").toString("utf16le"), - "$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory 'C:\\it''s a repo\\src'", + `$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${Buffer.from("C:\\it's a repo’;calc;#\\src", "utf16le").toString("base64")}')))`, ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 63830ffee4516..87eed526925d6 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -95,7 +95,6 @@ const compactEnv = (input: Record>): NodeJS.Proces ), ); -// Each of these opens in its spawn cwd when run bare, so none needs a directory flag. const LINUX_TERMINAL_COMMANDS = [ "xdg-terminal-exec", "x-terminal-emulator", @@ -385,7 +384,8 @@ const resolveTerminalCommand = Effect.fn("externalLauncher.resolveTerminalComman const powershell = resolvePowerShellPath(env); return (yield* isCommandAvailable(powershell, { env })) ? powershell : undefined; } - if (!hasGraphicalLinuxSession(env)) return undefined; + if (!hasGraphicalLinuxSession(env) && !hasGraphicalLinuxSession(yield* readGraphicalSessionEnv())) + return undefined; for (const command of LINUX_TERMINAL_COMMANDS) { if (yield* isCommandAvailable(command, { env })) return command; } @@ -588,7 +588,7 @@ const resolveTerminalLaunch = Effect.fn("resolveTerminalLaunch")(function* ( args: [ ...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64( - `$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory ${escapePowerShellStringLiteral(directory)}`, + `$ProgressPreference = 'SilentlyContinue'; Start-Process 'powershell.exe' -WorkingDirectory ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encodeUtf16LeBase64(directory)}')))`, ), ], detached: WINDOWS_POWERSHELL_DETACHED, @@ -599,9 +599,35 @@ const resolveTerminalLaunch = Effect.fn("resolveTerminalLaunch")(function* ( const directory = isDirectory ? target : path.dirname(target); return platform === "darwin" ? { editor: "terminal", target, command, args: ["-a", "Terminal", directory] } - : { editor: "terminal", target, command, args: [], cwd: directory }; + : { + editor: "terminal", + target, + command, + args: linuxTerminalArgs(command, directory), + cwd: directory, + }; }); +function linuxTerminalArgs(command: string, directory: string): ReadonlyArray { + switch (command) { + case "xdg-terminal-exec": + return [`--dir=${directory}`]; + case "konsole": + return ["--workdir", directory]; + case "kitty": + return ["--directory", directory]; + case "gnome-terminal": + case "kgx": + case "xfce4-terminal": + case "alacritty": + case "foot": + return [`--working-directory=${directory}`]; + default: + // The desktop's x-terminal-emulator can ignore its inherited cwd. + return ["-e", "/bin/sh", "-c", 'cd -- "$1" && exec "${SHELL:-/bin/sh}"', "sh", directory]; + } +} + // A replacement manager registers a default Directory shell verb; stock Windows // uses "none". Explorer needs its /select argument passed verbatim through // PowerShell because Node's argument quoting changes how Explorer parses it. From deccb35e8eb7a1ac12d9651eb8aa74c34bccd9fc Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 16:47:10 -0800 Subject: [PATCH 03/10] feat(web): give Terminal an app-style icon in Open in The thin gray prompt looked out of place next to the full-color app icons. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Icons.tsx | 22 +++++++++++++++++++ apps/web/src/components/chat/OpenInPicker.tsx | 12 ++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index d6977fc043097..0ab7e2b3cb7e0 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -25,6 +25,28 @@ export const FinderIcon: Icon = (props) => ( ); +export const TerminalAppIcon: Icon = (props) => ( + + + + +); + export const FileExplorerIcon: Icon = (props) => ( Date: Fri, 6 Nov 2026 06:37:55 -0800 Subject: [PATCH 04/10] feat(desktop): Open in Terminal SSHs into remote hosts When Open in runs against a remote SSH or advertised host, Terminal opens a local system terminal that SSHs in and starts in the project folder, matching how remote editors open over SSH. --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 13 ++ apps/desktop/src/preload.ts | 2 + .../src/shell/openRemoteTerminal.test.ts | 91 ++++++++++++ apps/desktop/src/shell/openRemoteTerminal.ts | 140 ++++++++++++++++++ .../chat/OpenInPicker.browser.test.tsx | 25 ++++ apps/web/src/components/chat/OpenInPicker.tsx | 29 ++-- .../components/chat/OpenInPickerShortcut.ts | 26 +++- apps/web/src/remoteOpen.test.ts | 34 ++++- apps/web/src/remoteOpen.ts | 29 +++- docs/user/terminal.md | 5 + packages/contracts/src/editor.ts | 19 +++ packages/contracts/src/ipc.ts | 10 ++ 14 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/shell/openRemoteTerminal.test.ts create mode 100644 apps/desktop/src/shell/openRemoteTerminal.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6a1b1c62e7471..eb7e910f23cdd 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -47,6 +47,7 @@ import { checkSystemPermission, pasteAsText, probeRemoteEditors, + openRemoteTerminal, revealWindow, pickFolder, pickProjectFavicon, @@ -157,6 +158,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(revealWindow); yield* ipc.handle(syncHtmlFullscreen); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(openRemoteTerminal); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..01de981ce5eb5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const READ_MEDIA_CHANNEL = "desktop:read-media"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; +export const OPEN_REMOTE_TERMINAL_CHANNEL = "desktop:open-remote-terminal"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const REVEAL_WINDOW_CHANNEL = "desktop:reveal-window"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index e0eea5c74c9ed..f33470b76fe02 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,6 +3,7 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopMediaReadSchema, + DesktopOpenRemoteTerminalInputSchema, DesktopThemeSchema, EDITORS, EditorId, @@ -14,6 +15,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -43,6 +45,7 @@ import * as MacPermissions from "../../permissions/MacPermissions.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; +import { openRemoteTerminal as launchRemoteTerminal } from "../../shell/openRemoteTerminal.ts"; import { extractDistroFromUncPath, resolveWslPickFolderDefaultPath, @@ -407,6 +410,16 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const openRemoteTerminal = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_REMOTE_TERMINAL_CHANNEL, + payload: DesktopOpenRemoteTerminalInputSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openRemoteTerminal")(function* (input) { + // Own Node services so installing IPC handlers does not demand them at bootstrap. + return yield* launchRemoteTerminal(input).pipe(Effect.provide(NodeServices.layer)); + }), +}); + export const pasteAsText = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..35f417bd94334 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -228,6 +228,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + openRemoteTerminal: (input) => + ipcRenderer.invoke(IpcChannels.OPEN_REMOTE_TERMINAL_CHANNEL, input), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts new file mode 100644 index 0000000000000..80a0e4acc0ba8 --- /dev/null +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -0,0 +1,91 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { openRemoteTerminal } from "./openRemoteTerminal.ts"; + +const captureSpawner = (spawned: Array<{ command: string; args: ReadonlyArray }>) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) { + spawned.push({ + command: command.command, + args: command.args.map(String), + }); + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + +describe("openRemoteTerminal", () => { + it.effect("opens Terminal.app via osascript with an ssh -t login shell", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "sol", + username: "theo", + absolutePath: "/work/project", + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, {}), + ); + assert.isTrue(opened); + assert.equal(spawned.length, 1); + assert.equal(spawned[0]?.command, "osascript"); + const script = spawned[0]?.args.join(" ") ?? ""; + assert.match(script, /tell application "Terminal" to do script/); + // Each argv is shell-quoted: 'ssh' '-t' 'theo@sol' 'cd -- ...' + assert.match(script, /'ssh' '-t' 'theo@sol'/); + assert.match(script, /cd -- /); + assert.match(script, /\/work\/project/); + assert.match(script, /\$\{SHELL:-\/bin\/sh\}/); + }), + ); + + it.effect("falls back to cmd start on Windows when Windows Terminal is missing", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "box", + absolutePath: "/home/theo/app/README.md", + isFile: true, + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "win32"), + // Empty PATH so wt.exe is unavailable and the cmd.exe fallback runs. + Effect.provideService(HostProcess.Environment, { PATH: "", Path: "", path: "" }), + ); + assert.isTrue(opened); + assert.equal(spawned[0]?.command, "cmd.exe"); + assert.deepEqual(spawned[0]?.args.slice(0, 3), ["/c", "start", ""]); + assert.deepEqual( + [...(spawned[0]?.args ?? [])].filter( + (arg) => arg === "ssh.exe" || arg === "-t" || arg === "box", + ), + ["ssh.exe", "-t", "box"], + ); + assert.isTrue((spawned[0]?.args ?? []).some((arg) => arg.includes("cd -- '/home/theo/app'"))); + }), + ); +}); diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts new file mode 100644 index 0000000000000..750cfa3ed8fb5 --- /dev/null +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -0,0 +1,140 @@ +import { + buildRemoteSshTerminalDestination, + buildRemoteSshTerminalRemoteCommand, + remoteTerminalWorkingDirectory, + type DesktopOpenRemoteTerminalInput, +} from "@t3tools/contracts"; +import { resolveSshCommand } from "@t3tools/ssh/command"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isCommandAvailable } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +const LINUX_TERMINAL_COMMANDS = [ + "xdg-terminal-exec", + "x-terminal-emulator", + "gnome-terminal", + "kgx", + "konsole", + "xfce4-terminal", + "kitty", + "alacritty", + "foot", + "xterm", +] as const; + +const DETACHED_IGNORE_STDIO = { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +} as const; + +const quoteSh = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`; + +const escapeAppleScriptString = (value: string): string => + value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + +const launchAndUnref = Effect.fn("desktop.openRemoteTerminal.launchAndUnref")(function* ( + command: string, + args: ReadonlyArray, + options: ChildProcess.CommandOptions = DETACHED_IGNORE_STDIO, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* spawner.spawn(ChildProcess.make(command, args, options)).pipe( + Effect.flatMap((handle) => handle.unref), + Effect.asVoid, + Effect.scoped, + ); +}); + +const linuxTerminalArgs = ( + command: string, + sshCommand: string, + sshArgs: ReadonlyArray, +): ReadonlyArray => { + switch (command) { + case "gnome-terminal": + case "kgx": + case "xfce4-terminal": + return ["--", sshCommand, ...sshArgs]; + default: + return ["-e", sshCommand, ...sshArgs]; + } +}; + +const resolveLinuxTerminalCommand = Effect.fn("desktop.openRemoteTerminal.resolveLinuxTerminal")( + function* (env: NodeJS.ProcessEnv) { + for (const command of LINUX_TERMINAL_COMMANDS) { + if (yield* isCommandAvailable(command, { env })) return command; + } + return undefined; + }, +); + +const launchDarwin = Effect.fn("desktop.openRemoteTerminal.launchDarwin")(function* ( + sshCommand: string, + sshArgs: ReadonlyArray, +) { + const shellLine = [sshCommand, ...sshArgs].map(quoteSh).join(" "); + yield* launchAndUnref("osascript", [ + "-e", + `tell application "Terminal" to do script "${escapeAppleScriptString(shellLine)}"`, + "-e", + 'tell application "Terminal" to activate', + ]); +}); + +const launchWindows = Effect.fn("desktop.openRemoteTerminal.launchWindows")(function* ( + sshCommand: string, + sshArgs: ReadonlyArray, + env: NodeJS.ProcessEnv, +) { + if (yield* isCommandAvailable("wt.exe", { env })) { + yield* launchAndUnref("wt.exe", ["-w", "0", "nt", "--", sshCommand, ...sshArgs]); + return; + } + // `start`'s first quoted token is the window title; keep it empty so ssh is the command. + yield* launchAndUnref("cmd.exe", ["/c", "start", "", sshCommand, ...sshArgs], { + ...DETACHED_IGNORE_STDIO, + shell: false, + }); +}); + +const launchLinux = Effect.fn("desktop.openRemoteTerminal.launchLinux")(function* ( + sshCommand: string, + sshArgs: ReadonlyArray, + env: NodeJS.ProcessEnv, +) { + const terminal = yield* resolveLinuxTerminalCommand(env); + if (terminal === undefined) return false; + yield* launchAndUnref(terminal, linuxTerminalArgs(terminal, sshCommand, sshArgs)); + return true; +}); + +/** Spawns a local system terminal that SSHs to the remote working directory. */ +export const openRemoteTerminal = Effect.fn("desktop.openRemoteTerminal")(function* ( + input: DesktopOpenRemoteTerminalInput, +) { + const platform = yield* HostProcess.Platform; + const env = yield* HostProcess.Environment; + const directory = remoteTerminalWorkingDirectory(input.absolutePath, input.isFile === true); + const destination = buildRemoteSshTerminalDestination(input.host, input.username); + const remoteCommand = buildRemoteSshTerminalRemoteCommand(directory); + const sshCommand = yield* resolveSshCommand; + const sshArgs = ["-t", destination, remoteCommand] as const; + + if (platform === "linux") { + return yield* launchLinux(sshCommand, sshArgs, env).pipe(Effect.orElseSucceed(() => false)); + } + + return yield* ( + platform === "darwin" + ? launchDarwin(sshCommand, sshArgs) + : launchWindows(sshCommand, sshArgs, env) + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 684db30988f3e..2fbe7b82f6085 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -13,6 +13,7 @@ const state = vi.hoisted(() => ({ clientEditors: ["vscode"] as readonly EditorId[], run: vi.fn(), openUrl: vi.fn(), + openTerminal: vi.fn(), setPreferred: vi.fn(), markHintSeen: vi.fn(), listeners: new Set<() => void>(), @@ -63,6 +64,7 @@ vi.mock(import("../../remoteOpen"), async (importOriginal) => ({ useRemoteCapableEditors: () => state.clientEditors, useRemoteOpenHint: () => [false, state.markHintSeen] as const, openRemoteEditorUrl: state.openUrl, + openRemoteTerminal: state.openTerminal, })); import { OpenInPicker } from "./OpenInPicker"; @@ -81,6 +83,7 @@ async function setup() { state.clientEditors = editors; state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); state.openUrl.mockReset().mockResolvedValue(true); + state.openTerminal.mockReset().mockResolvedValue(true); state.setPreferred.mockReset(); state.markHintSeen.mockReset(); state.listeners.clear(); @@ -236,3 +239,25 @@ describe("open in editor announcement", () => { expect(await spoken()).toBe("Opened in VS Code"); }); }); + +describe("remote SSH terminal", () => { + it("opens a local SSH terminal instead of an editor URL", async () => { + await using fixture = await setup(); + const { renderPicker, primaryButton, click } = fixture; + state.remote = { + mode: "remote-links", + host: { kind: "ssh-alias", host: "sol", username: "theo" }, + }; + state.clientEditors = ["vscode", "terminal"]; + window.localStorage.setItem("t3code:last-editor", JSON.stringify("terminal")); + await renderPicker(["vscode", "terminal"]); + await click(primaryButton()); + expect(state.openTerminal).toHaveBeenCalledWith({ + host: { kind: "ssh-alias", host: "sol", username: "theo" }, + absolutePath: "/work/project", + isFile: false, + }); + expect(state.openUrl).not.toHaveBeenCalled(); + expect(state.run).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 8155b6faf9807..a86565bb9dccb 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -14,6 +14,7 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, + openRemoteTerminal, useRemoteCapableEditors, useRemoteOpenHint, useRemoteOpenState, @@ -254,16 +255,24 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!editor) return false; if (remote.mode === "remote-unavailable") return false; if (remote.mode === "remote-links") { - const url = buildRemoteOpenUrl({ - editor, - ...remote.host, - absolutePath: openInCwd, - isFile: openInCwdIsFile, - }); - if (url === undefined) return false; - // Only record hint-seen/preferred when the shell actually accepted - // the URL (an older desktop build can refuse the editor scheme). - const opened = await openRemoteEditorUrl(url); + let opened = false; + if (editor === "terminal") { + opened = await openRemoteTerminal({ + host: remote.host, + absolutePath: openInCwd, + isFile: openInCwdIsFile, + }); + } else { + const url = buildRemoteOpenUrl({ + editor, + ...remote.host, + absolutePath: openInCwd, + isFile: openInCwdIsFile, + }); + // Only record hint-seen/preferred when the shell actually accepted + // the URL (an older desktop build can refuse the editor scheme). + opened = url !== undefined && (await openRemoteEditorUrl(url)); + } if (!opened) return false; markRemoteHintSeen(); setPreferredEditor(editor); diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 833c3f3784987..0b8233095f98e 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -10,7 +10,12 @@ import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; import { announce } from "../../lib/announce"; -import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen"; +import { + openRemoteEditorUrl, + openRemoteTerminal, + useRemoteCapableEditors, + useRemoteOpenState, +} from "../../remoteOpen"; import { registerCommands } from "../../shortcuts/dispatcher"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -76,13 +81,18 @@ export function useOpenFavoriteEditorShortcut({ event.preventDefault(); const label = editorLabelForPlatform(preferredEditor, navigator.platform); if (remote.mode === "remote-links") { - const url = buildRemoteOpenUrl({ - editor: preferredEditor, - ...remote.host, - absolutePath: openInCwd, - }); - if (url === undefined) return; - void openRemoteEditorUrl(url).then((opened) => { + const open = + preferredEditor === "terminal" + ? openRemoteTerminal({ host: remote.host, absolutePath: openInCwd }) + : (() => { + const url = buildRemoteOpenUrl({ + editor: preferredEditor, + ...remote.host, + absolutePath: openInCwd, + }); + return url === undefined ? Promise.resolve(false) : openRemoteEditorUrl(url); + })(); + void open.then((opened) => { if (opened) announce(`Opened in ${label}`); }); return; diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 365ec4a3d50af..295868bef69ae 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -3,7 +3,13 @@ import { RelayConnectionTarget, SshConnectionTarget, } from "@t3tools/client-runtime/connection"; -import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + buildRemoteSshTerminalDestination, + buildRemoteSshTerminalRemoteCommand, + EnvironmentId, + remoteTerminalWorkingDirectory, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { resolveRemoteOpenState } from "./remoteOpen"; @@ -143,6 +149,32 @@ describe("resolveRemoteOpenState", () => { }); }); +describe("remote SSH terminal helpers", () => { + it("builds a destination that matches remote editor hosts", () => { + expect(buildRemoteSshTerminalDestination("sol")).toBe("sol"); + expect(buildRemoteSshTerminalDestination("sol.tail.ts.net", "theo")).toBe( + "theo@sol.tail.ts.net", + ); + }); + + it("opens file targets in their parent folder", () => { + expect(remoteTerminalWorkingDirectory("/work/project", false)).toBe("/work/project"); + expect(remoteTerminalWorkingDirectory("/work/project/README.md", true)).toBe("/work/project"); + expect(remoteTerminalWorkingDirectory("C:\\Users\\theo\\app\\file.ts", true)).toBe( + "/C:/Users/theo/app", + ); + }); + + it("quotes the remote cd path and leaves SHELL for the remote shell", () => { + expect(buildRemoteSshTerminalRemoteCommand("/work/my project")).toBe( + `cd -- '/work/my project' && exec "\${SHELL:-/bin/sh}" -l`, + ); + expect(buildRemoteSshTerminalRemoteCommand("/tmp/o'brian")).toBe( + `cd -- '/tmp/o'\\''brian' && exec "\${SHELL:-/bin/sh}" -l`, + ); + }); +}); + describe("buildRemoteOpenUrl", () => { it("roots Windows paths", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 741f6f3c60cde..33bb7cfc13762 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -146,17 +146,22 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; +const withRemoteTerminal = (ids: ReadonlyArray): ReadonlyArray => + window.desktopBridge?.openRemoteTerminal !== undefined && !ids.includes("terminal") + ? [...ids, "terminal"] + : ids; + // Probed once per page: installed editors rarely change, and the probe spawns processes. const remoteCapableEditorsAtom = Atom.make((get): ReadonlyArray => { const probe = window.desktopBridge?.probeRemoteEditors; probe?.().then( (ids) => { const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); - if (remoteCapable.length > 0) get.setSelf(remoteCapable); + if (remoteCapable.length > 0) get.setSelf(withRemoteTerminal(remoteCapable)); }, () => undefined, ); - return REMOTE_FALLBACK_EDITORS; + return withRemoteTerminal(REMOTE_FALLBACK_EDITORS); }).pipe(Atom.keepAlive, Atom.withLabel("remote-capable-editors")); export function useRemoteCapableEditors(): ReadonlyArray { @@ -177,6 +182,26 @@ export async function openRemoteEditorUrl(url: string): Promise { return true; } +/** Opens a local system terminal that SSHs into the remote host. Desktop only. */ +export async function openRemoteTerminal(input: { + readonly host: RemoteOpenHost; + readonly absolutePath: string; + readonly isFile?: boolean; +}): Promise { + const bridge = window.desktopBridge; + if (bridge?.openRemoteTerminal === undefined) return false; + try { + return await bridge.openRemoteTerminal({ + host: input.host.host, + ...(input.host.username === undefined ? {} : { username: input.host.username }), + absolutePath: input.absolutePath, + ...(input.isFile === undefined ? {} : { isFile: input.isFile }), + }); + } catch { + return false; + } +} + const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { diff --git a/docs/user/terminal.md b/docs/user/terminal.md index b5aacf6812bc9..a5a7e5f4da5a5 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -26,6 +26,11 @@ file preview it opens in that file's folder. On macOS this is Terminal.app, on Windows it is PowerShell, and on Linux T3 Code uses the first terminal it finds, starting with your desktop's default. +When you are connected to a remote environment over SSH or an advertised +Tailscale or local network host, Terminal (desktop app) opens a local system +terminal that SSHs into that host and starts in the project folder, the same way +Open in opens editors over SSH. + ## fish T3 Code starts fish with `no-query-term`, avoiding startup waits for terminal diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0f52d2c667743..082496658cba5 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -233,6 +233,25 @@ export const buildRemoteOpenUrl = (input: { return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(destination)}${encodedPath}${input.isFile ? ":1:1" : ""}`; }; +/** Directory for a remote SSH terminal; file targets open in their parent folder. */ +export function remoteTerminalWorkingDirectory(absolutePath: string, isFile: boolean): string { + const posixPath = absolutePath.replaceAll("\\", "/"); + const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; + if (!isFile) return rootedPath; + const lastSlash = rootedPath.lastIndexOf("/"); + return lastSlash <= 0 ? "/" : rootedPath.slice(0, lastSlash); +} + +export function buildRemoteSshTerminalDestination(host: string, username?: string): string { + return username === undefined || username.length === 0 ? host : `${username}@${host}`; +} + +/** Single ssh remote-command argv: cd into the path, then replace with a login shell. */ +export function buildRemoteSshTerminalRemoteCommand(directory: string): string { + const quoted = `'${directory.replaceAll("'", `'\\''`)}'`; + return `cd -- ${quoted} && exec "\${SHELL:-/bin/sh}" -l`; +} + export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219f..8804b3deabffb 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1029,6 +1029,14 @@ export type DesktopMediaRead = typeof DesktopMediaReadSchema.Type; export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; +export const DesktopOpenRemoteTerminalInputSchema = Schema.Struct({ + host: TrimmedNonEmptyString, + username: Schema.optionalKey(TrimmedNonEmptyString), + absolutePath: TrimmedNonEmptyString, + isFile: Schema.optionalKey(Schema.Boolean), +}); +export type DesktopOpenRemoteTerminalInput = typeof DesktopOpenRemoteTerminalInputSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getRelayTelemetryEnabled: () => boolean; @@ -1122,6 +1130,8 @@ export interface DesktopBridge { openSystemSettings: (pane: SystemSettingsPane) => Promise; checkSystemPermission: (pane: SystemSettingsPane) => Promise; probeRemoteEditors: () => Promise; + /** Opens a local system terminal that SSHs to the remote host at absolutePath. */ + openRemoteTerminal: (input: DesktopOpenRemoteTerminalInput) => Promise; pasteAsText: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void; From 6738837e87e0b84bded4de7b6b8c4b95eaac552c Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 08:54:27 -0800 Subject: [PATCH 05/10] fix(desktop): harden remote Open in Terminal SSH launch Stop leading-dash hosts from becoming ssh options, run the remote cd/login through /bin/sh for fish/csh, and fix Windows Terminal, cmd.exe, and xfce4-terminal launch edge cases. Co-authored-by: Cursor --- .../src/shell/openRemoteTerminal.test.ts | 109 ++++++++++++++++-- apps/desktop/src/shell/openRemoteTerminal.ts | 45 +++++++- apps/web/src/remoteOpen.test.ts | 32 ++++- docs/user/terminal.md | 4 +- packages/contracts/src/editor.ts | 18 ++- packages/contracts/src/ipc.ts | 6 +- 6 files changed, 192 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts index 80a0e4acc0ba8..94f305f7998ab 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.test.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -7,7 +7,12 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { openRemoteTerminal } from "./openRemoteTerminal.ts"; +import { + cmdStartRemoteArgs, + linuxRemoteTerminalArgs, + openRemoteTerminal, + windowsTerminalRemoteArgs, +} from "./openRemoteTerminal.ts"; const captureSpawner = (spawned: Array<{ command: string; args: ReadonlyArray }>) => Layer.succeed( @@ -55,9 +60,9 @@ describe("openRemoteTerminal", () => { assert.equal(spawned[0]?.command, "osascript"); const script = spawned[0]?.args.join(" ") ?? ""; assert.match(script, /tell application "Terminal" to do script/); - // Each argv is shell-quoted: 'ssh' '-t' 'theo@sol' 'cd -- ...' - assert.match(script, /'ssh' '-t' 'theo@sol'/); - assert.match(script, /cd -- /); + // Each argv is shell-quoted: 'ssh' '-t' '--' 'theo@sol' 'exec /bin/sh -c ...' + assert.match(script, /'ssh' '-t' '--' 'theo@sol'/); + assert.match(script, /exec \/bin\/sh -c/); assert.match(script, /\/work\/project/); assert.match(script, /\$\{SHELL:-\/bin\/sh\}/); }), @@ -81,11 +86,101 @@ describe("openRemoteTerminal", () => { assert.deepEqual(spawned[0]?.args.slice(0, 3), ["/c", "start", ""]); assert.deepEqual( [...(spawned[0]?.args ?? [])].filter( - (arg) => arg === "ssh.exe" || arg === "-t" || arg === "box", + (arg) => arg === "ssh.exe" || arg === "-t" || arg === "--" || arg === "box", ), - ["ssh.exe", "-t", "box"], + ["ssh.exe", "-t", "--", "box"], ); - assert.isTrue((spawned[0]?.args ?? []).some((arg) => arg.includes("cd -- '/home/theo/app'"))); + assert.isTrue( + (spawned[0]?.args ?? []).some( + (arg) => arg.includes("exec /bin/sh -c") && arg.includes("'/home/theo/app'"), + ), + ); + }), + ); + + it.effect("escapes percent sequences on the cmd fallback", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "box", + absolutePath: "/home/%USERPROFILE%/app", + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, { PATH: "", Path: "", path: "" }), + ); + assert.isTrue(opened); + assert.equal(spawned[0]?.command, "cmd.exe"); + const remoteArg = (spawned[0]?.args ?? []).find((arg) => arg.includes("USERPROFILE")) ?? ""; + assert.match(remoteArg, /%%USERPROFILE%%/); }), ); }); + +describe("linuxRemoteTerminalArgs", () => { + it("runs SSH through xfce4-terminal -x", () => { + assert.deepEqual(linuxRemoteTerminalArgs("xfce4-terminal", "ssh", ["-t", "--", "box", "cmd"]), [ + "-x", + "ssh", + "-t", + "--", + "box", + "cmd", + ]); + }); + + it("keeps gnome-terminal option terminator", () => { + assert.deepEqual(linuxRemoteTerminalArgs("gnome-terminal", "ssh", ["-t", "--", "box"]), [ + "--", + "ssh", + "-t", + "--", + "box", + ]); + }); +}); + +describe("Windows launch argv", () => { + it("opens a new Windows Terminal window and escapes semicolon paths", () => { + assert.deepEqual( + windowsTerminalRemoteArgs("ssh.exe", [ + "-t", + "--", + "box", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/theo/weird;dir'", + ]), + [ + "-w", + "-1", + "nt", + "--", + "ssh.exe", + "-t", + "--", + "box", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/theo/weird\\;dir'", + ], + ); + }); + + it("doubles cmd percent signs in start argv", () => { + assert.deepEqual( + cmdStartRemoteArgs("ssh.exe", [ + "-t", + "--", + "box", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/%USERPROFILE%/app'", + ]), + [ + "/c", + "start", + "", + "ssh.exe", + "-t", + "--", + "box", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/%%USERPROFILE%%/app'", + ], + ); + }); +}); diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts index 750cfa3ed8fb5..682aef0c725e5 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -36,6 +36,36 @@ const quoteSh = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'` const escapeAppleScriptString = (value: string): string => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +/** wt.exe treats `;` as a command separator even inside quoted argv. */ +const escapeWindowsTerminalArg = (value: string): string => value.replaceAll(";", "\\;"); + +/** cmd.exe expands `%NAME%` in the /c string before start runs. */ +const escapeCmdPercent = (value: string): string => value.replaceAll("%", "%%"); + +export const windowsTerminalRemoteArgs = ( + sshCommand: string, + sshArgs: ReadonlyArray, +): ReadonlyArray => [ + // `-w -1` opens a new window; `-w 0` would reuse the last focused window. + "-w", + "-1", + "nt", + "--", + escapeWindowsTerminalArg(sshCommand), + ...sshArgs.map(escapeWindowsTerminalArg), +]; + +export const cmdStartRemoteArgs = ( + sshCommand: string, + sshArgs: ReadonlyArray, +): ReadonlyArray => [ + "/c", + "start", + "", + escapeCmdPercent(sshCommand), + ...sshArgs.map(escapeCmdPercent), +]; + const launchAndUnref = Effect.fn("desktop.openRemoteTerminal.launchAndUnref")(function* ( command: string, args: ReadonlyArray, @@ -49,7 +79,7 @@ const launchAndUnref = Effect.fn("desktop.openRemoteTerminal.launchAndUnref")(fu ); }); -const linuxTerminalArgs = ( +export const linuxRemoteTerminalArgs = ( command: string, sshCommand: string, sshArgs: ReadonlyArray, @@ -57,8 +87,10 @@ const linuxTerminalArgs = ( switch (command) { case "gnome-terminal": case "kgx": - case "xfce4-terminal": return ["--", sshCommand, ...sshArgs]; + case "xfce4-terminal": + // `--` only ends Xfce options; `-x` is required to run a command. + return ["-x", sshCommand, ...sshArgs]; default: return ["-e", sshCommand, ...sshArgs]; } @@ -92,11 +124,11 @@ const launchWindows = Effect.fn("desktop.openRemoteTerminal.launchWindows")(func env: NodeJS.ProcessEnv, ) { if (yield* isCommandAvailable("wt.exe", { env })) { - yield* launchAndUnref("wt.exe", ["-w", "0", "nt", "--", sshCommand, ...sshArgs]); + yield* launchAndUnref("wt.exe", windowsTerminalRemoteArgs(sshCommand, sshArgs)); return; } // `start`'s first quoted token is the window title; keep it empty so ssh is the command. - yield* launchAndUnref("cmd.exe", ["/c", "start", "", sshCommand, ...sshArgs], { + yield* launchAndUnref("cmd.exe", cmdStartRemoteArgs(sshCommand, sshArgs), { ...DETACHED_IGNORE_STDIO, shell: false, }); @@ -109,7 +141,7 @@ const launchLinux = Effect.fn("desktop.openRemoteTerminal.launchLinux")(function ) { const terminal = yield* resolveLinuxTerminalCommand(env); if (terminal === undefined) return false; - yield* launchAndUnref(terminal, linuxTerminalArgs(terminal, sshCommand, sshArgs)); + yield* launchAndUnref(terminal, linuxRemoteTerminalArgs(terminal, sshCommand, sshArgs)); return true; }); @@ -123,7 +155,8 @@ export const openRemoteTerminal = Effect.fn("desktop.openRemoteTerminal")(functi const destination = buildRemoteSshTerminalDestination(input.host, input.username); const remoteCommand = buildRemoteSshTerminalRemoteCommand(directory); const sshCommand = yield* resolveSshCommand; - const sshArgs = ["-t", destination, remoteCommand] as const; + // `--` ends option parsing so a destination that starts with `-` stays a hostname. + const sshArgs = ["-t", "--", destination, remoteCommand] as const; if (platform === "linux") { return yield* launchLinux(sshCommand, sshArgs, env).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 295868bef69ae..9d50393907884 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -7,13 +7,17 @@ import { buildRemoteOpenUrl, buildRemoteSshTerminalDestination, buildRemoteSshTerminalRemoteCommand, + DesktopOpenRemoteTerminalInputSchema, EnvironmentId, remoteTerminalWorkingDirectory, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { resolveRemoteOpenState } from "./remoteOpen"; +const decodeRemoteTerminalInput = Schema.decodeUnknownExit(DesktopOpenRemoteTerminalInputSchema); + const environmentId = EnvironmentId.make("environment-1"); const primaryTarget = (httpBaseUrl: string) => @@ -165,14 +169,36 @@ describe("remote SSH terminal helpers", () => { ); }); - it("quotes the remote cd path and leaves SHELL for the remote shell", () => { + it("runs cd and the login shell through /bin/sh for non-POSIX login shells", () => { expect(buildRemoteSshTerminalRemoteCommand("/work/my project")).toBe( - `cd -- '/work/my project' && exec "\${SHELL:-/bin/sh}" -l`, + `exec /bin/sh -c 'cd -- "$1" && exec "\${SHELL:-/bin/sh}" -l' sh '/work/my project'`, ); expect(buildRemoteSshTerminalRemoteCommand("/tmp/o'brian")).toBe( - `cd -- '/tmp/o'\\''brian' && exec "\${SHELL:-/bin/sh}" -l`, + `exec /bin/sh -c 'cd -- "$1" && exec "\${SHELL:-/bin/sh}" -l' sh '/tmp/o'\\''brian'`, ); }); + + it("rejects advertised hosts that would become ssh options", () => { + expect( + decodeRemoteTerminalInput({ + host: "-oProxyCommand=x", + absolutePath: "/work/project", + })._tag, + ).toBe("Failure"); + expect( + decodeRemoteTerminalInput({ + host: "sol", + username: "-oProxyCommand=x", + absolutePath: "/work/project", + })._tag, + ).toBe("Failure"); + expect( + decodeRemoteTerminalInput({ + host: "sol", + absolutePath: "/work/project", + })._tag, + ).toBe("Success"); + }); }); describe("buildRemoteOpenUrl", () => { diff --git a/docs/user/terminal.md b/docs/user/terminal.md index a5a7e5f4da5a5..5a8fa2cd570a8 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -29,7 +29,9 @@ starting with your desktop's default. When you are connected to a remote environment over SSH or an advertised Tailscale or local network host, Terminal (desktop app) opens a local system terminal that SSHs into that host and starts in the project folder, the same way -Open in opens editors over SSH. +Open in opens editors over SSH. The remote host needs a POSIX `/bin/sh` (Linux +and macOS SSH servers); Windows OpenSSH targets are not supported for this +action. On Windows desktop, T3 Code prefers Windows Terminal in a new window. ## fish diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 082496658cba5..b53eb297df085 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -246,10 +246,24 @@ export function buildRemoteSshTerminalDestination(host: string, username?: strin return username === undefined || username.length === 0 ? host : `${username}@${host}`; } -/** Single ssh remote-command argv: cd into the path, then replace with a login shell. */ +/** + * Host or username for a desktop SSH terminal launch. Leading dashes would be + * parsed as ssh options without `--`, so reject them at the IPC boundary too. + */ +export const SshTerminalDestinationSegment = TrimmedNonEmptyString.check( + Schema.makeFilter((value: string) => !value.startsWith("-"), { + expected: "a host or username that does not start with '-'", + }), +); +export type SshTerminalDestinationSegment = typeof SshTerminalDestinationSegment.Type; + +/** + * Single ssh remote-command argv: run POSIX cd + login shell through /bin/sh so + * fish/csh login shells do not have to parse parameter expansion. + */ export function buildRemoteSshTerminalRemoteCommand(directory: string): string { const quoted = `'${directory.replaceAll("'", `'\\''`)}'`; - return `cd -- ${quoted} && exec "\${SHELL:-/bin/sh}" -l`; + return `exec /bin/sh -c 'cd -- "$1" && exec "\${SHELL:-/bin/sh}" -l' sh ${quoted}`; } export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8804b3deabffb..775d508ab3528 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -13,7 +13,7 @@ import { AuthAccessTokenResult, AuthSessionState } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; -import type { EditorId } from "./editor.ts"; +import { SshTerminalDestinationSegment, type EditorId } from "./editor.ts"; import { KeybindingShortcut, type PreviewForwardedShortcut } from "./keybindings.ts"; import type { @@ -1030,8 +1030,8 @@ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export const DesktopOpenRemoteTerminalInputSchema = Schema.Struct({ - host: TrimmedNonEmptyString, - username: Schema.optionalKey(TrimmedNonEmptyString), + host: SshTerminalDestinationSegment, + username: Schema.optionalKey(SshTerminalDestinationSegment), absolutePath: TrimmedNonEmptyString, isFile: Schema.optionalKey(Schema.Boolean), }); From b0ecdef2cb543d6f192fcf789518bc615834594e Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 09:13:08 -0800 Subject: [PATCH 06/10] fix(desktop): avoid cmd.exe for remote Terminal SSH fallback PowerShell Start-Process keeps host and path metacharacters as SSH argv instead of letting cmd split on & and friends. Co-authored-by: Cursor --- .../src/shell/openRemoteTerminal.test.ts | 103 +++++++++++------- apps/desktop/src/shell/openRemoteTerminal.ts | 57 +++++++--- 2 files changed, 104 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts index 94f305f7998ab..f353896ddf661 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.test.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -8,12 +8,21 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { - cmdStartRemoteArgs, linuxRemoteTerminalArgs, openRemoteTerminal, + powerShellStartRemoteArgs, windowsTerminalRemoteArgs, } from "./openRemoteTerminal.ts"; +const decodePowerShellEncodedCommand = (encoded: string): string => { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new TextDecoder("utf-16le").decode(bytes); +}; + const captureSpawner = (spawned: Array<{ command: string; args: ReadonlyArray }>) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -68,7 +77,7 @@ describe("openRemoteTerminal", () => { }), ); - it.effect("falls back to cmd start on Windows when Windows Terminal is missing", () => + it.effect("falls back to PowerShell Start-Process when Windows Terminal is missing", () => Effect.gen(function* () { const spawned: Array<{ command: string; args: ReadonlyArray }> = []; const opened = yield* openRemoteTerminal({ @@ -78,41 +87,54 @@ describe("openRemoteTerminal", () => { }).pipe( Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), Effect.provideService(HostProcess.Platform, "win32"), - // Empty PATH so wt.exe is unavailable and the cmd.exe fallback runs. - Effect.provideService(HostProcess.Environment, { PATH: "", Path: "", path: "" }), + // Empty PATH so wt.exe is unavailable and the PowerShell fallback runs. + Effect.provideService(HostProcess.Environment, { + PATH: "", + Path: "", + path: "", + SYSTEMROOT: String.raw`C:\Windows`, + }), ); assert.isTrue(opened); - assert.equal(spawned[0]?.command, "cmd.exe"); - assert.deepEqual(spawned[0]?.args.slice(0, 3), ["/c", "start", ""]); - assert.deepEqual( - [...(spawned[0]?.args ?? [])].filter( - (arg) => arg === "ssh.exe" || arg === "-t" || arg === "--" || arg === "box", - ), - ["ssh.exe", "-t", "--", "box"], - ); - assert.isTrue( - (spawned[0]?.args ?? []).some( - (arg) => arg.includes("exec /bin/sh -c") && arg.includes("'/home/theo/app'"), - ), + assert.equal( + spawned[0]?.command, + String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, ); + assert.deepEqual(spawned[0]?.args.slice(0, 5), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const script = decodePowerShellEncodedCommand(spawned[0]?.args[5] ?? ""); + assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); + assert.match(script, /'-t','--','box'/); + assert.match(script, /exec \/bin\/sh -c/); + assert.match(script, /'\/home\/theo\/app'/); }), ); - it.effect("escapes percent sequences on the cmd fallback", () => + it.effect("keeps ampersand destinations inside PowerShell string literals", () => Effect.gen(function* () { const spawned: Array<{ command: string; args: ReadonlyArray }> = []; const opened = yield* openRemoteTerminal({ - host: "box", + host: "box&calc&rem", absolutePath: "/home/%USERPROFILE%/app", }).pipe( Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(HostProcess.Environment, { PATH: "", Path: "", path: "" }), + Effect.provideService(HostProcess.Environment, { + PATH: "", + Path: "", + path: "", + SYSTEMROOT: String.raw`C:\Windows`, + }), ); assert.isTrue(opened); - assert.equal(spawned[0]?.command, "cmd.exe"); - const remoteArg = (spawned[0]?.args ?? []).find((arg) => arg.includes("USERPROFILE")) ?? ""; - assert.match(remoteArg, /%%USERPROFILE%%/); + const script = decodePowerShellEncodedCommand(spawned[0]?.args[5] ?? ""); + assert.match(script, /ArgumentList @\('-t','--','box&calc&rem',/); + assert.match(script, /%USERPROFILE%/); }), ); }); @@ -163,24 +185,23 @@ describe("Windows launch argv", () => { ); }); - it("doubles cmd percent signs in start argv", () => { - assert.deepEqual( - cmdStartRemoteArgs("ssh.exe", [ - "-t", - "--", - "box", - "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/%USERPROFILE%/app'", - ]), - [ - "/c", - "start", - "", - "ssh.exe", - "-t", - "--", - "box", - "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/%%USERPROFILE%%/app'", - ], - ); + it("encodes PowerShell Start-Process argv without cmd metacharacter splits", () => { + const args = powerShellStartRemoteArgs("ssh.exe", [ + "-t", + "--", + "box&calc&rem", + "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/%USERPROFILE%/app'", + ]); + assert.deepEqual(args.slice(0, 5), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const script = decodePowerShellEncodedCommand(args[5] ?? ""); + assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); + assert.match(script, /ArgumentList @\('-t','--','box&calc&rem',/); + assert.match(script, /%USERPROFILE%/); }); }); diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts index 682aef0c725e5..1829af5ef3c23 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -8,6 +8,7 @@ import { resolveSshCommand } from "@t3tools/ssh/command"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; +import * as Base64 from "effect/encoding/Base64"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -31,6 +32,21 @@ const DETACHED_IGNORE_STDIO = { stderr: "ignore", } as const; +const POWERSHELL_ATTACHED_STDIO = { + // Detached Windows PowerShell exits 0 without executing the encoded command. + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +} as const; + +const POWERSHELL_ARGUMENTS_PREFIX = [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", +] as const; + const quoteSh = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`; const escapeAppleScriptString = (value: string): string => @@ -39,8 +55,20 @@ const escapeAppleScriptString = (value: string): string => /** wt.exe treats `;` as a command separator even inside quoted argv. */ const escapeWindowsTerminalArg = (value: string): string => value.replaceAll(";", "\\;"); -/** cmd.exe expands `%NAME%` in the /c string before start runs. */ -const escapeCmdPercent = (value: string): string => value.replaceAll("%", "%%"); +const escapePowerShellStringLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; + +const encodeUtf16LeBase64 = (input: string): string => { + const bytes = new Uint8Array(input.length * 2); + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + bytes[index * 2] = code & 0xff; + bytes[index * 2 + 1] = code >>> 8; + } + return Base64.encode(bytes); +}; + +const resolvePowerShellPath = (env: NodeJS.ProcessEnv): string => + `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; export const windowsTerminalRemoteArgs = ( sshCommand: string, @@ -55,16 +83,15 @@ export const windowsTerminalRemoteArgs = ( ...sshArgs.map(escapeWindowsTerminalArg), ]; -export const cmdStartRemoteArgs = ( +/** Avoids cmd.exe parsing so metacharacters in host/path stay SSH argv. */ +export const powerShellStartRemoteArgs = ( sshCommand: string, sshArgs: ReadonlyArray, -): ReadonlyArray => [ - "/c", - "start", - "", - escapeCmdPercent(sshCommand), - ...sshArgs.map(escapeCmdPercent), -]; +): ReadonlyArray => { + const argumentList = sshArgs.map(escapePowerShellStringLiteral).join(","); + const script = `$ProgressPreference = 'SilentlyContinue'; Start-Process -FilePath ${escapePowerShellStringLiteral(sshCommand)} -ArgumentList @(${argumentList})`; + return [...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(script)]; +}; const launchAndUnref = Effect.fn("desktop.openRemoteTerminal.launchAndUnref")(function* ( command: string, @@ -127,11 +154,11 @@ const launchWindows = Effect.fn("desktop.openRemoteTerminal.launchWindows")(func yield* launchAndUnref("wt.exe", windowsTerminalRemoteArgs(sshCommand, sshArgs)); return; } - // `start`'s first quoted token is the window title; keep it empty so ssh is the command. - yield* launchAndUnref("cmd.exe", cmdStartRemoteArgs(sshCommand, sshArgs), { - ...DETACHED_IGNORE_STDIO, - shell: false, - }); + yield* launchAndUnref( + resolvePowerShellPath(env), + powerShellStartRemoteArgs(sshCommand, sshArgs), + POWERSHELL_ATTACHED_STDIO, + ); }); const launchLinux = Effect.fn("desktop.openRemoteTerminal.launchLinux")(function* ( From 62754ee6acb242ba34dbc179d86648807bae6b12 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 09:27:38 -0800 Subject: [PATCH 07/10] fix(desktop): quote SSH argv for PowerShell Start-Process Start-Process joins ArgumentList without re-quoting, so serialize the SSH args for Windows command-line parsing first. Co-authored-by: Cursor --- .../src/shell/openRemoteTerminal.test.ts | 37 +++++++++---------- apps/desktop/src/shell/openRemoteTerminal.ts | 14 ++++++- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts index f353896ddf661..1d97148cc8ab4 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.test.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { buildRemoteSshTerminalRemoteCommand } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -11,6 +12,7 @@ import { linuxRemoteTerminalArgs, openRemoteTerminal, powerShellStartRemoteArgs, + serializeWindowsSshArgumentList, windowsTerminalRemoteArgs, } from "./openRemoteTerminal.ts"; @@ -109,13 +111,13 @@ describe("openRemoteTerminal", () => { ]); const script = decodePowerShellEncodedCommand(spawned[0]?.args[5] ?? ""); assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); - assert.match(script, /'-t','--','box'/); + assert.match(script, /ArgumentList '-t -- box "/); assert.match(script, /exec \/bin\/sh -c/); - assert.match(script, /'\/home\/theo\/app'/); + assert.match(script, /\/home\/theo\/app/); }), ); - it.effect("keeps ampersand destinations inside PowerShell string literals", () => + it.effect("keeps ampersand destinations inside the Windows ArgumentList string", () => Effect.gen(function* () { const spawned: Array<{ command: string; args: ReadonlyArray }> = []; const opened = yield* openRemoteTerminal({ @@ -133,7 +135,7 @@ describe("openRemoteTerminal", () => { ); assert.isTrue(opened); const script = decodePowerShellEncodedCommand(spawned[0]?.args[5] ?? ""); - assert.match(script, /ArgumentList @\('-t','--','box&calc&rem',/); + assert.match(script, /ArgumentList '-t -- box&calc&rem "/); assert.match(script, /%USERPROFILE%/); }), ); @@ -185,23 +187,18 @@ describe("Windows launch argv", () => { ); }); - it("encodes PowerShell Start-Process argv without cmd metacharacter splits", () => { - const args = powerShellStartRemoteArgs("ssh.exe", [ - "-t", - "--", - "box&calc&rem", - "exec /bin/sh -c 'cd -- \"$1\" && exec \"${SHELL:-/bin/sh}\" -l' sh '/home/%USERPROFILE%/app'", - ]); - assert.deepEqual(args.slice(0, 5), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - ]); + it("quotes spaces, quotes, and backslashes for Start-Process ArgumentList", () => { + const remoteCommand = buildRemoteSshTerminalRemoteCommand(String.raw`/work/my "proj"\path`); + const serialized = serializeWindowsSshArgumentList(["-t", "--", "box", remoteCommand]); + assert.equal( + serialized, + `-t -- box "exec /bin/sh -c 'cd -- \\"$1\\" && exec \\"\${SHELL:-/bin/sh}\\" -l' sh '/work/my \\"proj\\"\\path'"`, + ); + + const args = powerShellStartRemoteArgs("ssh.exe", ["-t", "--", "box&calc&rem", remoteCommand]); const script = decodePowerShellEncodedCommand(args[5] ?? ""); assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); - assert.match(script, /ArgumentList @\('-t','--','box&calc&rem',/); - assert.match(script, /%USERPROFILE%/); + assert.match(script, /ArgumentList '-t -- box&calc&rem "/); + assert.match(script, /my \\"proj\\"\\path/); }); }); diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts index 1829af5ef3c23..e934dd60b4dc1 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -8,9 +8,11 @@ import { resolveSshCommand } from "@t3tools/ssh/command"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Base64 from "effect/encoding/Base64"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as CommandLine from "effect/process/CommandLine"; const LINUX_TERMINAL_COMMANDS = [ "xdg-terminal-exec", @@ -83,13 +85,21 @@ export const windowsTerminalRemoteArgs = ( ...sshArgs.map(escapeWindowsTerminalArg), ]; +/** + * Start-Process joins ArgumentList array elements with spaces and does not + * re-quote them. Serialize once for CommandLineToArgvW, then pass that string + * as a single ArgumentList value so spaces/quotes/backslashes survive. + */ +export const serializeWindowsSshArgumentList = (sshArgs: ReadonlyArray): string => + sshArgs.map((arg) => Result.getOrThrow(CommandLine.quote(arg, { format: "windows" }))).join(" "); + /** Avoids cmd.exe parsing so metacharacters in host/path stay SSH argv. */ export const powerShellStartRemoteArgs = ( sshCommand: string, sshArgs: ReadonlyArray, ): ReadonlyArray => { - const argumentList = sshArgs.map(escapePowerShellStringLiteral).join(","); - const script = `$ProgressPreference = 'SilentlyContinue'; Start-Process -FilePath ${escapePowerShellStringLiteral(sshCommand)} -ArgumentList @(${argumentList})`; + const argumentList = escapePowerShellStringLiteral(serializeWindowsSshArgumentList(sshArgs)); + const script = `$ProgressPreference = 'SilentlyContinue'; Start-Process -FilePath ${escapePowerShellStringLiteral(sshCommand)} -ArgumentList ${argumentList}`; return [...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(script)]; }; From da2d2ce6cacc3e894f36e6a78795efec019fbc5c Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 09:37:08 -0800 Subject: [PATCH 08/10] fix(desktop): escape PowerShell smart quotes in remote Terminal Typographic single quotes can close a PowerShell string literal; use Effect CommandLine quoting so they stay inside ArgumentList. Co-authored-by: Cursor --- apps/desktop/src/shell/openRemoteTerminal.test.ts | 10 ++++++++++ apps/desktop/src/shell/openRemoteTerminal.ts | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts index 1d97148cc8ab4..2702cfc6a4abc 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.test.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -201,4 +201,14 @@ describe("Windows launch argv", () => { assert.match(script, /ArgumentList '-t -- box&calc&rem "/); assert.match(script, /my \\"proj\\"\\path/); }); + + it("keeps smart single quotes inside PowerShell string literals", () => { + const host = "box\u2019 ; Start-Process calc; #"; + const args = powerShellStartRemoteArgs("ssh.exe", ["-t", "--", host, "remote"]); + const script = decodePowerShellEncodedCommand(args[5] ?? ""); + assert.match(script, /Start-Process -FilePath 'ssh\.exe'/); + // Typographic ’ is doubled so it cannot close the ArgumentList literal. + assert.match(script, /ArgumentList '-t -- "box\u2019\u2019 ; Start-Process calc; #" remote'/); + assert.isFalse(script.includes(`box\u2019 ; Start-Process`)); + }); }); diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts index e934dd60b4dc1..bf7292a080efb 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -57,7 +57,9 @@ const escapeAppleScriptString = (value: string): string => /** wt.exe treats `;` as a command separator even inside quoted argv. */ const escapeWindowsTerminalArg = (value: string): string => value.replaceAll(";", "\\;"); -const escapePowerShellStringLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; +/** Doubles ASCII and typographic single quotes so they cannot end a PS literal. */ +const escapePowerShellStringLiteral = (value: string): string => + Result.getOrThrow(CommandLine.quote(value, { format: "powershell" })); const encodeUtf16LeBase64 = (input: string): string => { const bytes = new Uint8Array(input.length * 2); From d2f9d6961f0df28e2a1c3a8fa699f2dbf2dd03dc Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 09:50:29 -0800 Subject: [PATCH 09/10] fix(desktop): pass profile SSH port to Open in Terminal Manual SSH profiles with a non-default port already use -p for the tunnel; carry that same port through remote-open state and ssh -p before --. Co-authored-by: Cursor --- .../src/shell/openRemoteTerminal.test.ts | 19 +++++++++++++++ apps/desktop/src/shell/openRemoteTerminal.ts | 10 ++++++-- apps/web/src/remoteOpen.test.ts | 23 +++++++++++++++++++ apps/web/src/remoteOpen.ts | 14 +++++++++-- packages/contracts/src/ipc.ts | 4 +++- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts index 2702cfc6a4abc..67d9eef73a6e8 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.test.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -79,6 +79,25 @@ describe("openRemoteTerminal", () => { }), ); + it.effect("passes -p before -- when the profile sets a non-default SSH port", () => + Effect.gen(function* () { + const spawned: Array<{ command: string; args: ReadonlyArray }> = []; + const opened = yield* openRemoteTerminal({ + host: "sol", + username: "theo", + port: 2222, + absolutePath: "/work/project", + }).pipe( + Effect.provide(Layer.merge(NodeServices.layer, captureSpawner(spawned))), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, {}), + ); + assert.isTrue(opened); + const script = spawned[0]?.args.join(" ") ?? ""; + assert.match(script, /'ssh' '-t' '-p' '2222' '--' 'theo@sol'/); + }), + ); + it.effect("falls back to PowerShell Start-Process when Windows Terminal is missing", () => Effect.gen(function* () { const spawned: Array<{ command: string; args: ReadonlyArray }> = []; diff --git a/apps/desktop/src/shell/openRemoteTerminal.ts b/apps/desktop/src/shell/openRemoteTerminal.ts index bf7292a080efb..46ffa34831743 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.ts @@ -194,8 +194,14 @@ export const openRemoteTerminal = Effect.fn("desktop.openRemoteTerminal")(functi const destination = buildRemoteSshTerminalDestination(input.host, input.username); const remoteCommand = buildRemoteSshTerminalRemoteCommand(directory); const sshCommand = yield* resolveSshCommand; - // `--` ends option parsing so a destination that starts with `-` stays a hostname. - const sshArgs = ["-t", "--", destination, remoteCommand] as const; + // `-p` before `--`; `--` ends option parsing so a leading-dash destination stays a hostname. + const sshArgs = [ + "-t", + ...(input.port === undefined ? [] : (["-p", String(input.port)] as const)), + "--", + destination, + remoteCommand, + ] as const; if (platform === "linux") { return yield* launchLinux(sshCommand, sshArgs, env).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 9d50393907884..11a5bcb366944 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -95,6 +95,29 @@ describe("resolveRemoteOpenState", () => { ); }); + it("carries the profile SSH port into remote-links state for Terminal", () => { + const state = resolveRemoteOpenState({ + target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), + sshTarget: { alias: "sol", username: "theo", port: 2222 }, + isDesktopRenderer: true, + remoteOpenTargets: [], + }); + expect(state).toEqual({ + mode: "remote-links", + host: { kind: "ssh-alias", host: "sol", username: "theo", port: 2222 }, + }); + }); + + it("omits a null profile SSH port so OpenSSH uses config / default 22", () => { + const state = resolveRemoteOpenState({ + target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }), + sshTarget: { alias: "sol", username: null, port: null }, + isDesktopRenderer: true, + remoteOpenTargets: [], + }); + expect(state).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); + }); + it.each([ { alias: "server", username: "admin-user" }, { alias: "admin-user@server", username: null }, diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 33bb7cfc13762..7f17d770dc649 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -19,6 +19,8 @@ export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; readonly host: string; readonly username?: string; + /** SSH listen port from the connection profile; not the forwarded HTTP port. */ + readonly port?: number; } export type RemoteOpenState = @@ -50,7 +52,11 @@ function parseHostname(url: string): string | null { export function resolveRemoteOpenState(input: { readonly target: ConnectionTarget | null; - readonly sshTarget: { readonly alias: string; readonly username: string | null } | null; + readonly sshTarget: { + readonly alias: string; + readonly username: string | null; + readonly port?: number | null; + } | null; readonly remoteOpenTargets: ReadonlyArray | undefined; readonly isDesktopRenderer: boolean; }): RemoteOpenState { @@ -78,12 +84,14 @@ export function resolveRemoteOpenState(input: { const username = input.sshTarget.username?.trim() || (userSeparator === -1 ? null : alias.slice(0, userSeparator)); + const port = input.sshTarget.port ?? null; return { mode: "remote-links", host: { kind: "ssh-alias", host, ...(username === null ? {} : { username }), + ...(port === null ? {} : { port }), }, }; } @@ -100,7 +108,8 @@ function remoteOpenStatesEqual(previous: RemoteOpenState, next: RemoteOpenState) return ( previous.host.kind === next.host.kind && previous.host.host === next.host.host && - previous.host.username === next.host.username + previous.host.username === next.host.username && + previous.host.port === next.host.port ); } @@ -194,6 +203,7 @@ export async function openRemoteTerminal(input: { return await bridge.openRemoteTerminal({ host: input.host.host, ...(input.host.username === undefined ? {} : { username: input.host.username }), + ...(input.host.port === undefined ? {} : { port: input.host.port }), absolutePath: input.absolutePath, ...(input.isFile === undefined ? {} : { isFile: input.isFile }), }); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 775d508ab3528..1622d545e2c24 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import type { PreviewCaptureFailureEncoded } from "./previewCapture.ts"; import { SnapShotSource } from "./chatAttachment.ts"; -import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, PortSchema, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; import type { BrowserImportResult, @@ -1032,6 +1032,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export const DesktopOpenRemoteTerminalInputSchema = Schema.Struct({ host: SshTerminalDestinationSegment, username: Schema.optionalKey(SshTerminalDestinationSegment), + /** Profile SSH port when set; omit so OpenSSH uses config / default 22. */ + port: Schema.optionalKey(PortSchema), absolutePath: TrimmedNonEmptyString, isFile: Schema.optionalKey(Schema.Boolean), }); From d400775ea7c454427f7c4de32b872e1a47cb62cf Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Fri, 6 Nov 2026 15:57:01 -0800 Subject: [PATCH 10/10] test(desktop): reject leading-dash SSH terminal destinations Cover the IPC schema guard that blocks host/username values starting with '-', so the defense-in-depth check cannot regress silently. Co-authored-by: Cursor --- .../src/shell/openRemoteTerminal.test.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/shell/openRemoteTerminal.test.ts b/apps/desktop/src/shell/openRemoteTerminal.test.ts index 67d9eef73a6e8..2af34611aec3b 100644 --- a/apps/desktop/src/shell/openRemoteTerminal.test.ts +++ b/apps/desktop/src/shell/openRemoteTerminal.test.ts @@ -1,9 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { buildRemoteSshTerminalRemoteCommand } from "@t3tools/contracts"; +import { + buildRemoteSshTerminalRemoteCommand, + DesktopOpenRemoteTerminalInputSchema, +} from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -231,3 +236,42 @@ describe("Windows launch argv", () => { assert.isFalse(script.includes(`box\u2019 ; Start-Process`)); }); }); + +describe("DesktopOpenRemoteTerminalInputSchema", () => { + const decode = Schema.decodeUnknownExit(DesktopOpenRemoteTerminalInputSchema); + + it("accepts ordinary host and username segments", () => { + assert.isTrue( + Exit.isSuccess( + decode({ + host: "sol", + username: "theo", + absolutePath: "/work/project", + }), + ), + ); + }); + + it("rejects a leading-dash host that would look like an SSH option", () => { + assert.isTrue( + Exit.isFailure( + decode({ + host: "-oProxyCommand=touch,/tmp/pwned", + absolutePath: "/work/project", + }), + ), + ); + }); + + it("rejects a leading-dash username", () => { + assert.isTrue( + Exit.isFailure( + decode({ + host: "sol", + username: "-oProxyCommand=touch,/tmp/pwned", + absolutePath: "/work/project", + }), + ), + ); + }); +});