Skip to content
Open
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
8 changes: 6 additions & 2 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,14 @@ describe("ElectronShell", () => {
const results = yield* Effect.all([
electronShell.openExternal("zed://ssh/example.com/home/user/project"),
electronShell.openExternal("zed://ssh/example.com/"),
electronShell.openExternal("zed://ssh/[fd7a:115c::5]/home/user/project"),
]);

assert.deepEqual(results, [true, true]);
assert.deepEqual(results, [true, true, true]);
assert.deepEqual(openExternalMock.mock.calls, [
["zed://ssh/example.com/home/user/project"],
["zed://ssh/example.com/"],
["zed://ssh/[fd7a:115c::5]/home/user/project"],
]);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down Expand Up @@ -152,9 +154,11 @@ describe("ElectronShell", () => {
),
electronShell.openExternal("zed://ssh/user@example.com/home/user/project"),
electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"),
electronShell.openExternal("zed://ssh/user:pw@[fd7a::5]/home/user/project"),
electronShell.openExternal("zed://ssh/example.com:22/home/user/project"),
]);

assert.deepEqual(results, [false, false, false, false]);
assert.deepEqual(results, [false, false, false, false, false, false]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
);

// Zed's host sits in the first path segment, so it needs its own userinfo ban.
const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/;
// An IPv6 host comes bracketed, the only place a `:` may appear.
const ZED_SSH_PATHNAME = /^\/(?:[^/@:[\]]+|\[[0-9A-Fa-f:.]+\])\/.*$/;

const isRemoteEditorUrl = (url: URL) =>
REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
Expand Down
133 changes: 127 additions & 6 deletions apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,123 @@ describe("resolveRemoteOpenState", () => {
resolveRemoteOpenState({
target: primaryTarget("http://127.0.0.1:8000"),
sshAlias: null,
connection: null,
isDesktopRenderer: false,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({ mode: "local-exec" });
});

it("uses deep links for a primary target reached over the network", () => {
it("uses deep links to the host a browser reached its primary target at", () => {
const target = primaryTarget("http://sol:3773");
expect(
resolveRemoteOpenState({
target: primaryTarget("https://sol.tail1234.ts.net"),
target,
sshAlias: null,
connection: { target, httpBaseUrl: target.httpBaseUrl },
isDesktopRenderer: false,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({
mode: "remote-links",
host: { kind: "tailscale", host: "sol.tail1234.ts.net" },
});
).toEqual({ mode: "remote-links", host: { kind: "connection", host: "sol" } });
});

it("uses the host of the route the client is connected over, ahead of advertised hosts", () => {
const cases = [
["http://nixos:3773/", "nixos"],
["https://nixos.tail9876.ts.net/", "nixos.tail9876.ts.net"],
["http://100.101.102.103:3773/", "100.101.102.103"],
["http://[fd7a:115c:a1e0::5]:3773/", "fd7a:115c:a1e0::5"],
] as const;
for (const [httpBaseUrl, host] of cases) {
expect(
resolveRemoteOpenState({
target: new RelayConnectionTarget({ environmentId, label: "sol" }),
sshAlias: null,
connection: {
target: new BearerConnectionTarget({ environmentId, label: "sol", connectionId: "c1" }),
httpBaseUrl,
},
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({ mode: "remote-links", host: { kind: "connection", host } });
}
});

it("falls back to advertised hosts when the connection has no host to reuse", () => {
const connections = [
{
target: new RelayConnectionTarget({ environmentId, label: "sol" }),
httpBaseUrl: "https://prod-1234.relay.example.test/",
},
{
target: new BearerConnectionTarget({ environmentId, label: "sol", connectionId: "c1" }),
httpBaseUrl: "http://127.0.0.1:3773/",
},
{
target: new BearerConnectionTarget({ environmentId, label: "sol", connectionId: "c1" }),
httpBaseUrl: "http://[::1]:3773/",
},
{
target: new BearerConnectionTarget({ environmentId, label: "sol", connectionId: "c1" }),
httpBaseUrl: "http://127.0.0.2:3773/",
},
null,
];
for (const connection of connections) {
expect(
resolveRemoteOpenState({
target: new RelayConnectionTarget({ environmentId, label: "sol" }),
sshAlias: null,
connection,
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({
mode: "remote-links",
host: { kind: "tailscale", host: "sol.tail1234.ts.net" },
});
}
});

it("stays unavailable when the server reports no sshd, whatever host the client used", () => {
const connection = {
target: new BearerConnectionTarget({ environmentId, label: "sol", connectionId: "c1" }),
httpBaseUrl: "http://nixos:3773/",
};
expect(
resolveRemoteOpenState({
target: connection.target,
sshAlias: null,
connection,
isDesktopRenderer: true,
remoteOpenTargets: [],
}),
).toEqual({ mode: "remote-unavailable" });
expect(
resolveRemoteOpenState({
target: connection.target,
sshAlias: null,
connection,
isDesktopRenderer: true,
remoteOpenTargets: undefined,
}),
).toEqual({ mode: "remote-links", host: { kind: "connection", host: "nixos" } });
});

it("keeps the SSH alias ahead of the connection host", () => {
expect(
resolveRemoteOpenState({
target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }),
sshAlias: "sol-ssh",
connection: {
target: new BearerConnectionTarget({ environmentId, label: "sol", connectionId: "c1" }),
httpBaseUrl: "http://192.168.1.10:3773/",
},
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol-ssh" } });
});

it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => {
Expand All @@ -57,6 +156,7 @@ describe("resolveRemoteOpenState", () => {
resolveRemoteOpenState({
target: primaryTarget("http://172.29.112.1:14369"),
sshAlias: null,
connection: null,
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -72,6 +172,7 @@ describe("resolveRemoteOpenState", () => {
connectionId: "local:wsl-1",
}),
sshAlias: null,
connection: null,
isDesktopRenderer: false,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -87,6 +188,10 @@ describe("resolveRemoteOpenState", () => {
connectionId: "ssh-1",
}),
sshAlias: "sol",
connection: {
target: new SshConnectionTarget({ environmentId, label: "sol", connectionId: "ssh-1" }),
httpBaseUrl: "http://127.0.0.1:52011",
},
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -99,6 +204,7 @@ describe("resolveRemoteOpenState", () => {
resolveRemoteOpenState({
target: new RelayConnectionTarget({ environmentId, label: "sol" }),
sshAlias: null,
connection: null,
isDesktopRenderer: false,
remoteOpenTargets,
}),
Expand All @@ -111,6 +217,7 @@ describe("resolveRemoteOpenState", () => {
resolveRemoteOpenState({
target: null,
sshAlias: null,
connection: null,
isDesktopRenderer: false,
remoteOpenTargets: undefined,
}),
Expand Down Expand Up @@ -177,6 +284,20 @@ describe("buildRemoteOpenUrl", () => {
);
});

it("passes IPv6 hosts bare to Remote-SSH and Toolbox and bracketed to Zed", () => {
for (const host of ["fd7a:115c::5", "[fd7a:115c::5]"]) {
expect(buildRemoteOpenUrl({ editor: "vscode", host, absolutePath: "/tmp/x" })).toBe(
"vscode://vscode-remote/ssh-remote+fd7a%3A115c%3A%3A5/tmp/x",
);
expect(buildRemoteOpenUrl({ editor: "zed", host, absolutePath: "/tmp/x" })).toBe(
"zed://ssh/[fd7a:115c::5]/tmp/x",
);
expect(buildRemoteOpenUrl({ editor: "idea", host, absolutePath: "/tmp/x" })).toBe(
"jetbrains://gateway/ssh/environment?h=fd7a%3A115c%3A%3A5&launchIde=true&ideHint=IU&projectHint=%2Ftmp%2Fx",
);
}
});

it("returns undefined for editors without remote support", () => {
expect(buildRemoteOpenUrl({ editor: "kiro", host: "sol", absolutePath: "/tmp/x" })).toBe(
undefined,
Expand Down
48 changes: 42 additions & 6 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
* 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
* name beats mDNS `<hostname>.local` (server sends them in that order).
* Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias,
* then the host this client reached the environment at (it provably resolves
* here), then server-advertised names; among advertised names the tailnet
* MagicDNS name beats mDNS `<hostname>.local` (server sends them in that
* order). Advertised names cover T3 Connect, whose URL names the relay.
*/
import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
import type { ConnectionTarget, PreparedConnection } from "@t3tools/client-runtime/connection";
import {
REMOTE_CAPABLE_EDITOR_IDS,
type EditorId,
type EnvironmentId,
type RemoteOpenTarget,
} from "@t3tools/contracts";
import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { useEffect, useMemo, useState } from "react";
Expand All @@ -23,9 +26,10 @@ import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
import { isLoopbackHostname } from "~/environments/primary/target";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useEnvironmentPresentation } from "~/state/presentation";
import { usePreparedConnection } from "~/state/session";

export interface RemoteOpenHost {
readonly kind: "ssh-alias" | RemoteOpenTarget["kind"];
readonly kind: "ssh-alias" | "connection" | RemoteOpenTarget["kind"];
readonly host: string;
}

Expand All @@ -36,6 +40,8 @@ export type RemoteOpenState =

export type RemoteOpenMode = RemoteOpenState["mode"];

type RemoteOpenConnection = Pick<PreparedConnection, "target" | "httpBaseUrl">;

export interface RemoteOpenResolution {
readonly state: RemoteOpenState;
readonly isResolved: boolean;
Expand All @@ -56,10 +62,28 @@ function parseHostname(url: string): string | null {
}
}

function connectionHost(connection: RemoteOpenConnection | null): string | null {
if (
connection === null ||
connection.target._tag === "RelayConnectionTarget" ||
connection.target._tag === "SshConnectionTarget" ||
isDesktopLocalConnectionTarget(connection.target)
) {
return null;
}
const hostname = parseHostname(connection.httpBaseUrl);
if (hostname === null || hostname === "" || isLocalLoopbackHost(hostname)) {
return null;
}
return hostname.replace(/^\[(.*)\]$/, "$1");
}

export function resolveRemoteOpenState(input: {
readonly target: ConnectionTarget | null;
/** Real ssh alias for desktop-SSH environments; null elsewhere. */
readonly sshAlias: string | null;
/** The route this client is connected over; null while disconnected. */
readonly connection: RemoteOpenConnection | null;
/** Server-advertised hosts; undefined on servers that predate the feature. */
readonly remoteOpenTargets: ReadonlyArray<RemoteOpenTarget> | undefined;
/** True when running inside the desktop app's renderer. */
Expand Down Expand Up @@ -89,6 +113,14 @@ export function resolveRemoteOpenState(input: {
if (input.sshAlias !== null && input.sshAlias.length > 0) {
return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } };
}
// An empty list is the server reporting that no sshd listens, so no host can work.
if (input.remoteOpenTargets?.length === 0) {
return REMOTE_UNAVAILABLE;
}
const host = connectionHost(input.connection);
if (host !== null) {
return { mode: "remote-links", host: { kind: "connection", host } };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const advertised = input.remoteOpenTargets?.[0];
if (advertised !== undefined) {
return { mode: "remote-links", host: advertised };
Expand All @@ -98,6 +130,7 @@ export function resolveRemoteOpenState(input: {

export function useRemoteOpenResolution(environmentId: EnvironmentId | null): RemoteOpenResolution {
const { presentation } = useEnvironmentPresentation(environmentId);
const prepared = usePreparedConnection(environmentId);

return useMemo(() => {
if (presentation === null) {
Expand All @@ -110,12 +143,15 @@ export function useRemoteOpenResolution(environmentId: EnvironmentId | null): Re
state: resolveRemoteOpenState({
target: presentation.entry.target,
sshAlias,
// The prepared route is published before its socket opens.
connection:
presentation.connection.phase === "connected" ? Option.getOrNull(prepared) : null,
remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
isDesktopRenderer: window.desktopBridge !== undefined,
}),
isResolved: true,
};
}, [presentation]);
}, [presentation, prepared]);
}

export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
Expand Down
12 changes: 8 additions & 4 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,9 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
* Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link (Zed
* takes `zed://ssh/<host><path>`, JetBrains IDEs a Toolbox App
* `jetbrains://gateway/ssh/environment?...` link) that opens `absolutePath` on
* `host` in the local editor over SSH. Returns undefined for editors without
* remote deep-link support.
* `host` in the local editor over SSH. `host` may be an IPv6 address, with or
* without brackets. Returns undefined for editors without remote deep-link
* support.
*/
export const buildRemoteOpenUrl = (input: {
readonly editor: EditorId;
Expand All @@ -205,12 +206,13 @@ export const buildRemoteOpenUrl = (input: {
if (scheme === undefined) {
return undefined;
}
const host = input.host.replace(/^\[(.*)\]$/, "$1");
const editor = EDITORS.find((candidate) => candidate.id === input.editor);
if (editor !== undefined && "jetbrainsProductCode" in editor) {
// Like the VS Code link, no user or port: the SSH config entry for `host`
// supplies them. A bare product code lets Toolbox pick the backend build.
const params = new URLSearchParams({
h: input.host,
h: host,
launchIde: "true",
ideHint: editor.jetbrainsProductCode,
projectHint: input.absolutePath.replaceAll("\\", "/"),
Expand All @@ -220,7 +222,9 @@ export const buildRemoteOpenUrl = (input: {
// Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.
const posixPath = input.absolutePath.replaceAll("\\", "/");
const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
const encodedHost = encodeURIComponent(input.host);
// Remote-SSH reads a bare IPv6 address itself; brackets are only for Zed's URL parser.
const encodedHost =
input.editor === "zed" && host.includes(":") ? `[${host}]` : encodeURIComponent(host);
if (input.editor === "zed") {
// Zed's remote server resolves a rooted path on the system drive, so a
// Windows `C:\Users\x` must become `/Users/x` (verified in #8938). Other
Expand Down
Loading