Skip to content
Closed
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
22 changes: 21 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens remote SSH editor URLs with a login user in the target", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.all([
electronShell.openExternal(
"cursor://vscode-remote/ssh-remote+r2d2%40r2d2/Users/r2d2/project",
),
electronShell.openExternal("zed://ssh/r2d2@r2d2/Users/r2d2/project"),
]);

assert.deepEqual(results, [true, true]);
assert.deepEqual(openExternalMock.mock.calls, [
["cursor://vscode-remote/ssh-remote+r2d2%40r2d2/Users/r2d2/project"],
["zed://ssh/r2d2@r2d2/Users/r2d2/project"],
]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("does not open editor URLs that mix up link shapes", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down Expand Up @@ -150,7 +170,7 @@ 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"),
electronShell.openExternal("zed://ssh/user:secret@example.com/home/user/project"),
electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"),
]);

Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ 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 = /^\/[^/@:]+\/.*$/;
// Zed's `[user@]host` sits in the first path segment, so it needs its own
// userinfo check: a login user is allowed, a password or port is not.
const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/;

const isRemoteEditorUrl = (url: URL) =>
REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
Expand Down
34 changes: 28 additions & 6 deletions apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { it } from "@effect/vitest";
import { HostProcessHostname } from "@t3tools/shared/hostProcess";
import { HostProcessHostname, HostProcessUsername } from "@t3tools/shared/hostProcess";
import * as NetService from "@t3tools/shared/Net";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -52,9 +52,11 @@ const resolveTargets = (input: {
readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
readonly tailscale: { readonly exitCode: number; readonly stdout: string };
readonly hostname: string;
readonly username: string | null;
}) =>
Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
Effect.provideService(HostProcessHostname, input.hostname),
Effect.provideService(HostProcessUsername, input.username),
Effect.provide(
RemoteOpenTargets.layer.pipe(
Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))),
Expand All @@ -72,6 +74,7 @@ describe("RemoteOpenTargets", () => {
sshd: { ipv4: false, ipv6: false },
tailscale: TAILSCALE_UP,
hostname: "bb-1",
username: "t3-user",
});
expect(targets).toEqual([]);
}),
Expand All @@ -83,10 +86,11 @@ describe("RemoteOpenTargets", () => {
sshd: { ipv4: true, ipv6: true },
tailscale: TAILSCALE_UP,
hostname: "bb-1",
username: "t3-user",
});
expect(targets).toEqual([
{ kind: "tailscale", host: "bb-1.tail1234.ts.net" },
{ kind: "mdns", host: "bb-1.local" },
{ kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" },
{ kind: "mdns", host: "bb-1.local", username: "t3-user" },
]);
}),
);
Expand All @@ -97,8 +101,9 @@ describe("RemoteOpenTargets", () => {
sshd: { ipv4: false, ipv6: true },
tailscale: TAILSCALE_DOWN,
hostname: "bb-1",
username: "t3-user",
});
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]);
}),
);

Expand All @@ -108,8 +113,9 @@ describe("RemoteOpenTargets", () => {
sshd: { ipv4: true, ipv6: false },
tailscale: TAILSCALE_DOWN,
hostname: "bb-1",
username: "t3-user",
});
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]);
}),
);

Expand All @@ -119,8 +125,24 @@ describe("RemoteOpenTargets", () => {
sshd: { ipv4: true, ipv6: true },
tailscale: TAILSCALE_DOWN,
hostname: "bb-1.example.com",
username: "t3-user",
});
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]);
}),
);

it.effect("still advertises hostname targets when the username is unavailable", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: true, ipv6: true },
tailscale: TAILSCALE_UP,
hostname: "bb-1",
username: null,
});
expect(targets).toEqual([
{ kind: "tailscale", host: "bb-1.tail1234.ts.net" },
{ kind: "mdns", host: "bb-1.local" },
]);
}),
);
});
15 changes: 12 additions & 3 deletions apps/server/src/environment/RemoteOpenTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* on the tailnet; `<hostname>.local` only on the same LAN).
*/
import { type RemoteOpenTarget } from "@t3tools/contracts";
import { HostProcessHostname } from "@t3tools/shared/hostProcess";
import { HostProcessHostname, HostProcessUsername } from "@t3tools/shared/hostProcess";
import * as NetService from "@t3tools/shared/Net";
import { readTailscaleStatus } from "@t3tools/tailscale";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -45,6 +45,7 @@ export const make = Effect.gen(function* () {
}

const targets: Array<RemoteOpenTarget> = [];
const username = yield* HostProcessUsername;

// Tailscale absent or down is the common case, not an error.
const magicDnsName = yield* readTailscaleStatus.pipe(
Expand All @@ -53,15 +54,23 @@ export const make = Effect.gen(function* () {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
if (magicDnsName !== null) {
targets.push({ kind: "tailscale", host: magicDnsName });
targets.push({
kind: "tailscale",
host: magicDnsName,
...(username === null ? {} : { username }),
});
}

// os.hostname() may already be an FQDN (macOS often reports
// "Name.local"); mDNS names are always `<first-label>.local`.
const hostname = yield* HostProcessHostname;
const shortHostname = hostname.split(".")[0]?.trim();
if (shortHostname !== undefined && shortHostname.length > 0) {
targets.push({ kind: "mdns", host: `${shortHostname}.local` });
targets.push({
kind: "mdns",
host: `${shortHostname}.local`,
...(username === null ? {} : { username }),
});
}

return targets;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export const OpenInPicker = memo(function OpenInPicker({
const url = buildRemoteOpenUrl({
editor,
host: remote.host.host,
...(remote.host.username === undefined ? {} : { username: remote.host.username }),
absolutePath: openInCwd,
});
if (url === undefined) return;
Expand Down
51 changes: 44 additions & 7 deletions apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("resolveRemoteOpenState", () => {
expect(
resolveRemoteOpenState({
target: primaryTarget("http://127.0.0.1:8000"),
sshAlias: null,
sshTarget: null,
isDesktopRenderer: false,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -40,7 +40,7 @@ describe("resolveRemoteOpenState", () => {
expect(
resolveRemoteOpenState({
target: primaryTarget("https://sol.tail1234.ts.net"),
sshAlias: null,
sshTarget: null,
isDesktopRenderer: false,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -56,7 +56,7 @@ describe("resolveRemoteOpenState", () => {
expect(
resolveRemoteOpenState({
target: primaryTarget("http://172.29.112.1:14369"),
sshAlias: null,
sshTarget: null,
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -71,7 +71,7 @@ describe("resolveRemoteOpenState", () => {
label: "WSL (Ubuntu)",
connectionId: "local:wsl-1",
}),
sshAlias: null,
sshTarget: null,
isDesktopRenderer: false,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
Expand All @@ -86,19 +86,37 @@ describe("resolveRemoteOpenState", () => {
label: "sol",
connectionId: "ssh-1",
}),
sshAlias: "sol",
sshTarget: { alias: "sol", username: null },
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } });
});

it("keeps the configured username for a desktop SSH target", () => {
expect(
resolveRemoteOpenState({
target: new SshConnectionTarget({
environmentId,
label: "r2d2",
connectionId: "ssh-1",
}),
sshTarget: { alias: "r2d2", username: "r2d2" },
isDesktopRenderer: true,
remoteOpenTargets: TAILSCALE_TARGETS,
}),
).toEqual({
mode: "remote-links",
host: { kind: "ssh-alias", host: "r2d2", username: "r2d2" },
});
});

it("reports unavailable when a remote environment advertises no hosts", () => {
for (const remoteOpenTargets of [[], undefined] as const) {
expect(
resolveRemoteOpenState({
target: new RelayConnectionTarget({ environmentId, label: "sol" }),
sshAlias: null,
sshTarget: null,
isDesktopRenderer: false,
remoteOpenTargets,
}),
Expand All @@ -110,7 +128,7 @@ describe("resolveRemoteOpenState", () => {
expect(
resolveRemoteOpenState({
target: null,
sshAlias: null,
sshTarget: null,
isDesktopRenderer: false,
remoteOpenTargets: undefined,
}),
Expand All @@ -135,6 +153,25 @@ describe("buildRemoteOpenUrl", () => {
);
});

it("includes the remote environment's login user", () => {
expect(
buildRemoteOpenUrl({
editor: "cursor",
host: "r2d2",
username: "r2d2",
absolutePath: "/Users/r2d2/code",
}),
).toBe("cursor://vscode-remote/ssh-remote+r2d2%40r2d2/Users/r2d2/code");
expect(
buildRemoteOpenUrl({
editor: "zed",
host: "r2d2",
username: "r2d2",
absolutePath: "/Users/r2d2/code",
}),
).toBe("zed://ssh/r2d2@r2d2/Users/r2d2/code");
});

it("roots Windows paths", () => {
expect(
buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }),
Expand Down
30 changes: 20 additions & 10 deletions apps/web/src/remoteOpen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
* 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 configured target beats
* server-advertised names; among advertised names the tailnet MagicDNS name
* beats mDNS `<hostname>.local` (server sends them in that order).
*/
import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
import {
Expand All @@ -27,6 +27,7 @@ import { useEnvironmentPresentation } from "~/state/presentation";
export interface RemoteOpenHost {
readonly kind: "ssh-alias" | RemoteOpenTarget["kind"];
readonly host: string;
readonly username?: string;
}

export type RemoteOpenState =
Expand Down Expand Up @@ -58,8 +59,8 @@ function parseHostname(url: string): string | null {

export function resolveRemoteOpenState(input: {
readonly target: ConnectionTarget | null;
/** Real ssh alias for desktop-SSH environments; null elsewhere. */
readonly sshAlias: string | null;
/** Configured target for desktop-SSH environments; null elsewhere. */
readonly sshTarget: { readonly alias: string; readonly username: string | null } | 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 All @@ -86,8 +87,15 @@ export function resolveRemoteOpenState(input: {
return LOCAL_EXEC;
}

if (input.sshAlias !== null && input.sshAlias.length > 0) {
return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } };
if (input.sshTarget !== null && input.sshTarget.alias.length > 0) {
return {
mode: "remote-links",
host: {
kind: "ssh-alias",
host: input.sshTarget.alias,
...(input.sshTarget.username === null ? {} : { username: input.sshTarget.username }),
},
};
}
const advertised = input.remoteOpenTargets?.[0];
if (advertised !== undefined) {
Expand All @@ -104,12 +112,14 @@ export function useRemoteOpenResolution(environmentId: EnvironmentId | null): Re
return UNRESOLVED_REMOTE_OPEN;
}
const profile = Option.getOrNull(presentation.entry.profile);
const sshAlias =
profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;
const sshTarget =
profile !== null && profile._tag === "SshConnectionProfile"
? { alias: profile.target.alias, username: profile.target.username }
: null;
return {
state: resolveRemoteOpenState({
target: presentation.entry.target,
sshAlias,
sshTarget,
remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
isDesktopRenderer: window.desktopBridge !== undefined,
}),
Expand Down
Loading
Loading