| author | |
| committer | |
| log | 0f602b3372b300ae94084bd3fe7dbaadaa58ba3a |
| tree | 9c9617f259e1199b56e7e89e80ec8ff8e74d6e8a |
| parent | d1eeb16247a0bd2eca8bbbfa5ab777e096af949c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>4 files changed, 68 insertions(+), 13 deletions(-)
apps/desktop/src/electron/ElectronShell.test.ts+29-1| ... | ... | @@ -88,6 +88,33 @@ describe("ElectronShell", () => { |
| 88 | 88 | }).pipe(Effect.provide(ElectronShell.layer)), |
| 89 | 89 | ); |
| 90 | 90 | |
| 91 | it.effect("opens Zed's ssh deep link", () => | |
| 92 | Effect.gen(function* () { | |
| 93 | openExternalMock.mockResolvedValue(undefined); | |
| 94 | ||
| 95 | const electronShell = yield* ElectronShell.ElectronShell; | |
| 96 | const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); | |
| 97 | ||
| 98 | assert.equal(result, true); | |
| 99 | assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); | |
| 100 | }).pipe(Effect.provide(ElectronShell.layer)), | |
| 101 | ); | |
| 102 | ||
| 103 | it.effect("does not open editor URLs that mix up link shapes", () => | |
| 104 | Effect.gen(function* () { | |
| 105 | openExternalMock.mockResolvedValue(undefined); | |
| 106 | ||
| 107 | const electronShell = yield* ElectronShell.ElectronShell; | |
| 108 | const results = yield* Effect.all([ | |
| 109 | electronShell.openExternal("zed://extension/attacker"), | |
| 110 | electronShell.openExternal("vscode://ssh/example.com/home/user/project"), | |
| 111 | ]); | |
| 112 | ||
| 113 | assert.deepEqual(results, [false, false]); | |
| 114 | assert.equal(openExternalMock.mock.calls.length, 0); | |
| 115 | }).pipe(Effect.provide(ElectronShell.layer)), | |
| 116 | ); | |
| 117 | ||
| 91 | 118 | it.effect("does not open remote editor URLs with userinfo", () => |
| 92 | 119 | Effect.gen(function* () { |
| 93 | 120 | openExternalMock.mockResolvedValue(undefined); |
| ... | ... | @@ -100,9 +127,10 @@ describe("ElectronShell", () => { |
| 100 | 127 | electronShell.openExternal( |
| 101 | 128 | "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", |
| 102 | 129 | ), |
| 130 | electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), | |
| 103 | 131 | ]); |
| 104 | 132 | |
| 105 | assert.deepEqual(results, [false, false]); | |
| 133 | assert.deepEqual(results, [false, false, false]); | |
| 106 | 134 | assert.equal(openExternalMock.mock.calls.length, 0); |
| 107 | 135 | }).pipe(Effect.provide(ElectronShell.layer)), |
| 108 | 136 | ); |
apps/desktop/src/electron/ElectronShell.ts+11-5| ... | ... | @@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = { |
| 24 | 24 | "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", |
| 25 | 25 | }; |
| 26 | 26 | |
| 27 | // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) | |
| 28 | // must reach the OS handler; every other non-web scheme stays blocked. | |
| 27 | // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, | |
| 28 | // `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web | |
| 29 | // scheme stays blocked. | |
| 29 | 30 | const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); |
| 30 | 31 | const REMOTE_EDITOR_PROTOCOLS = new Set( |
| 31 | 32 | REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { |
| ... | ... | @@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( |
| 34 | 35 | }), |
| 35 | 36 | ); |
| 36 | 37 | |
| 38 | // Zed's host sits in the first path segment, so it needs its own userinfo ban. | |
| 39 | const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; | |
| 40 | ||
| 37 | 41 | const isRemoteEditorUrl = (url: URL) => |
| 38 | 42 | REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && |
| 39 | 43 | url.username.length === 0 && |
| 40 | 44 | url.password.length === 0 && |
| 41 | url.host === "vscode-remote" && | |
| 42 | url.pathname.startsWith("/ssh-remote+") && | |
| 43 | url.pathname.length > "/ssh-remote+".length; | |
| 45 | (url.protocol === "zed:" | |
| 46 | ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) | |
| 47 | : url.host === "vscode-remote" && | |
| 48 | url.pathname.startsWith("/ssh-remote+") && | |
| 49 | url.pathname.length > "/ssh-remote+".length); | |
| 44 | 50 | |
| 45 | 51 | export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> { |
| 46 | 52 | if (typeof rawUrl !== "string") { |
apps/web/src/remoteOpen.test.ts+11-1| ... | ... | @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { |
| 141 | 141 | ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); |
| 142 | 142 | }); |
| 143 | 143 | |
| 144 | it("builds Zed's ssh deep link", () => { | |
| 145 | expect( | |
| 146 | buildRemoteOpenUrl({ | |
| 147 | editor: "zed", | |
| 148 | host: "sol.tail1234.ts.net", | |
| 149 | absolutePath: "/home/theo/code/my repo", | |
| 150 | }), | |
| 151 | ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); | |
| 152 | }); | |
| 153 | ||
| 144 | 154 | it("returns undefined for editors without remote support", () => { |
| 145 | expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( | |
| 155 | expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( | |
| 146 | 156 | undefined, |
| 147 | 157 | ); |
| 148 | 158 | }); |
packages/contracts/src/editor.ts+17-6| ... | ... | @@ -13,7 +13,8 @@ type EditorDefinition = { |
| 13 | 13 | /** |
| 14 | 14 | * URL scheme for editors that support VS Code's remote deep links |
| 15 | 15 | * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code |
| 16 | * and forks that ship the Remote-SSH machinery. | |
| 16 | * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own | |
| 17 | * `zed://ssh/<host><path>` shape. | |
| 17 | 18 | */ |
| 18 | 19 | readonly remoteScheme?: string; |
| 19 | 20 | }; |
| ... | ... | @@ -49,7 +50,13 @@ export const EDITORS = [ |
| 49 | 50 | launchStyle: "goto", |
| 50 | 51 | remoteScheme: "vscodium", |
| 51 | 52 | }, |
| 52 | { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, | |
| 53 | { | |
| 54 | id: "zed", | |
| 55 | label: "Zed", | |
| 56 | commands: ["zed", "zeditor"], | |
| 57 | launchStyle: "direct-path", | |
| 58 | remoteScheme: "zed", | |
| 59 | }, | |
| 53 | 60 | { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, |
| 54 | 61 | { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, |
| 55 | 62 | { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, |
| ... | ... | @@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { |
| 95 | 102 | }; |
| 96 | 103 | |
| 97 | 104 | /** |
| 98 | * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that | |
| 99 | * opens `absolutePath` on `host` in the local editor over SSH. Returns | |
| 100 | * undefined for editors without remote deep-link support. | |
| 105 | * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed | |
| 106 | * takes `zed://ssh/<host><path>`) that opens `absolutePath` on `host` in the | |
| 107 | * local editor over SSH. Returns undefined for editors without remote | |
| 108 | * deep-link support. | |
| 101 | 109 | */ |
| 102 | 110 | export const buildRemoteOpenUrl = (input: { |
| 103 | 111 | readonly editor: EditorId; |
| ... | ... | @@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: { |
| 112 | 120 | const posixPath = input.absolutePath.replaceAll("\\", "/"); |
| 113 | 121 | const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; |
| 114 | 122 | const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); |
| 115 | return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; | |
| 123 | const encodedHost = encodeURIComponent(input.host); | |
| 124 | return input.editor === "zed" | |
| 125 | ? `${scheme}://ssh/${encodedHost}${encodedPath}` | |
| 126 | : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; | |
| 116 | 127 | }; |
| 117 | 128 | |
| 118 | 129 | /** |