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 @@...@@ -1,3 +1,4 @@
1import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -5,7 +6,16 @@ import * as Option from "effect/Option";...@@ -5,7 +6,16 @@ import * as Option from "effect/Option";
56
6import * as Electron from "electron";7import * 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
10export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {20export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
11 if (typeof rawUrl !== "string") {21 if (typeof rawUrl !== "string") {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 getLocalEnvironmentBearerToken,36 getLocalEnvironmentBearerToken,
37 getWindowFullscreenState,37 getWindowFullscreenState,
38 openExternal,38 openExternal,
39 probeRemoteEditors,
39 pickFolder,40 pickFolder,
40 pickThemeFiles,41 pickThemeFiles,
41 setTheme,42 setTheme,
...@@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
83 yield* ipc.handle(setTheme);84 yield* ipc.handle(setTheme);
84 yield* ipc.handle(showContextMenu);85 yield* ipc.handle(showContextMenu);
85 yield* ipc.handle(openExternal);86 yield* ipc.handle(openExternal);
87 yield* ipc.handle(probeRemoteEditors);
86 yield* ipc.handle(getUpdateState);88 yield* ipc.handle(getUpdateState);
87 yield* ipc.handle(setUpdateChannel);89 yield* ipc.handle(setUpdateChannel);
88 yield* ipc.handle(downloadUpdate);90 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";...@@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
3export const SET_THEME_CHANNEL = "desktop:set-theme";3export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
6export const MENU_ACTION_CHANNEL = "desktop:menu-action";7export const MENU_ACTION_CHANNEL = "desktop:menu-action";
7export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";8export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
8export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";9export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
apps/desktop/src/ipc/methods/window.ts+28
...@@ -3,12 +3,16 @@ import {...@@ -3,12 +3,16 @@ import {
3 DesktopAppBrandingSchema,3 DesktopAppBrandingSchema,
4 DesktopEnvironmentBootstrapSchema,4 DesktopEnvironmentBootstrapSchema,
5 DesktopThemeSchema,5 DesktopThemeSchema,
6 EDITORS,
7 EditorId,
6 PickedThemeFileSchema,8 PickedThemeFileSchema,
7 PickFolderOptionsSchema,9 PickFolderOptionsSchema,
8 PRIMARY_LOCAL_ENVIRONMENT_ID,10 PRIMARY_LOCAL_ENVIRONMENT_ID,
11 REMOTE_CAPABLE_EDITOR_IDS,
9 type DesktopEnvironmentBootstrap,12 type DesktopEnvironmentBootstrap,
10 type PickedThemeFile,13 type PickedThemeFile,
11} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { isCommandAvailable } from "@t3tools/shared/shell";
12import * as NodeOS from "node:os";16import * as NodeOS from "node:os";
13import * as FileSystem from "effect/FileSystem";17import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";18import * as Path from "effect/Path";
...@@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({...@@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({
261 }),265 }),
262});266});
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
264/** Theme files are a few KB; anything larger returns empty text and lets the292/** Theme files are a few KB; anything larger returns empty text and lets the
265 * renderer reject it by size without the contents ever crossing the bridge. */293 * renderer reject it by size without the contents ever crossing the bridge. */
266const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;294const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
apps/desktop/src/preload.ts+1
...@@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
105 ...(position === undefined ? {} : { position }),105 ...(position === undefined ? {} : { position }),
106 }),106 }),
107 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),107 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
108 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
108 onMenuAction: (listener) => {109 onMenuAction: (listener) => {
109 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {110 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
110 if (typeof action !== "string") return;111 if (typeof action !== "string") return;
apps/desktop/src/wsl/DesktopWslBackend.test.ts+1
...@@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed(...@@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed(
77const netLayer = Layer.succeed(NetService.NetService, {77const netLayer = Layer.succeed(NetService.NetService, {
78 canListenOnHost: () => Effect.succeed(true),78 canListenOnHost: () => Effect.succeed(true),
79 isPortAvailableOnLoopback: () => Effect.succeed(true),79 isPortAvailableOnLoopback: () => Effect.succeed(true),
80 hasListenerOnHost: () => Effect.succeed(false),
80 reserveLoopbackPort: () => Effect.succeed(41773),81 reserveLoopbackPort: () => Effect.succeed(41773),
81 findAvailablePort: (preferred) => Effect.succeed(preferred),82 findAvailablePort: (preferred) => Effect.succeed(preferred),
82} satisfies NetService.NetService["Service"]);83} 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;...@@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null;
47const TestIntegrationNet = Layer.succeed(Net.NetService, {47const TestIntegrationNet = Layer.succeed(Net.NetService, {
48 canListenOnHost: () => Effect.succeed(true),48 canListenOnHost: () => Effect.succeed(true),
49 isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort),49 isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort),
50 hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort),
50 reserveLoopbackPort: () => Effect.succeed(40_000),51 reserveLoopbackPort: () => Effect.succeed(40_000),
51 findAvailablePort: (preferred) => Effect.succeed(preferred),52 findAvailablePort: (preferred) => Effect.succeed(preferred),
52});53});
...@@ -62,6 +63,7 @@ const makeProbeFailureLayer = (...@@ -62,6 +63,7 @@ const makeProbeFailureLayer = (
62 Layer.succeed(Net.NetService, {63 Layer.succeed(Net.NetService, {
63 canListenOnHost: () => Effect.succeed(true),64 canListenOnHost: () => Effect.succeed(true),
64 isPortAvailableOnLoopback: () => Effect.succeed(true),65 isPortAvailableOnLoopback: () => Effect.succeed(true),
66 hasListenerOnHost: () => Effect.succeed(false),
65 reserveLoopbackPort: () => Effect.succeed(40_000),67 reserveLoopbackPort: () => Effect.succeed(40_000),
66 findAvailablePort: (preferred) => Effect.succeed(preferred),68 findAvailablePort: (preferred) => Effect.succeed(preferred),
67 }),69 }),
...@@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: {...@@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: {
107 Layer.succeed(Net.NetService, {109 Layer.succeed(Net.NetService, {
108 canListenOnHost: () => Effect.succeed(true),110 canListenOnHost: () => Effect.succeed(true),
109 isPortAvailableOnLoopback: () => Effect.succeed(true),111 isPortAvailableOnLoopback: () => Effect.succeed(true),
112 hasListenerOnHost: () => Effect.succeed(false),
110 reserveLoopbackPort: () => Effect.succeed(40_000),113 reserveLoopbackPort: () => Effect.succeed(40_000),
111 findAvailablePort: (preferred) => Effect.succeed(preferred),114 findAvailablePort: (preferred) => Effect.succeed(preferred),
112 }),115 }),
apps/server/src/server.test.ts+10-4
...@@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";...@@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
107import * as GitManager from "./git/GitManager.ts";107import * as GitManager from "./git/GitManager.ts";
108import * as Keybindings from "./keybindings.ts";108import * as Keybindings from "./keybindings.ts";
109import * as ExternalLauncher from "./process/externalLauncher.ts";109import * as ExternalLauncher from "./process/externalLauncher.ts";
110import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
110import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";111import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
111import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";112import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts";
112import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";113import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
...@@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: {...@@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: {
650 }),651 }),
651 ),652 ),
652 Layer.provide(653 Layer.provide(
653 Layer.mock(ExternalLauncher.ExternalLauncher)({654 Layer.mergeAll(
654 resolveAvailableEditors: () => Effect.succeed([]),655 Layer.mock(ExternalLauncher.ExternalLauncher)({
655 ...options?.layers?.externalLauncher,656 resolveAvailableEditors: () => Effect.succeed([]),
656 }),657 ...options?.layers?.externalLauncher,
658 }),
659 Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({
660 resolveTargets: () => Effect.succeed([]),
661 }),
662 ),
657 ),663 ),
658 Layer.provide(664 Layer.provide(
659 Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({665 Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({
apps/server/src/server.ts+2
...@@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe...@@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe
81import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";81import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
82import { ObservabilityLive } from "./observability/Layers/Observability.ts";82import { ObservabilityLive } from "./observability/Layers/Observability.ts";
83import * as ServerEnvironment from "./environment/ServerEnvironment.ts";83import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
84import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
84import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";85import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
85import * as ServerSecretStore from "./auth/ServerSecretStore.ts";86import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
86import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";87import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
...@@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(...@@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
420 Layer.provideMerge(TraceDiagnostics.layer),421 Layer.provideMerge(TraceDiagnostics.layer),
421 Layer.provideMerge(AnalyticsService.layer),422 Layer.provideMerge(AnalyticsService.layer),
422 Layer.provideMerge(ExternalLauncher.layer),423 Layer.provideMerge(ExternalLauncher.layer),
424 Layer.provideMerge(RemoteOpenTargets.layer),
423 Layer.provideMerge(ServerLifecycleEvents.layer),425 Layer.provideMerge(ServerLifecycleEvents.layer),
424 Layer.provide(NetService.layer),426 Layer.provide(NetService.layer),
425);427);
apps/server/src/ws.ts+7
...@@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";...@@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
99import * as ReviewService from "./review/ReviewService.ts";99import * as ReviewService from "./review/ReviewService.ts";
100import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";100import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
101import * as ServerEnvironment from "./environment/ServerEnvironment.ts";101import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
102import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
102import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";103import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
103import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";104import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
104import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";105import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
...@@ -361,6 +362,7 @@ const makeWsRpcLayer = (...@@ -361,6 +362,7 @@ const makeWsRpcLayer = (
361 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;362 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
362 const keybindings = yield* Keybindings.Keybindings;363 const keybindings = yield* Keybindings.Keybindings;
363 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;364 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
365 const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets;
364 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;366 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
365 const review = yield* ReviewService.ReviewService;367 const review = yield* ReviewService.ReviewService;
366 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;368 const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService;
...@@ -1010,6 +1012,11 @@ const makeWsRpcLayer = (...@@ -1010,6 +1012,11 @@ const makeWsRpcLayer = (
1010 availableEditors: yield* resolveAvailableEditorsForConfig(1012 availableEditors: yield* resolveAvailableEditorsForConfig(
1011 externalLauncher.resolveAvailableEditors(),1013 externalLauncher.resolveAvailableEditors(),
1012 ),1014 ),
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 ),
1013 observability: {1020 observability: {
1014 logsDirectoryPath: config.logsDir,1021 logsDirectoryPath: config.logsDir,
1015 localTracingEnabled: true,1022 localTracingEnabled: true,
apps/web/src/components/chat/ChatHeader.test.ts+18-3
...@@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => {...@@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => {
12 activeProjectName: "codething-mvp",12 activeProjectName: "codething-mvp",
13 activeThreadEnvironmentId: primaryEnvironmentId,13 activeThreadEnvironmentId: primaryEnvironmentId,
14 primaryEnvironmentId,14 primaryEnvironmentId,
15 remoteOpenMode: "local-exec",
15 }),16 }),
16 ).toBe(true);17 ).toBe(true);
17 });18 });
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", () => {
20 expect(32 expect(
21 shouldShowOpenInPicker({33 shouldShowOpenInPicker({
22 activeProjectName: "codething-mvp",34 activeProjectName: "codething-mvp",
23 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),35 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
24 primaryEnvironmentId: null,36 primaryEnvironmentId: null,
37 remoteOpenMode: "remote-unavailable",
25 }),38 }),
26 ).toBe(false);39 ).toBe(true);
27 });40 });
2841
29 it("hides the picker for remote environments", () => {42 it("hides the picker for non-primary local backends", () => {
30 expect(43 expect(
31 shouldShowOpenInPicker({44 shouldShowOpenInPicker({
32 activeProjectName: "codething-mvp",45 activeProjectName: "codething-mvp",
33 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),46 activeThreadEnvironmentId: EnvironmentId.make("environment-remote"),
34 primaryEnvironmentId,47 primaryEnvironmentId,
48 remoteOpenMode: "local-exec",
35 }),49 }),
36 ).toBe(false);50 ).toBe(false);
37 });51 });
...@@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => {...@@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => {
42 activeProjectName: undefined,56 activeProjectName: undefined,
43 activeThreadEnvironmentId: primaryEnvironmentId,57 activeThreadEnvironmentId: primaryEnvironmentId,
44 primaryEnvironmentId,58 primaryEnvironmentId,
59 remoteOpenMode: "remote-links",
45 }),60 }),
46 ).toBe(false);61 ).toBe(false);
47 });62 });
apps/web/src/components/chat/ChatHeader.tsx+13-3
...@@ -30,6 +30,7 @@ import ProjectScriptsControl, {...@@ -30,6 +30,7 @@ import ProjectScriptsControl, {
30 type ProjectScriptActionResult,30 type ProjectScriptActionResult,
31} from "../ProjectScriptsControl";31} from "../ProjectScriptsControl";
32import { OpenInPicker } from "./OpenInPicker";32import { OpenInPicker } from "./OpenInPicker";
33import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen";
33import { usePrimaryEnvironmentId } from "../../state/environments";34import { usePrimaryEnvironmentId } from "../../state/environments";
34import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";35import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
35import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";36import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
...@@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: {...@@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: {
91 readonly activeProjectName: string | undefined;92 readonly activeProjectName: string | undefined;
92 readonly activeThreadEnvironmentId: EnvironmentId;93 readonly activeThreadEnvironmentId: EnvironmentId;
93 readonly primaryEnvironmentId: EnvironmentId | null;94 readonly primaryEnvironmentId: EnvironmentId | null;
95 readonly remoteOpenMode: RemoteOpenMode;
94}): boolean {96}): boolean {
95 return (97 if (!input.activeProjectName) return false;
96 Boolean(input.activeProjectName) &&98 if (
97 input.primaryEnvironmentId !== null &&99 input.primaryEnvironmentId !== null &&
98 input.activeThreadEnvironmentId === input.primaryEnvironmentId100 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";
100}108}
101109
102export const ChatHeader = memo(function ChatHeader({110export const ChatHeader = memo(function ChatHeader({
...@@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({...@@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({
128 activeThreadEnvironmentId,136 activeThreadEnvironmentId,
129 activeProjectScripts ? activeProjectCwd : null,137 activeProjectScripts ? activeProjectCwd : null,
130 );138 );
139 const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId);
131 const showOpenInPicker = shouldShowOpenInPicker({140 const showOpenInPicker = shouldShowOpenInPicker({
132 activeProjectName,141 activeProjectName,
133 activeThreadEnvironmentId,142 activeThreadEnvironmentId,
134 primaryEnvironmentId,143 primaryEnvironmentId,
144 remoteOpenMode: remoteOpenState.mode,
135 });145 });
136 const activeThreadRef = useMemo(146 const activeThreadRef = useMemo(
137 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),147 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
apps/web/src/components/chat/OpenInPicker.tsx+70-30
...@@ -1,7 +1,19 @@...@@ -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";
2import { memo, useCallback, useEffect, useMemo } from "react";7import { memo, useCallback, useEffect, useMemo } from "react";
3import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";8import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
4import { usePreferredEditor } from "../../editorPreferences";9import { usePreferredEditor } from "../../editorPreferences";
10import {
11 openRemoteEditorUrl,
12 useRemoteCapableEditors,
13 useRemoteOpenHint,
14 useRemoteOpenState,
15} from "../../remoteOpen";
16import { useEnvironment } from "../../state/environments";
5import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";17import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";
6import { Button } from "../ui/button";18import { Button } from "../ui/button";
7import { Group, GroupSeparator } from "../ui/group";19import { Group, GroupSeparator } from "../ui/group";
...@@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({
199 enableShortcut?: boolean;211 enableShortcut?: boolean;
200}) {212}) {
201 const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");213 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);
203 const options = useMemo(222 const options = useMemo(
204 () => resolveOptions(navigator.platform, availableEditors),223 () => resolveOptions(navigator.platform, effectiveEditors),
205 [availableEditors],224 [effectiveEditors],
206 );225 );
207 const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null;226 const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null;
208227
...@@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({
211 if (!openInCwd) return;230 if (!openInCwd) return;
212 const editor = editorId ?? preferredEditor;231 const editor = editorId ?? preferredEditor;
213 if (!editor) return;232 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 }
214 const result = openInEditorMutation({250 const result = openInEditorMutation({
215 environmentId,251 environmentId,
216 input: {252 input: {
...@@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({
221 setPreferredEditor(editor);257 setPreferredEditor(editor);
222 return result;258 return result;
223 },259 },
224 [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor],260 [
261 environmentId,
262 markRemoteHintSeen,
263 openInCwd,
264 openInEditorMutation,
265 preferredEditor,
266 remote,
267 setPreferredEditor,
268 ],
225 );269 );
226270
227 const openFavoriteEditorShortcutLabel = useMemo(271 const openFavoriteEditorShortcutLabel = useMemo(
...@@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({
237 if (!preferredEditor) return;281 if (!preferredEditor) return;
238282
239 e.preventDefault();283 e.preventDefault();
240 void openInEditorMutation({284 void openInEditor(preferredEditor);
241 environmentId,
242 input: {
243 cwd: openInCwd,
244 editor: preferredEditor,
245 },
246 });
247 };285 };
248 window.addEventListener("keydown", handler);286 window.addEventListener("keydown", handler);
249 return () => window.removeEventListener("keydown", handler);287 return () => window.removeEventListener("keydown", handler);
250 }, [288 }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]);
251 enableShortcut,
252 environmentId,
253 keybindings,
254 openInCwd,
255 openInEditorMutation,
256 preferredEditor,
257 ]);
258289
259 return (290 return (
260 <Group aria-label="Open in editor">291 <Group aria-label="Open in editor">
...@@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({
263 className="ps-[8.5px]"294 className="ps-[8.5px]"
264 size="xs"295 size="xs"
265 variant="outline"296 variant="outline"
266 disabled={!preferredEditor || !openInCwd}297 disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"}
267 onClick={() => openInEditor(preferredEditor)}298 onClick={() => openInEditor(preferredEditor)}
268 >299 >
269 {primaryOption?.Icon && (300 {primaryOption?.Icon && (
...@@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({
296 <ChevronDownIcon aria-hidden="true" className="size-4" />327 <ChevronDownIcon aria-hidden="true" className="size-4" />
297 </MenuTrigger>328 </MenuTrigger>
298 <MenuPopup align="end">329 <MenuPopup align="end">
299 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}330 {remote.mode === "remote-unavailable" ? (
300 {options.map(({ label, Icon, value, kind }) => (331 <MenuItem disabled>No SSH route to {environmentLabel}</MenuItem>
301 <MenuItem key={value} onClick={() => openInEditor(value)}>332 ) : (
302 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />333 <>
303 {label}334 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}
304 {value === preferredEditor && openFavoriteEditorShortcutLabel && (335 {options.map(({ label, Icon, value, kind }) => (
305 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>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>
306 )}346 )}
307 </MenuItem>347 </>
308 ))}348 )}
309 </MenuPopup>349 </MenuPopup>
310 </Menu>350 </Menu>
311 </Group>351 </Group>
apps/web/src/components/files/FilePreviewPanel.tsx+4-1
...@@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre...@@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre
20import { useAssetUrlState } from "~/assets/assetUrls";20import { useAssetUrlState } from "~/assets/assetUrls";
21import ChatMarkdown from "~/components/ChatMarkdown";21import ChatMarkdown from "~/components/ChatMarkdown";
22import { OpenInPicker } from "~/components/chat/OpenInPicker";22import { OpenInPicker } from "~/components/chat/OpenInPicker";
23import { useRemoteOpenState } from "~/remoteOpen";
23import { useClientSettings } from "~/hooks/useSettings";24import { useClientSettings } from "~/hooks/useSettings";
24import { useTheme } from "~/hooks/useTheme";25import { useTheme } from "~/hooks/useTheme";
25import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";26import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage";
...@@ -771,6 +772,7 @@ export default function FilePreviewPanel({...@@ -771,6 +772,7 @@ export default function FilePreviewPanel({
771 const { resolvedTheme } = useTheme();772 const { resolvedTheme } = useTheme();
772 const wordWrap = useClientSettings((settings) => settings.wordWrap);773 const wordWrap = useClientSettings((settings) => settings.wordWrap);
773 const primaryEnvironmentId = usePrimaryEnvironmentId();774 const primaryEnvironmentId = usePrimaryEnvironmentId();
775 const remoteOpenState = useRemoteOpenState(environmentId);
774 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);776 const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
775 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {777 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
776 reportFailure: false,778 reportFailure: false,
...@@ -893,7 +895,8 @@ export default function FilePreviewPanel({...@@ -893,7 +895,8 @@ export default function FilePreviewPanel({
893 ))}895 ))}
894 </div>896 </div>
895 </ScrollArea>897 </ScrollArea>
896 {absolutePath && environmentId === primaryEnvironmentId ? (898 {absolutePath &&
899 (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? (
897 <OpenInPicker900 <OpenInPicker
898 environmentId={environmentId}901 environmentId={environmentId}
899 keybindings={keybindings}902 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 = {...@@ -10,20 +10,45 @@ type EditorDefinition = {
10 readonly commands: readonly [string, ...string[]] | null;10 readonly commands: readonly [string, ...string[]] | null;
11 readonly baseArgs?: readonly string[];11 readonly baseArgs?: readonly string[];
12 readonly launchStyle: EditorLaunchStyle;12 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;
13};19};
1420
15export const EDITORS = [21export 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 },
17 { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" },29 { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" },
18 { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" },30 { 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 },
20 {38 {
21 id: "vscode-insiders",39 id: "vscode-insiders",
22 label: "VS Code Insiders",40 label: "VS Code Insiders",
23 commands: ["code-insiders"],41 commands: ["code-insiders"],
24 launchStyle: "goto",42 launchStyle: "goto",
43 remoteScheme: "vscode-insiders",
44 },
45 {
46 id: "vscodium",
47 label: "VSCodium",
48 commands: ["codium"],
49 launchStyle: "goto",
50 remoteScheme: "vscodium",
25 },51 },
26 { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" },
27 { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" },52 { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" },
28 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },53 { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" },
29 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },54 { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" },
...@@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({...@@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({
50});75});
51export type LaunchEditorInput = typeof LaunchEditorInput.Type;76export 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
53export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()(126export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()(
54 "ExternalLauncherUnknownEditorError",127 "ExternalLauncherUnknownEditorError",
55 {128 {
packages/contracts/src/ipc.ts+7
...@@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr...@@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr
92import { AdvertisedEndpoint } from "./remoteAccess.ts";92import { AdvertisedEndpoint } from "./remoteAccess.ts";
93import { ExecutionEnvironmentDescriptor } from "./environment.ts";93import { ExecutionEnvironmentDescriptor } from "./environment.ts";
94import type { ClientSettings } from "./settings.ts";94import type { ClientSettings } from "./settings.ts";
95import type { EditorId } from "./editor.ts";
95import type {96import type {
96 SourceControlCloneRepositoryInput,97 SourceControlCloneRepositoryInput,
97 SourceControlCloneRepositoryResult,98 SourceControlCloneRepositoryResult,
...@@ -1072,6 +1073,12 @@ export interface DesktopBridge {...@@ -1072,6 +1073,12 @@ export interface DesktopBridge {
1072 position?: { x: number; y: number },1073 position?: { x: number; y: number },
1073 ) => Promise<T | null>;1074 ) => Promise<T | null>;
1074 openExternal: (url: string) => Promise<boolean>;1075 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[]>;
1075 onMenuAction: (listener: (action: string) => void) => () => void;1082 onMenuAction: (listener: (action: string) => void) => () => void;
1076 getWindowFullscreenState: () => boolean;1083 getWindowFullscreenState: () => boolean;
1077 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;1084 onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
packages/contracts/src/server.ts+7-1
...@@ -17,7 +17,7 @@ import {...@@ -17,7 +17,7 @@ import {
17 KeybindingWhen,17 KeybindingWhen,
18 ResolvedKeybindingsConfig,18 ResolvedKeybindingsConfig,
19} from "./keybindings.ts";19} from "./keybindings.ts";
20import { EditorId } from "./editor.ts";20import { EditorId, RemoteOpenTarget } from "./editor.ts";
21import { ModelCapabilities } from "./model.ts";21import { ModelCapabilities } from "./model.ts";
22import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";22import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
23import { ServerSettings } from "./settings.ts";23import { ServerSettings } from "./settings.ts";
...@@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({...@@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({
428 // Editor ids grow over time; drop ones this build does not know rather than428 // Editor ids grow over time; drop ones this build does not know rather than
429 // failing the whole config decode.429 // failing the whole config decode.
430 availableEditors: ForwardCompatibleArray(EditorId),430 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)),
431 observability: ServerObservability,437 observability: ServerObservability,
432 settings: ServerSettings,438 settings: ServerSettings,
433 /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */439 /** 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 {...@@ -39,6 +39,12 @@ export interface NetServiceShape {
39 */39 */
40 readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect<boolean>;40 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
42 /**48 /**
43 * Reserve an ephemeral loopback port and release it immediately.49 * Reserve an ephemeral loopback port and release it immediately.
44 */50 */
...@@ -183,6 +189,7 @@ export const make = () => {...@@ -183,6 +189,7 @@ export const make = () => {
183 return {189 return {
184 canListenOnHost,190 canListenOnHost,
185 isPortAvailableOnLoopback,191 isPortAvailableOnLoopback,
192 hasListenerOnHost,
186 reserveLoopbackPort,193 reserveLoopbackPort,
187 findAvailablePort: (preferred) =>194 findAvailablePort: (preferred) =>
188 Effect.gen(function* () {195 Effect.gen(function* () {
packages/ssh/src/tunnel.test.ts+1
...@@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never);...@@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never);
80const testNetService = NetService.NetService.of({80const testNetService = NetService.NetService.of({
81 canListenOnHost: () => Effect.succeed(true),81 canListenOnHost: () => Effect.succeed(true),
82 isPortAvailableOnLoopback: () => Effect.succeed(true),82 isPortAvailableOnLoopback: () => Effect.succeed(true),
83 hasListenerOnHost: () => Effect.succeed(false),
83 reserveLoopbackPort: () => Effect.succeed(41_773),84 reserveLoopbackPort: () => Effect.succeed(41_773),
84 findAvailablePort: (preferred) => Effect.succeed(preferred),85 findAvailablePort: (preferred) => Effect.succeed(preferred),
85});86});
scripts/dev-runner.test.ts+1
...@@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }...@@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }
35const netServiceLayer = Layer.succeed(NetService.NetService, {35const netServiceLayer = Layer.succeed(NetService.NetService, {
36 canListenOnHost: () => Effect.succeed(true),36 canListenOnHost: () => Effect.succeed(true),
37 isPortAvailableOnLoopback: () => Effect.succeed(true),37 isPortAvailableOnLoopback: () => Effect.succeed(true),
38 hasListenerOnHost: () => Effect.succeed(false),
38 reserveLoopbackPort: () => Effect.succeed(49_152),39 reserveLoopbackPort: () => Effect.succeed(49_152),
39 findAvailablePort: (port) => Effect.succeed(port),40 findAvailablePort: (port) => Effect.succeed(port),
40});41});