From d3efb4344f449c4d6c2cbd21777d64e558822296 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 10 Sep 2026 00:45:28 +0000 Subject: [PATCH 1/2] fix(editors): open remote projects in Zed Zed had no remote scheme in the editor registry, so remote and SSH environments dropped it from the Open picker and fell back to VS Code. Zed uses `zed://ssh//` rather than VS Code's `vscode://vscode-remote/ssh-remote+`, so the registry now records a link style alongside the scheme, the link builder emits the right shape, and the desktop shell allowlist accepts each scheme only in its own editor's shape. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/electron/ElectronShell.test.ts | 27 ++++++++++ apps/desktop/src/electron/ElectronShell.ts | 39 ++++++++++---- apps/web/src/remoteOpen.test.ts | 12 ++++- apps/web/src/remoteOpen.ts | 6 +-- packages/contracts/src/editor.ts | 52 +++++++++++++++---- 5 files changed, 110 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0d..9df86a52533e 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -88,6 +88,33 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index cda9c2567b37..9fa24d367089 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,6 +1,8 @@ import { REMOTE_CAPABLE_EDITOR_IDS, + remoteLinkStyleForEditor, remoteSchemeForEditor, + type EditorRemoteLinkStyle, type SystemSettingsPane, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -24,23 +26,38 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…` and +// `zed://ssh//`) must reach the OS handler; every other non-web +// scheme stays blocked. Each scheme only unlocks its own editor's link shape, +// so a Zed link cannot ride in on a VS Code scheme or the reverse. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); -const REMOTE_EDITOR_PROTOCOLS = new Set( +const REMOTE_EDITOR_PROTOCOLS = new Map( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { const scheme = remoteSchemeForEditor(id); - return scheme === undefined ? [] : [`${scheme}:`]; + const style = remoteLinkStyleForEditor(id); + return scheme === undefined || style === undefined + ? [] + : [[`${scheme}:`, style] as [string, EditorRemoteLinkStyle]]; }), ); -const isRemoteEditorUrl = (url: URL) => - REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && - url.username.length === 0 && - url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; +// `zed://ssh//`: a host segment plus a non-empty path. +const ZED_SSH_PATHNAME = /^\/[^/]+\/.+$/; + +const isRemoteEditorUrl = (url: URL) => { + const style = REMOTE_EDITOR_PROTOCOLS.get(url.protocol); + if (style === undefined || url.username.length > 0 || url.password.length > 0) { + return false; + } + if (style === "zed-ssh") { + return url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname); + } + return ( + url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length + ); +}; export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3dc..6f7c17d8177a 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); + it("builds Zed's ssh deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 7f23d408844e..0c795d5bc8fc 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -1,8 +1,8 @@ /** * Remote open-in-editor: when this client is not on the environment's - * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` - * deep link (local editor connects over SSH) instead of exec'ing an editor - * on the environment host. + * machine, "Open" must hand the OS an editor deep link + * (`vscode://vscode-remote/ssh-remote+…`, `zed://ssh/…`) so the local editor + * connects over SSH instead of exec'ing an editor on the environment host. * * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias * beats server-advertised names; among advertised names the tailnet MagicDNS diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 72efd84a14d6..0fc77f71b69d 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -4,6 +4,9 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; +/** Deep-link shapes editors use to open a remote workspace over SSH. */ +export type EditorRemoteLinkStyle = "vscode-remote" | "zed-ssh"; + type EditorDefinition = { readonly id: string; readonly label: string; @@ -11,11 +14,16 @@ type EditorDefinition = { readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; /** - * URL scheme for editors that support VS Code's remote deep links - * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery. + * URL scheme for editors that can open a remote workspace over SSH. Only set + * for editors that ship the remote machinery; see `remoteLinkStyle` for the + * shape of the link the scheme expects. */ readonly remoteScheme?: string; + /** + * Shape of `remoteScheme`'s deep link. Defaults to VS Code's + * `://vscode-remote/ssh-remote+`, which its forks share. + */ + readonly remoteLinkStyle?: EditorRemoteLinkStyle; }; export const EDITORS = [ @@ -49,7 +57,14 @@ export const EDITORS = [ launchStyle: "goto", remoteScheme: "vscodium", }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteScheme: "zed", + remoteLinkStyle: "zed-ssh", + }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, @@ -84,7 +99,10 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type; const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; -/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +const remoteLinkStyleOf = (editor: EditorDefinition): EditorRemoteLinkStyle | undefined => + editor.remoteScheme === undefined ? undefined : (editor.remoteLinkStyle ?? "vscode-remote"); + +/** Editors that can open a remote workspace via an SSH deep link. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => remoteSchemeOf(editor) !== undefined ? [editor.id] : [], ); @@ -94,10 +112,17 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { return editor === undefined ? undefined : remoteSchemeOf(editor); }; +/** Link shape `remoteSchemeForEditor` returns a scheme for, if any. */ +export const remoteLinkStyleForEditor = (id: EditorId): EditorRemoteLinkStyle | undefined => { + const editor = EDITORS.find((candidate) => candidate.id === id); + return editor === undefined ? undefined : remoteLinkStyleOf(editor); +}; + /** - * Builds a `://vscode-remote/ssh-remote+` deep link that - * opens `absolutePath` on `host` in the local editor over SSH. Returns - * undefined for editors without remote deep-link support. + * Builds the deep link that opens `absolutePath` on `host` in the local editor + * over SSH: `://vscode-remote/ssh-remote+` for VS Code and + * its forks, `zed://ssh/` for Zed. Returns undefined for editors + * without remote deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -105,14 +130,19 @@ export const buildRemoteOpenUrl = (input: { readonly absolutePath: string; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); - if (scheme === undefined) { + const style = remoteLinkStyleForEditor(input.editor); + if (scheme === undefined || style === undefined) { return undefined; } - // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. + // Windows server paths (`C:\...`) appear as `/C:/...` in the remote URI, and + // percent-encoding keeps the drive colon out of Zed's SCP-style host split. const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + const encodedHost = encodeURIComponent(input.host); + return style === "zed-ssh" + ? `${scheme}://ssh/${encodedHost}${encodedPath}` + : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; /** From 3c5cd906e475a44010e22044555eb2d314c915d6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 10 Sep 2026 00:53:42 +0000 Subject: [PATCH 2/2] refactor(editors): shrink the Zed remote link change Drop the link-style helper and the desktop scheme map: a `remoteScheme` entry plus one `editor === "zed"` branch in the link builder and one `zed:` branch in the shell allowlist cover the same behavior. The Zed branch bans userinfo in the host path segment, matching the rule the vscode-remote branch already applies. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/electron/ElectronShell.test.ts | 3 +- apps/desktop/src/electron/ElectronShell.ts | 41 +++++++----------- apps/web/src/remoteOpen.ts | 6 +-- packages/contracts/src/editor.ts | 43 ++++++------------- 4 files changed, 32 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9df86a52533e..75eea216df21 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -127,9 +127,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 9fa24d367089..2089be58c0dc 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,8 +1,6 @@ import { REMOTE_CAPABLE_EDITOR_IDS, - remoteLinkStyleForEditor, remoteSchemeForEditor, - type EditorRemoteLinkStyle, type SystemSettingsPane, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -26,38 +24,29 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…` and +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, // `zed://ssh//`) must reach the OS handler; every other non-web -// scheme stays blocked. Each scheme only unlocks its own editor's link shape, -// so a Zed link cannot ride in on a VS Code scheme or the reverse. +// scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); -const REMOTE_EDITOR_PROTOCOLS = new Map( +const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { const scheme = remoteSchemeForEditor(id); - const style = remoteLinkStyleForEditor(id); - return scheme === undefined || style === undefined - ? [] - : [[`${scheme}:`, style] as [string, EditorRemoteLinkStyle]]; + return scheme === undefined ? [] : [`${scheme}:`]; }), ); -// `zed://ssh//`: a host segment plus a non-empty path. -const ZED_SSH_PATHNAME = /^\/[^/]+\/.+$/; +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; -const isRemoteEditorUrl = (url: URL) => { - const style = REMOTE_EDITOR_PROTOCOLS.get(url.protocol); - if (style === undefined || url.username.length > 0 || url.password.length > 0) { - return false; - } - if (style === "zed-ssh") { - return url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname); - } - return ( - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length - ); -}; +const isRemoteEditorUrl = (url: URL) => + REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && + url.username.length === 0 && + url.password.length === 0 && + (url.protocol === "zed:" + ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 0c795d5bc8fc..7f23d408844e 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -1,8 +1,8 @@ /** * Remote open-in-editor: when this client is not on the environment's - * machine, "Open" must hand the OS an editor deep link - * (`vscode://vscode-remote/ssh-remote+…`, `zed://ssh/…`) so the local editor - * connects over SSH instead of exec'ing an editor on the environment host. + * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` + * deep link (local editor connects over SSH) instead of exec'ing an editor + * on the environment host. * * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias * beats server-advertised names; among advertised names the tailnet MagicDNS diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0fc77f71b69d..6331a544d341 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -4,9 +4,6 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; -/** Deep-link shapes editors use to open a remote workspace over SSH. */ -export type EditorRemoteLinkStyle = "vscode-remote" | "zed-ssh"; - type EditorDefinition = { readonly id: string; readonly label: string; @@ -14,16 +11,12 @@ type EditorDefinition = { readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; /** - * URL scheme for editors that can open a remote workspace over SSH. Only set - * for editors that ship the remote machinery; see `remoteLinkStyle` for the - * shape of the link the scheme expects. + * URL scheme for editors that support VS Code's remote deep links + * (`://vscode-remote/ssh-remote+`). Only set for VS Code + * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own + * `zed://ssh/` shape. */ readonly remoteScheme?: string; - /** - * Shape of `remoteScheme`'s deep link. Defaults to VS Code's - * `://vscode-remote/ssh-remote+`, which its forks share. - */ - readonly remoteLinkStyle?: EditorRemoteLinkStyle; }; export const EDITORS = [ @@ -63,7 +56,6 @@ export const EDITORS = [ commands: ["zed", "zeditor"], launchStyle: "direct-path", remoteScheme: "zed", - remoteLinkStyle: "zed-ssh", }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, @@ -99,10 +91,7 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type; const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; -const remoteLinkStyleOf = (editor: EditorDefinition): EditorRemoteLinkStyle | undefined => - editor.remoteScheme === undefined ? undefined : (editor.remoteLinkStyle ?? "vscode-remote"); - -/** Editors that can open a remote workspace via an SSH deep link. */ +/** Editors that can open a remote workspace via `vscode-remote` deep links. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => remoteSchemeOf(editor) !== undefined ? [editor.id] : [], ); @@ -112,17 +101,11 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { return editor === undefined ? undefined : remoteSchemeOf(editor); }; -/** Link shape `remoteSchemeForEditor` returns a scheme for, if any. */ -export const remoteLinkStyleForEditor = (id: EditorId): EditorRemoteLinkStyle | undefined => { - const editor = EDITORS.find((candidate) => candidate.id === id); - return editor === undefined ? undefined : remoteLinkStyleOf(editor); -}; - /** - * Builds the deep link that opens `absolutePath` on `host` in the local editor - * over SSH: `://vscode-remote/ssh-remote+` for VS Code and - * its forks, `zed://ssh/` for Zed. Returns undefined for editors - * without remote deep-link support. + * Builds a `://vscode-remote/ssh-remote+` deep link (Zed + * takes `zed://ssh/`) that opens `absolutePath` on `host` in the + * local editor over SSH. Returns undefined for editors without remote + * deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -130,17 +113,15 @@ export const buildRemoteOpenUrl = (input: { readonly absolutePath: string; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); - const style = remoteLinkStyleForEditor(input.editor); - if (scheme === undefined || style === undefined) { + if (scheme === undefined) { return undefined; } - // Windows server paths (`C:\...`) appear as `/C:/...` in the remote URI, and - // percent-encoding keeps the drive colon out of Zed's SCP-style host split. + // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); const encodedHost = encodeURIComponent(input.host); - return style === "zed-ssh" + return input.editor === "zed" ? `${scheme}://ssh/${encodedHost}${encodedPath}` : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; };