authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-14 20:35:34-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 17:35:34-07:00
log57a299a7852b430613dfdd97ba76249ee9f374d5
tree311db911020f881694e85d0657d82a851e4ba1d3
parent74f7b434865c2d758c7b1cd5f52f4c96b76d03fb
signature Signed by PGP key B5690EEEBB952194

feat(web): open remote environments in your local editor over SSH (#6572)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

24 files changed, 806 insertions(+), 46 deletions(-)

apps/desktop/src/electron/ElectronShell.ts+11-1
......@@ -1,3 +1,4 @@
1import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
12import * as Context from "effect/Context";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -5,7 +6,16 @@ import * as Option from "effect/Option";
56
67import * as Electron from "electron";
78
8const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
9// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
10// must reach the OS handler; every other non-web scheme stays blocked.
11const SAFE_EXTERNAL_PROTOCOLS = new Set([
12 "http:",
13 "https:",
14 ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => {
15 const scheme = remoteSchemeForEditor(id);
16 return scheme === undefined ? [] : [`${scheme}:`];
17 }),
18]);
919
1020export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
1121 if (typeof rawUrl !== "string") {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -36,6 +36,7 @@ import {
3636 getLocalEnvironmentBearerToken,
3737 getWindowFullscreenState,
3838 openExternal,
39 probeRemoteEditors,
3940 pickFolder,
4041 pickThemeFiles,
4142 setTheme,
......@@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8384 yield* ipc.handle(setTheme);
8485 yield* ipc.handle(showContextMenu);
8586 yield* ipc.handle(openExternal);
87 yield* ipc.handle(probeRemoteEditors);
8688 yield* ipc.handle(getUpdateState);
8789 yield* ipc.handle(setUpdateChannel);
8890 yield* ipc.handle(downloadUpdate);
apps/desktop/src/ipc/channels.ts+1
......@@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
33export const SET_THEME_CHANNEL = "desktop:set-theme";
44export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
55export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
67export const MENU_ACTION_CHANNEL = "desktop:menu-action";
78export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
89export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
apps/desktop/src/ipc/methods/window.ts+28
......@@ -3,12 +3,16 @@ import {
33 DesktopAppBrandingSchema,
44 DesktopEnvironmentBootstrapSchema,
55 DesktopThemeSchema,
6 EDITORS,
7 EditorId,
68 PickedThemeFileSchema,
79 PickFolderOptionsSchema,
810 PRIMARY_LOCAL_ENVIRONMENT_ID,
11 REMOTE_CAPABLE_EDITOR_IDS,
912 type DesktopEnvironmentBootstrap,
1013 type PickedThemeFile,
1114} from "@t3tools/contracts";
15import { isCommandAvailable } from "@t3tools/shared/shell";
1216import * as NodeOS from "node:os";
1317import * as FileSystem from "effect/FileSystem";
1418import * as Path from "effect/Path";
......@@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({
261265 }),
262266});
263267
268export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
269 channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
270 payload: Schema.Undefined,
271 result: Schema.Array(EditorId),
272 // Probes THIS machine (where the renderer runs) for remote-capable editor
273 // CLIs, unlike the server's probe which walks the environment host's PATH.
274 // A Finder-launched app can miss PATH entries; an empty result makes the
275 // renderer fall back to VS Code only, so that fails soft.
276 handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () {
277 const available: Array<EditorId> = [];
278 for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) {
279 const commands = EDITORS.find((editor) => editor.id === editorId)?.commands;
280 if (!commands) continue;
281 for (const command of commands) {
282 if (yield* isCommandAvailable(command, { env: process.env })) {
283 available.push(editorId);
284 break;
285 }
286 }
287 }
288 return available;
289 }),
290});
291
264292/** Theme files are a few KB; anything larger returns empty text and lets the
265293 * renderer reject it by size without the contents ever crossing the bridge. */
266294const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
apps/desktop/src/preload.ts+1
......@@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
105105 ...(position === undefined ? {} : { position }),
106106 }),
107107 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
108 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
108109 onMenuAction: (listener) => {
109110 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
110111 if (typeof action !== "string") return;
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1
......@@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed(
7777const netLayer = Layer.succeed(NetService.NetService, {
7878 canListenOnHost: () => Effect.succeed(true),
7979 isPortAvailableOnLoopback: () => Effect.succeed(true),
80 hasListenerOnHost: () => Effect.succeed(false),
8081 reserveLoopbackPort: () => Effect.succeed(41773),
8182 findAvailablePort: (preferred) => Effect.succeed(preferred),
8283} satisfies NetService.NetService["Service"]);
apps/server/src/environment/RemoteOpenTargets.test.ts created+126
......@@ -0,0 +1,126 @@
1import { it } from "@effect/vitest";
2import { HostProcessHostname } from "@t3tools/shared/hostProcess";
3import * as NetService from "@t3tools/shared/Net";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
9import { describe, expect } from "vite-plus/test";
10
11import * as RemoteOpenTargets from "./RemoteOpenTargets.ts";
12
13const encoder = new TextEncoder();
14
15const TAILSCALE_STATUS_JSON = JSON.stringify({
16 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
17});
18
19/** Spawner whose `tailscale status --json` exits with the given output. */
20const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) =>
21 Layer.succeed(
22 ChildProcessSpawner.ChildProcessSpawner,
23 ChildProcessSpawner.make(() =>
24 Effect.succeed(
25 ChildProcessSpawner.makeHandle({
26 pid: ChildProcessSpawner.ProcessId(1),
27 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)),
28 isRunning: Effect.succeed(false),
29 kill: () => Effect.void,
30 unref: Effect.succeed(Effect.void),
31 stdin: Sink.drain,
32 stdout: Stream.make(encoder.encode(input.stdout)),
33 stderr: Stream.empty,
34 all: Stream.empty,
35 getInputFd: () => Sink.drain,
36 getOutputFd: () => Stream.empty,
37 }),
38 ),
39 ),
40 );
41
42const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
43 Layer.succeed(NetService.NetService, {
44 canListenOnHost: () => Effect.succeed(true),
45 isPortAvailableOnLoopback: () => Effect.succeed(true),
46 hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4),
47 reserveLoopbackPort: () => Effect.succeed(40_000),
48 findAvailablePort: (preferred) => Effect.succeed(preferred),
49 });
50
51const resolveTargets = (input: {
52 readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
53 readonly tailscale: { readonly exitCode: number; readonly stdout: string };
54 readonly hostname: string;
55}) =>
56 Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
57 Effect.provideService(HostProcessHostname, input.hostname),
58 Effect.provide(
59 RemoteOpenTargets.layer.pipe(
60 Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))),
61 ),
62 ),
63 );
64
65const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
66const TAILSCALE_DOWN = { exitCode: 1, stdout: "" };
67
68describe("RemoteOpenTargets", () => {
69 it.effect("advertises nothing when no sshd accepts on either loopback", () =>
70 Effect.gen(function* () {
71 const targets = yield* resolveTargets({
72 sshd: { ipv4: false, ipv6: false },
73 tailscale: TAILSCALE_UP,
74 hostname: "bb-1",
75 });
76 expect(targets).toEqual([]);
77 }),
78 );
79
80 it.effect("orders the tailnet name before the mDNS name", () =>
81 Effect.gen(function* () {
82 const targets = yield* resolveTargets({
83 sshd: { ipv4: true, ipv6: true },
84 tailscale: TAILSCALE_UP,
85 hostname: "bb-1",
86 });
87 expect(targets).toEqual([
88 { kind: "tailscale", host: "bb-1.tail1234.ts.net" },
89 { kind: "mdns", host: "bb-1.local" },
90 ]);
91 }),
92 );
93
94 it.effect("accepts an sshd bound to IPv6 loopback only", () =>
95 Effect.gen(function* () {
96 const targets = yield* resolveTargets({
97 sshd: { ipv4: false, ipv6: true },
98 tailscale: TAILSCALE_DOWN,
99 hostname: "bb-1",
100 });
101 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
102 }),
103 );
104
105 it.effect("falls back to mDNS alone when tailscale is unavailable", () =>
106 Effect.gen(function* () {
107 const targets = yield* resolveTargets({
108 sshd: { ipv4: true, ipv6: false },
109 tailscale: TAILSCALE_DOWN,
110 hostname: "bb-1",
111 });
112 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
113 }),
114 );
115
116 it.effect("shortens an FQDN hostname to its first label for mDNS", () =>
117 Effect.gen(function* () {
118 const targets = yield* resolveTargets({
119 sshd: { ipv4: true, ipv6: true },
120 tailscale: TAILSCALE_DOWN,
121 hostname: "bb-1.example.com",
122 });
123 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
124 }),
125 );
126});
apps/server/src/environment/RemoteOpenTargets.ts created+72
......@@ -0,0 +1,72 @@
1/**
2 * RemoteOpenTargets - resolves the SSH hostnames this environment advertises
3 * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).
4 *
5 * The server can only check itself: sshd listening locally, tailscaled
6 * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a
7 * given name resolves from the viewer's machine is inherently client-side.
8 * Targets are ordered most-reachable first (tailnet name works from anywhere
9 * on the tailnet; `<hostname>.local` only on the same LAN).
10 */
11import { type RemoteOpenTarget } from "@t3tools/contracts";
12import { HostProcessHostname } from "@t3tools/shared/hostProcess";
13import * as NetService from "@t3tools/shared/Net";
14import { readTailscaleStatus } from "@t3tools/tailscale";
15import * as Context from "effect/Context";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
19
20const SSH_PORT = 22;
21
22export class RemoteOpenTargets extends Context.Service<
23 RemoteOpenTargets,
24 {
25 readonly resolveTargets: () => Effect.Effect<ReadonlyArray<RemoteOpenTarget>>;
26 }
27>()("t3/environment/RemoteOpenTargets") {}
28
29export const make = Effect.gen(function* () {
30 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
31 const net = yield* NetService.NetService;
32
33 const resolveTargets = Effect.gen(function* () {
34 // No local sshd means no name can work; advertise nothing so clients
35 // render a clear "no SSH route" state instead of links that hang.
36 // Check both loopback families: sshd can be bound IPv6-only.
37 const sshdListening = yield* Effect.zipWith(
38 net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),
39 net.hasListenerOnHost(SSH_PORT, "::1"),
40 (ipv4, ipv6) => ipv4 || ipv6,
41 );
42 if (!sshdListening) {
43 return [];
44 }
45
46 const targets: Array<RemoteOpenTarget> = [];
47
48 // Tailscale absent or down is the common case, not an error.
49 const magicDnsName = yield* readTailscaleStatus.pipe(
50 Effect.map((status) => status.magicDnsName),
51 Effect.orElseSucceed(() => null),
52 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
53 );
54 if (magicDnsName !== null) {
55 targets.push({ kind: "tailscale", host: magicDnsName });
56 }
57
58 // os.hostname() may already be an FQDN (macOS often reports
59 // "Name.local"); mDNS names are always `<first-label>.local`.
60 const hostname = yield* HostProcessHostname;
61 const shortHostname = hostname.split(".")[0]?.trim();
62 if (shortHostname !== undefined && shortHostname.length > 0) {
63 targets.push({ kind: "mdns", host: `${shortHostname}.local` });
64 }
65
66 return targets;
67 });
68
69 return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets });
70});
71
72export const layer = Layer.effect(RemoteOpenTargets, make);
apps/server/src/preview/PortScanner.test.ts+3
......@@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null;
4747const TestIntegrationNet = Layer.succeed(Net.NetService, {
4848 canListenOnHost: () => Effect.succeed(true),
4949 isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort),
50 hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort),
5051 reserveLoopbackPort: () => Effect.succeed(40_000),
5152 findAvailablePort: (preferred) => Effect.succeed(preferred),
5253});
......@@ -62,6 +63,7 @@ const makeProbeFailureLayer = (
6263 Layer.succeed(Net.NetService, {
6364 canListenOnHost: () => Effect.succeed(true),
6465 isPortAvailableOnLoopback: () => Effect.succeed(true),
66 hasListenerOnHost: () => Effect.succeed(false),
6567 reserveLoopbackPort: () => Effect.succeed(40_000),
6668 findAvailablePort: (preferred) => Effect.succeed(preferred),
6769 }),
......@@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: {
107109 Layer.succeed(Net.NetService, {
108110 canListenOnHost: () => Effect.succeed(true),
109111 isPortAvailableOnLoopback: () => Effect.succeed(true),
112 hasListenerOnHost: () => Effect.succeed(false),
110113 reserveLoopbackPort: () => Effect.succeed(40_000),
111114 findAvailablePort: (preferred) => Effect.succeed(preferred),
112115 }),
apps/server/src/server.test.ts+10-4
......@@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
107107import * as GitManager from "./git/GitManager.ts";
108108import * as Keybindings from "./keybindings.ts";
109109import * as ExternalLauncher from "./process/externalLauncher.ts";
110import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
110111import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
111112import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";
112113import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
......@@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: {
650651 }),
651652 ),
652653 Layer.provide(
653 Layer.mock(ExternalLauncher.ExternalLauncher)({
654 resolveAvailableEditors: () => Effect.succeed([]),
655 ...options?.layers?.externalLauncher,
656 }),
654 Layer.mergeAll(
655 Layer.mock(ExternalLauncher.ExternalLauncher)({
656 resolveAvailableEditors: () => Effect.succeed([]),
657 ...options?.layers?.externalLauncher,
658 }),
659 Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({
660 resolveTargets: () => Effect.succeed([]),
661 }),
662 ),
657663 ),
658664 Layer.provide(
659665 Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({
apps/server/src/server.ts+2
......@@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe
8181import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
8282import { ObservabilityLive } from "./observability/Layers/Observability.ts";
8383import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
84import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
8485import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
8586import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
8687import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
......@@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
420421 Layer.provideMerge(TraceDiagnostics.layer),
421422 Layer.provideMerge(AnalyticsService.layer),
422423 Layer.provideMerge(ExternalLauncher.layer),
424 Layer.provideMerge(RemoteOpenTargets.layer),
423425 Layer.provideMerge(ServerLifecycleEvents.layer),
424426 Layer.provide(NetService.layer),
425427);
apps/server/src/ws.ts+7
......@@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
9999import * as ReviewService from "./review/ReviewService.ts";
100100import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
101101import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
102import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
102103import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
103104import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
104105import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
......@@ -361,6 +362,7 @@ const makeWsRpcLayer = (
361362 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
362363 const keybindings = yield* Keybindings.Keybindings;
363364 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
365 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
364366 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
365367 const review = yield* ReviewService.ReviewService;
366368 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;
......@@ -1010,6 +1012,11 @@ const makeWsRpcLayer = (
10101012 availableEditors: yield* resolveAvailableEditorsForConfig(
10111013 externalLauncher.resolveAvailableEditors(),
10121014 ),
1015 // Same discovery-with-timeout treatment as editors: a slow probe
1016 // must not stall server.getConfig, so it degrades to no targets.
1017 remoteOpenTargets: yield* resolveAvailableEditorsForConfig(
1018 remoteOpenTargets.resolveTargets(),
1019 ),
10131020 observability: {
10141021 logsDirectoryPath: config.logsDir,
10151022 localTracingEnabled: true,
apps/web/src/components/chat/ChatHeader.test.ts+18-3
......@@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => {
1212 activeProjectName: "codething-mvp",
1313 activeThreadEnvironmentId: primaryEnvironmentId,
1414 primaryEnvironmentId,
15 remoteOpenMode: "local-exec",
1516 }),
1617 ).toBe(true);
1718 });
1819
19 it("hides the picker when hosted static mode has no primary environment", () => {
20 it("shows the picker for remote environments in deep-link mode", () => {
21 expect(
22 shouldShowOpenInPicker({
23 activeProjectName: "codething-mvp",
24 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
25 primaryEnvironmentId,
26 remoteOpenMode: "remote-links",
27 }),
28 ).toBe(true);
29 });
30
31 it("shows the picker's unavailable state for remote environments without an SSH route", () => {
2032 expect(
2133 shouldShowOpenInPicker({
2234 activeProjectName: "codething-mvp",
2335 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
2436 primaryEnvironmentId: null,
37 remoteOpenMode: "remote-unavailable",
2538 }),
26 ).toBe(false);
39 ).toBe(true);
2740 });
2841
29 it("hides the picker for remote environments", () => {
42 it("hides the picker for non-primary local backends", () => {
3043 expect(
3144 shouldShowOpenInPicker({
3245 activeProjectName: "codething-mvp",
3346 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
3447 primaryEnvironmentId,
48 remoteOpenMode: "local-exec",
3549 }),
3650 ).toBe(false);
3751 });
......@@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => {
4256 activeProjectName: undefined,
4357 activeThreadEnvironmentId: primaryEnvironmentId,
4458 primaryEnvironmentId,
59 remoteOpenMode: "remote-links",
4560 }),
4661 ).toBe(false);
4762 });
apps/web/src/components/chat/ChatHeader.tsx+13-3
......@@ -30,6 +30,7 @@ import ProjectScriptsControl, {
3030 type ProjectScriptActionResult,
3131} from "../ProjectScriptsControl";
3232import { OpenInPicker } from "./OpenInPicker";
33import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen";
3334import { usePrimaryEnvironmentId } from "../../state/environments";
3435import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
3536import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
......@@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: {
9192 readonly activeProjectName: string | undefined;
9293 readonly activeThreadEnvironmentId: EnvironmentId;
9394 readonly primaryEnvironmentId: EnvironmentId | null;
95 readonly remoteOpenMode: RemoteOpenMode;
9496}): boolean {
95 return (
96 Boolean(input.activeProjectName) &&
97 if (!input.activeProjectName) return false;
98 if (
9799 input.primaryEnvironmentId !== null &&
98100 input.activeThreadEnvironmentId === input.primaryEnvironmentId
99 );
101 ) {
102 return true;
103 }
104 // Remote environments get the picker in deep-link mode (or its explicit
105 // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it
106 // hidden, matching pre-remote behavior.
107 return input.remoteOpenMode !== "local-exec";
100108}
101109
102110export const ChatHeader = memo(function ChatHeader({
......@@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({
128136 activeThreadEnvironmentId,
129137 activeProjectScripts ? activeProjectCwd : null,
130138 );
139 const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId);
131140 const showOpenInPicker = shouldShowOpenInPicker({
132141 activeProjectName,
133142 activeThreadEnvironmentId,
134143 primaryEnvironmentId,
144 remoteOpenMode: remoteOpenState.mode,
135145 });
136146 const activeThreadRef = useMemo(
137147 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
apps/web/src/components/chat/OpenInPicker.tsx+70-30
......@@ -1,7 +1,19 @@
1import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";
1import {
2 buildRemoteOpenUrl,
3 EditorId,
4 type EnvironmentId,
5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
27import { memo, useCallback, useEffect, useMemo } from "react";
38import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
49import { usePreferredEditor } from "../../editorPreferences";
10import {
11 openRemoteEditorUrl,
12 useRemoteCapableEditors,
13 useRemoteOpenHint,
14 useRemoteOpenState,
15} from "../../remoteOpen";
16import { useEnvironment } from "../../state/environments";
517import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";
618import { Button } from "../ui/button";
719import { Group, GroupSeparator } from "../ui/group";
......@@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({
199211 enableShortcut?: boolean;
200212}) {
201213 const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");
202 const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors);
214 const remote = useRemoteOpenState(environmentId);
215 const remoteCapableEditors = useRemoteCapableEditors();
216 const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint();
217 const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine";
218 // Remote mode ignores the server's PATH probe: what matters is what runs on
219 // the viewing machine, which only the desktop app can probe.
220 const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors;
221 const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors);
203222 const options = useMemo(
204 () => resolveOptions(navigator.platform, availableEditors),
205 [availableEditors],
223 () => resolveOptions(navigator.platform, effectiveEditors),
224 [effectiveEditors],
206225 );
207226 const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null;
208227
......@@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({
211230 if (!openInCwd) return;
212231 const editor = editorId ?? preferredEditor;
213232 if (!editor) return;
233 if (remote.mode === "remote-unavailable") return;
234 if (remote.mode === "remote-links") {
235 const url = buildRemoteOpenUrl({
236 editor,
237 host: remote.host.host,
238 absolutePath: openInCwd,
239 });
240 if (url === undefined) return;
241 // Only record hint-seen/preferred when the shell actually accepted
242 // the URL (an older desktop build can refuse the editor scheme).
243 void openRemoteEditorUrl(url).then((opened) => {
244 if (!opened) return;
245 markRemoteHintSeen();
246 setPreferredEditor(editor);
247 });
248 return;
249 }
214250 const result = openInEditorMutation({
215251 environmentId,
216252 input: {
......@@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({
221257 setPreferredEditor(editor);
222258 return result;
223259 },
224 [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor],
260 [
261 environmentId,
262 markRemoteHintSeen,
263 openInCwd,
264 openInEditorMutation,
265 preferredEditor,
266 remote,
267 setPreferredEditor,
268 ],
225269 );
226270
227271 const openFavoriteEditorShortcutLabel = useMemo(
......@@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({
237281 if (!preferredEditor) return;
238282
239283 e.preventDefault();
240 void openInEditorMutation({
241 environmentId,
242 input: {
243 cwd: openInCwd,
244 editor: preferredEditor,
245 },
246 });
284 void openInEditor(preferredEditor);
247285 };
248286 window.addEventListener("keydown", handler);
249287 return () => window.removeEventListener("keydown", handler);
250 }, [
251 enableShortcut,
252 environmentId,
253 keybindings,
254 openInCwd,
255 openInEditorMutation,
256 preferredEditor,
257 ]);
288 }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]);
258289
259290 return (
260291 <Group aria-label="Open in editor">
......@@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({
263294 className="ps-[8.5px]"
264295 size="xs"
265296 variant="outline"
266 disabled={!preferredEditor || !openInCwd}
297 disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"}
267298 onClick={() => openInEditor(preferredEditor)}
268299 >
269300 {primaryOption?.Icon && (
......@@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({
296327 <ChevronDownIcon aria-hidden="true" className="size-4" />
297328 </MenuTrigger>
298329 <MenuPopup align="end">
299 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}
300 {options.map(({ label, Icon, value, kind }) => (
301 <MenuItem key={value} onClick={() => openInEditor(value)}>
302 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
303 {label}
304 {value === preferredEditor && openFavoriteEditorShortcutLabel && (
305 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
330 {remote.mode === "remote-unavailable" ? (
331 <MenuItem disabled>No SSH route to {environmentLabel}</MenuItem>
332 ) : (
333 <>
334 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}
335 {options.map(({ label, Icon, value, kind }) => (
336 <MenuItem key={value} onClick={() => openInEditor(value)}>
337 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
338 {label}
339 {value === preferredEditor && openFavoriteEditorShortcutLabel && (
340 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
341 )}
342 </MenuItem>
343 ))}
344 {remote.mode === "remote-links" && !remoteHintSeen && (
345 <MenuItem disabled>Opens over SSH. Needs your key on {environmentLabel}</MenuItem>
306346 )}
307 </MenuItem>
308 ))}
347 </>
348 )}
309349 </MenuPopup>
310350 </Menu>
311351 </Group>
apps/web/src/components/files/FilePreviewPanel.tsx+4-1
......@@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre
2020import { useAssetUrlState } from "~/assets/assetUrls";
2121import ChatMarkdown from "~/components/ChatMarkdown";
2222import { OpenInPicker } from "~/components/chat/OpenInPicker";
23import { useRemoteOpenState } from "~/remoteOpen";
2324import { useClientSettings } from "~/hooks/useSettings";
2425import { useTheme } from "~/hooks/useTheme";
2526import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
......@@ -771,6 +772,7 @@ export default function FilePreviewPanel({
771772 const { resolvedTheme } = useTheme();
772773 const wordWrap = useClientSettings((settings) => settings.wordWrap);
773774 const primaryEnvironmentId = usePrimaryEnvironmentId();
775 const remoteOpenState = useRemoteOpenState(environmentId);
774776 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
775777 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
776778 reportFailure: false,
......@@ -893,7 +895,8 @@ export default function FilePreviewPanel({
893895 ))}
894896 </div>
895897 </ScrollArea>
896 {absolutePath && environmentId === primaryEnvironmentId ? (
898 {absolutePath &&
899 (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (
897900 <OpenInPicker
898901 environmentId={environmentId}
899902 keybindings={keybindings}
apps/web/src/remoteOpen.test.ts created+149
......@@ -0,0 +1,149 @@
1import {
2 BearerConnectionTarget,
3 PrimaryConnectionTarget,
4 RelayConnectionTarget,
5 SshConnectionTarget,
6} from "@t3tools/client-runtime/connection";
7import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import { resolveRemoteOpenState } from "./remoteOpen";
11
12const environmentId = EnvironmentId.make("environment-1");
13
14const primaryTarget = (httpBaseUrl: string) =>
15 new PrimaryConnectionTarget({
16 environmentId,
17 label: "sol",
18 httpBaseUrl,
19 wsBaseUrl: httpBaseUrl.replace("http", "ws"),
20 });
21
22const TAILSCALE_TARGETS = [
23 { kind: "tailscale", host: "sol.tail1234.ts.net" },
24 { kind: "mdns", host: "sol.local" },
25] as const;
26
27describe("resolveRemoteOpenState", () => {
28 it("keeps exec behavior for a loopback primary target", () => {
29 expect(
30 resolveRemoteOpenState({
31 target: primaryTarget("http://127.0.0.1:8000"),
32 sshAlias: null,
33 isDesktopRenderer: false,
34 remoteOpenTargets: TAILSCALE_TARGETS,
35 }),
36 ).toEqual({ mode: "local-exec" });
37 });
38
39 it("uses deep links for a primary target reached over the network", () => {
40 expect(
41 resolveRemoteOpenState({
42 target: primaryTarget("https://sol.tail1234.ts.net"),
43 sshAlias: null,
44 isDesktopRenderer: false,
45 remoteOpenTargets: TAILSCALE_TARGETS,
46 }),
47 ).toEqual({
48 mode: "remote-links",
49 host: { kind: "tailscale", host: "sol.tail1234.ts.net" },
50 });
51 });
52
53 it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => {
54 // wsl-only mode binds the primary to the WSL2 NAT address; it is still
55 // this machine because the desktop app manages its own primary backend.
56 expect(
57 resolveRemoteOpenState({
58 target: primaryTarget("http://172.29.112.1:14369"),
59 sshAlias: null,
60 isDesktopRenderer: true,
61 remoteOpenTargets: TAILSCALE_TARGETS,
62 }),
63 ).toEqual({ mode: "local-exec" });
64 });
65
66 it("keeps exec behavior for desktop-local secondary backends", () => {
67 expect(
68 resolveRemoteOpenState({
69 target: new BearerConnectionTarget({
70 environmentId,
71 label: "WSL (Ubuntu)",
72 connectionId: "local:wsl-1",
73 }),
74 sshAlias: null,
75 isDesktopRenderer: false,
76 remoteOpenTargets: TAILSCALE_TARGETS,
77 }),
78 ).toEqual({ mode: "local-exec" });
79 });
80
81 it("prefers the desktop SSH alias over server-advertised hosts", () => {
82 expect(
83 resolveRemoteOpenState({
84 target: new SshConnectionTarget({
85 environmentId,
86 label: "sol",
87 connectionId: "ssh-1",
88 }),
89 sshAlias: "sol",
90 isDesktopRenderer: true,
91 remoteOpenTargets: TAILSCALE_TARGETS,
92 }),
93 ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } });
94 });
95
96 it("reports unavailable when a remote environment advertises no hosts", () => {
97 for (const remoteOpenTargets of [[], undefined] as const) {
98 expect(
99 resolveRemoteOpenState({
100 target: new RelayConnectionTarget({ environmentId, label: "sol" }),
101 sshAlias: null,
102 isDesktopRenderer: false,
103 remoteOpenTargets,
104 }),
105 ).toEqual({ mode: "remote-unavailable" });
106 }
107 });
108
109 it("falls back to exec when the environment has no catalog entry", () => {
110 expect(
111 resolveRemoteOpenState({
112 target: null,
113 sshAlias: null,
114 isDesktopRenderer: false,
115 remoteOpenTargets: undefined,
116 }),
117 ).toEqual({ mode: "local-exec" });
118 });
119});
120
121describe("buildRemoteOpenUrl", () => {
122 it("builds a vscode-remote deep link", () => {
123 expect(
124 buildRemoteOpenUrl({
125 editor: "vscode",
126 host: "sol.tail1234.ts.net",
127 absolutePath: "/home/theo/code/my repo",
128 }),
129 ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo");
130 });
131
132 it("uses the fork's scheme", () => {
133 expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe(
134 "cursor://vscode-remote/ssh-remote+sol/tmp/x",
135 );
136 });
137
138 it("roots Windows paths", () => {
139 expect(
140 buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }),
141 ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo");
142 });
143
144 it("returns undefined for editors without remote support", () => {
145 expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe(
146 undefined,
147 );
148 });
149});
apps/web/src/remoteOpen.ts created+189
......@@ -0,0 +1,189 @@
1/**
2 * Remote open-in-editor: when this client is not on the environment's
3 * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…`
4 * deep link (local editor connects over SSH) instead of exec'ing an editor
5 * on the environment host.
6 *
7 * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias
8 * beats server-advertised names; among advertised names the tailnet MagicDNS
9 * name beats mDNS `<hostname>.local` (server sends them in that order).
10 */
11import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
12import {
13 REMOTE_CAPABLE_EDITOR_IDS,
14 type EditorId,
15 type EnvironmentId,
16 type RemoteOpenTarget,
17} from "@t3tools/contracts";
18import * as Option from "effect/Option";
19import * as Schema from "effect/Schema";
20import { useEffect, useMemo, useState } from "react";
21
22import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
23import { isLoopbackHostname } from "~/environments/primary/target";
24import { useLocalStorage } from "~/hooks/useLocalStorage";
25import { useEnvironmentPresentation } from "~/state/presentation";
26
27export interface RemoteOpenHost {
28 readonly kind: "ssh-alias" | RemoteOpenTarget["kind"];
29 readonly host: string;
30}
31
32export type RemoteOpenState =
33 | { readonly mode: "local-exec" }
34 | { readonly mode: "remote-links"; readonly host: RemoteOpenHost }
35 | { readonly mode: "remote-unavailable" };
36
37export type RemoteOpenMode = RemoteOpenState["mode"];
38
39const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" };
40const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" };
41
42function parseHostname(url: string): string | null {
43 try {
44 return new URL(url).hostname;
45 } catch {
46 return null;
47 }
48}
49
50export function resolveRemoteOpenState(input: {
51 readonly target: ConnectionTarget | null;
52 /** Real ssh alias for desktop-SSH environments; null elsewhere. */
53 readonly sshAlias: string | null;
54 /** Server-advertised hosts; undefined on servers that predate the feature. */
55 readonly remoteOpenTargets: ReadonlyArray<RemoteOpenTarget> | undefined;
56 /** True when running inside the desktop app's renderer. */
57 readonly isDesktopRenderer: boolean;
58}): RemoteOpenState {
59 const { target } = input;
60 // No catalog entry: keep today's exec behavior rather than guessing.
61 if (target === null) {
62 return LOCAL_EXEC;
63 }
64 if (target._tag === "PrimaryConnectionTarget") {
65 // The desktop app manages its own primary backend, so it is always on
66 // this machine even when its URL is not loopback (wsl-only mode binds
67 // the WSL2 NAT address). In a browser, a loopback primary means the
68 // browser runs on the serving machine; a tailnet/LAN URL means remote.
69 if (input.isDesktopRenderer) {
70 return LOCAL_EXEC;
71 }
72 const hostname = parseHostname(target.httpBaseUrl);
73 if (hostname !== null && isLoopbackHostname(hostname)) {
74 return LOCAL_EXEC;
75 }
76 } else if (isDesktopLocalConnectionTarget(target)) {
77 return LOCAL_EXEC;
78 }
79
80 if (input.sshAlias !== null && input.sshAlias.length > 0) {
81 return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } };
82 }
83 const advertised = input.remoteOpenTargets?.[0];
84 if (advertised !== undefined) {
85 return { mode: "remote-links", host: advertised };
86 }
87 return REMOTE_UNAVAILABLE;
88}
89
90export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState {
91 const { presentation } = useEnvironmentPresentation(environmentId);
92
93 return useMemo(() => {
94 if (presentation === null) {
95 return LOCAL_EXEC;
96 }
97 const profile = Option.getOrNull(presentation.entry.profile);
98 const sshAlias =
99 profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null;
100 return resolveRemoteOpenState({
101 target: presentation.entry.target,
102 sshAlias,
103 remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets,
104 isDesktopRenderer: window.desktopBridge !== undefined,
105 });
106 }, [presentation]);
107}
108
109/**
110 * Editors offered in remote-link mode. The desktop app probes the machine the
111 * renderer runs on; a browser cannot, so it offers VS Code only.
112 */
113const REMOTE_FALLBACK_EDITORS: ReadonlyArray<EditorId> = ["vscode"];
114
115let cachedProbedEditors: ReadonlyArray<EditorId> | null = null;
116
117export function __resetRemoteEditorProbeForTests(): void {
118 cachedProbedEditors = null;
119}
120
121export function useRemoteCapableEditors(): ReadonlyArray<EditorId> {
122 const [editors, setEditors] = useState<ReadonlyArray<EditorId>>(
123 () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS,
124 );
125
126 useEffect(() => {
127 if (cachedProbedEditors !== null) {
128 return;
129 }
130 const probe = window.desktopBridge?.probeRemoteEditors;
131 if (probe === undefined) {
132 cachedProbedEditors = REMOTE_FALLBACK_EDITORS;
133 return;
134 }
135 let cancelled = false;
136 probe().then(
137 (ids) => {
138 const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id));
139 cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS;
140 if (!cancelled) {
141 setEditors(cachedProbedEditors);
142 }
143 },
144 () => {
145 cachedProbedEditors = REMOTE_FALLBACK_EDITORS;
146 },
147 );
148 return () => {
149 cancelled = true;
150 };
151 }, []);
152
153 return editors;
154}
155
156/**
157 * Fire a remote editor deep link. In desktop, route through the Electron
158 * shell so the OS handler opens without navigating the renderer; in a
159 * browser, assign the location — unlike window.open this does not leave a
160 * blank tab behind.
161 *
162 * Resolves false when the desktop shell refused the URL (e.g. an older
163 * build whose protocol allowlist predates editor schemes) so callers do not
164 * record a successful open that never happened.
165 */
166export async function openRemoteEditorUrl(url: string): Promise<boolean> {
167 const bridge = window.desktopBridge;
168 if (bridge !== undefined) {
169 try {
170 return await bridge.openExternal(url);
171 } catch {
172 return false;
173 }
174 }
175 window.location.assign(url);
176 return true;
177}
178
179/**
180 * One-time "you need SSH keys on that machine" hint, shown in the picker menu
181 * until the first remote open fires (we cannot observe SSH success from here,
182 * so first click is the dismiss signal).
183 */
184const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen";
185
186export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] {
187 const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean);
188 return [seen, () => setSeen(true)] as const;
189}
packages/contracts/src/editor.ts+76-3
......@@ -10,20 +10,45 @@ type EditorDefinition = {
1010 readonly commands: readonly [string, ...string[]] | null;
1111 readonly baseArgs?: readonly string[];
1212 readonly launchStyle: EditorLaunchStyle;
13 /**
14 * URL scheme for editors that support VS Code's remote deep links
15 * (`<scheme>://vscode-remote/ssh-remote+<host><path>`). Only set for VS Code
16 * and forks that ship the Remote-SSH machinery.
17 */
18 readonly remoteScheme?: string;
1319};
1420
1521export const EDITORS = [
16 { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" },
22 {
23 id: "cursor",
24 label: "Cursor",
25 commands: ["cursor"],
26 launchStyle: "goto",
27 remoteScheme: "cursor",
28 },
1729 { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" },
1830 { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" },
19 { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" },
31 {
32 id: "vscode",
33 label: "VS Code",
34 commands: ["code"],
35 launchStyle: "goto",
36 remoteScheme: "vscode",
37 },
2038 {
2139 id: "vscode-insiders",
2240 label: "VS Code Insiders",
2341 commands: ["code-insiders"],
2442 launchStyle: "goto",
43 remoteScheme: "vscode-insiders",
44 },
45 {
46 id: "vscodium",
47 label: "VSCodium",
48 commands: ["codium"],
49 launchStyle: "goto",
50 remoteScheme: "vscodium",
2551 },
26 { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" },
2752 { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" },
2853 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },
2954 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
......@@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({
5075});
5176export type LaunchEditorInput = typeof LaunchEditorInput.Type;
5277
78const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme;
79
80/** Editors that can open a remote workspace via `vscode-remote` deep links. */
81export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray<EditorId> = EDITORS.flatMap((editor) =>
82 remoteSchemeOf(editor) !== undefined ? [editor.id] : [],
83);
84
85export const remoteSchemeForEditor = (id: EditorId): string | undefined => {
86 const editor = EDITORS.find((candidate) => candidate.id === id);
87 return editor === undefined ? undefined : remoteSchemeOf(editor);
88};
89
90/**
91 * Builds a `<scheme>://vscode-remote/ssh-remote+<host><path>` deep link that
92 * opens `absolutePath` on `host` in the local editor over SSH. Returns
93 * undefined for editors without remote deep-link support.
94 */
95export const buildRemoteOpenUrl = (input: {
96 readonly editor: EditorId;
97 readonly host: string;
98 readonly absolutePath: string;
99}): string | undefined => {
100 const scheme = remoteSchemeForEditor(input.editor);
101 if (scheme === undefined) {
102 return undefined;
103 }
104 // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs.
105 const posixPath = input.absolutePath.replaceAll("\\", "/");
106 const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
107 const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/");
108 return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`;
109};
110
111/**
112 * SSH hostnames an environment advertises for remote open links. Reachability
113 * is client-side; the server only advertises names that resolve to itself and
114 * gates them on a local sshd listen check. Ordered most-reachable first
115 * (tailnet MagicDNS name, then mDNS `<hostname>.local`).
116 */
117export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]);
118export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type;
119
120export const RemoteOpenTarget = Schema.Struct({
121 kind: RemoteOpenTargetKind,
122 host: TrimmedNonEmptyString,
123});
124export type RemoteOpenTarget = typeof RemoteOpenTarget.Type;
125
53126export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()(
54127 "ExternalLauncherUnknownEditorError",
55128 {
packages/contracts/src/ipc.ts+7
......@@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr
9292import { AdvertisedEndpoint } from "./remoteAccess.ts";
9393import { ExecutionEnvironmentDescriptor } from "./environment.ts";
9494import type { ClientSettings } from "./settings.ts";
95import type { EditorId } from "./editor.ts";
9596import type {
9697 SourceControlCloneRepositoryInput,
9798 SourceControlCloneRepositoryResult,
......@@ -1072,6 +1073,12 @@ export interface DesktopBridge {
10721073 position?: { x: number; y: number },
10731074 ) => Promise<T | null>;
10741075 openExternal: (url: string) => Promise<boolean>;
1076 /**
1077 * Probe this desktop machine for installed remote-capable editor CLIs
1078 * (used for remote open-in-editor deep links). Optional: older desktop
1079 * builds lack it; callers fall back to VS Code only.
1080 */
1081 probeRemoteEditors?: () => Promise<readonly EditorId[]>;
10751082 onMenuAction: (listener: (action: string) => void) => () => void;
10761083 getWindowFullscreenState: () => boolean;
10771084 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
packages/contracts/src/server.ts+7-1
......@@ -17,7 +17,7 @@ import {
1717 KeybindingWhen,
1818 ResolvedKeybindingsConfig,
1919} from "./keybindings.ts";
20import { EditorId } from "./editor.ts";
20import { EditorId, RemoteOpenTarget } from "./editor.ts";
2121import { ModelCapabilities } from "./model.ts";
2222import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
2323import { ServerSettings } from "./settings.ts";
......@@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({
428428 // Editor ids grow over time; drop ones this build does not know rather than
429429 // failing the whole config decode.
430430 availableEditors: ForwardCompatibleArray(EditorId),
431 /**
432 * SSH hosts this environment advertises for remote open-in-editor links.
433 * Absent on servers that predate the feature; empty when the machine has no
434 * sshd or no advertisable name.
435 */
436 remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)),
431437 observability: ServerObservability,
432438 settings: ServerSettings,
433439 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
packages/shared/src/Net.ts+7
......@@ -39,6 +39,12 @@ export interface NetServiceShape {
3939 */
4040 readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>;
4141
42 /**
43 * Returns true when something accepts TCP connections on {host, port}.
44 * Unlike the bind-side checks this works for privileged ports (<1024).
45 */
46 readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect<boolean>;
47
4248 /**
4349 * Reserve an ephemeral loopback port and release it immediately.
4450 */
......@@ -183,6 +189,7 @@ export const make = () => {
183189 return {
184190 canListenOnHost,
185191 isPortAvailableOnLoopback,
192 hasListenerOnHost,
186193 reserveLoopbackPort,
187194 findAvailablePort: (preferred) =>
188195 Effect.gen(function* () {
packages/ssh/src/tunnel.test.ts+1
......@@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never);
8080const testNetService = NetService.NetService.of({
8181 canListenOnHost: () => Effect.succeed(true),
8282 isPortAvailableOnLoopback: () => Effect.succeed(true),
83 hasListenerOnHost: () => Effect.succeed(false),
8384 reserveLoopbackPort: () => Effect.succeed(41_773),
8485 findAvailablePort: (preferred) => Effect.succeed(preferred),
8586});
scripts/dev-runner.test.ts+1
......@@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }
3535const netServiceLayer = Layer.succeed(NetService.NetService, {
3636 canListenOnHost: () => Effect.succeed(true),
3737 isPortAvailableOnLoopback: () => Effect.succeed(true),
38 hasListenerOnHost: () => Effect.succeed(false),
3839 reserveLoopbackPort: () => Effect.succeed(49_152),
3940 findAvailablePort: (port) => Effect.succeed(port),
4041});