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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,54 @@ 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 Zed ssh links with encoded host delimiters", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal("zed://ssh/user%40host/workspace"),
electronShell.openExternal("zed://ssh/host%3A22/workspace"),
electronShell.openExternal("zed://ssh/host%2Fevil/workspace"),
electronShell.openExternal("zed://ssh/host%2fevil/workspace"),
electronShell.openExternal("zed://ssh/host%3a22/workspace"),
electronShell.openExternal("zed://ssh/user%2540host/workspace"),
electronShell.openExternal("zed://ssh/host%252Fevil/workspace"),
electronShell.openExternal("zed://ssh/host%253A22/workspace"),
]);

assert.deepEqual(results, [false, false, false, false, false, false, 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);
Expand All @@ -64,9 +112,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)),
);
Expand Down
35 changes: 30 additions & 5 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import * as Option from "effect/Option";

import * as Electron from "electron";

// 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+…`,
// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
// scheme stays blocked.
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REMOTE_EDITOR_PROTOCOLS = new Set(
REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
Expand All @@ -16,13 +17,37 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
}),
);

// Zed's host sits in the first path segment, so it needs its own userinfo ban.
// A decoded host cannot contain `%`; rejecting it also blocks nested encoding
// such as `%2540`, which another URL parser could decode into a delimiter.
const ZED_SSH_HOST = /^[^/@:%]+$/;

function isZedSshUrl(url: URL): boolean {
if (url.host !== "ssh") {
return false;
}
const encodedHost = url.pathname.split("/")[1];
if (!encodedHost) {
return false;
}
let host: string;
try {
host = decodeURIComponent(encodedHost);
} catch {
return false;
}
return ZED_SSH_HOST.test(host) && url.pathname.length > encodedHost.length + 1;
}

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;
(url.protocol === "zed:"
? isZedSshUrl(url)
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);

export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
if (typeof rawUrl !== "string") {
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,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,
);
});
Expand Down
23 changes: 17 additions & 6 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ type EditorDefinition = {
/**
* URL scheme for editors that support VS Code's remote deep links
* (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code
* and forks that ship the Remote-SSH machinery.
* and forks that ship the Remote-SSH machinery, plus Zed, which uses its own
* `zed://ssh/<host><path>` shape.
*/
readonly remoteScheme?: string;
};
Expand Down Expand Up @@ -49,7 +50,13 @@ 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",
},
{ 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" },
Expand Down Expand Up @@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
};

/**
* Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that
* opens `absolutePath` on `host` in the local editor over SSH. Returns
* undefined for editors without remote deep-link support.
* Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
* takes `zed://ssh/<host><path>`) 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;
Expand All @@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: {
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 input.editor === "zed"
? `${scheme}://ssh/${encodedHost}${encodedPath}`
: `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`;
};

/**
Expand Down
Loading