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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,10 +866,12 @@ describe("DesktopBackendConfiguration", () => {
const previousWslEnv = process.env.WSLENV;
const previousOpenAiKey = process.env.OPENAI_API_KEY;
const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
const previousRemoteOpenHost = process.env.T3CODE_REMOTE_OPEN_HOST;
try {
process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u";
process.env.OPENAI_API_KEY = "openai-key";
process.env.ANTHROPIC_API_KEY = "anthropic-key";
process.env.T3CODE_REMOTE_OPEN_HOST = "sol";

yield* Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
Expand All @@ -889,13 +891,14 @@ describe("DesktopBackendConfiguration", () => {
assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/");
assert.equal(config.env.OPENAI_API_KEY, "openai-key");
assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key");
assert.equal(config.env.T3CODE_REMOTE_OPEN_HOST, "sol");
// The existing WSLENV is preserved byte-for-byte (note the empty
// "::" segment survives — WSL ignores it, so we don't normalize
// it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is
// already declared, so it isn't forwarded twice.
// it away) and the remaining forwarded names are appended.
// OPENAI_API_KEY is already declared, so it isn't forwarded twice.
assert.equal(
config.env.WSLENV,
"GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY",
"GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_REMOTE_OPEN_HOST",
);
}).pipe(
Effect.provide(
Expand All @@ -918,6 +921,7 @@ describe("DesktopBackendConfiguration", () => {
restoreEnv("WSLENV", previousWslEnv);
restoreEnv("OPENAI_API_KEY", previousOpenAiKey);
restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey);
restoreEnv("T3CODE_REMOTE_OPEN_HOST", previousRemoteOpenHost);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
Expand Down
15 changes: 10 additions & 5 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,16 @@ const DESKTOP_BACKEND_ENV_NAMES = [
"T3CODE_TAILSCALE_SERVE_PORT",
] as const;

// Sensitive env vars that the WSL backend needs but Windows process.env won't
// forward across the wsl.exe boundary without WSLENV. The dev-server URL is
// handled separately via a `--dev-url` CLI flag because WSLENV translation of
// URL-shaped values (colons / slashes) is unreliable.
const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const;
// Env vars that the WSL backend needs but Windows process.env won't forward
// across the wsl.exe boundary without WSLENV: provider secrets and the remote
// open-in-editor host operators set on the desktop process. The dev-server
// URL is handled separately via a `--dev-url` CLI flag because WSLENV
// translation of URL-shaped values (colons / slashes) is unreliable.
const WSL_FORWARDED_ENV_NAMES = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"T3CODE_REMOTE_OPEN_HOST",
] as const;

const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const EnvServerConfig = Config.all({
Config.option,
Config.map(Option.getOrUndefined),
),
remoteOpenHost: Config.string("T3CODE_REMOTE_OPEN_HOST").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
Comment thread
HaukeSchnau marked this conversation as resolved.
});

export interface CliServerFlags {
Expand Down Expand Up @@ -384,6 +388,7 @@ export const resolveServerConfig = (
logWebSocketEvents,
tailscaleServeEnabled,
tailscaleServePort,
remoteOpenHost: env.remoteOpenHost?.trim() || undefined,
};

return config;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export class ServerConfig extends Context.Service<
readonly logWebSocketEvents: boolean;
readonly tailscaleServeEnabled: boolean;
readonly tailscaleServePort: number;
/** Hostname or ssh config alias clients use for remote open-in-editor deep links. */
readonly remoteOpenHost?: string | undefined;
}
>()("t3/config/ServerConfig") {
/** @deprecated Import and use `layerTest` from this module. */
Expand Down
49 changes: 48 additions & 1 deletion apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import { HostProcessHostname } from "@t3tools/shared/hostProcess";
import * as NetService from "@t3tools/shared/Net";
Expand All @@ -8,6 +9,7 @@ import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
import { describe, expect } from "vite-plus/test";

import * as ServerConfig from "../config.ts";
import * as RemoteOpenTargets from "./RemoteOpenTargets.ts";

const encoder = new TextEncoder();
Expand Down Expand Up @@ -48,16 +50,33 @@ const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
findAvailablePort: (preferred) => Effect.succeed(preferred),
});

/** Test server config with the remote-open alias applied on top. */
const serverConfigLayer = (remoteOpenHost: string | undefined) =>
Layer.effect(
ServerConfig.ServerConfig,
Effect.map(ServerConfig.ServerConfig, (base) => ServerConfig.make({ ...base, remoteOpenHost })),
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "remote-open-targets-" })),
Layer.provide(NodeServices.layer),
);

const resolveTargets = (input: {
readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
readonly tailscale: { readonly exitCode: number; readonly stdout: string };
readonly hostname: string;
readonly remoteOpenHost?: string;
}) =>
Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
Effect.provideService(HostProcessHostname, input.hostname),
Effect.provide(
RemoteOpenTargets.layer.pipe(
Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))),
Layer.provide(
Layer.mergeAll(
netLayer(input.sshd),
spawnerLayer(input.tailscale),
serverConfigLayer(input.remoteOpenHost),
),
),
),
),
);
Expand All @@ -77,6 +96,34 @@ describe("RemoteOpenTargets", () => {
}),
);

it.effect("advertises the configured host ahead of probed names", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: true, ipv6: true },
tailscale: TAILSCALE_UP,
hostname: "bb-1",
remoteOpenHost: "bb-1",
});
expect(targets).toEqual([
{ kind: "configured", host: "bb-1" },
{ kind: "tailscale", host: "bb-1.tail1234.ts.net" },
{ kind: "mdns", host: "bb-1.local" },
]);
}),
);

it.effect("advertises the configured host even when nothing can be probed", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
sshd: { ipv4: false, ipv6: false },
tailscale: TAILSCALE_UP,
hostname: "bb-1",
remoteOpenHost: "bb-1",
});
expect(targets).toEqual([{ kind: "configured", host: "bb-1" }]);
}),
);

it.effect("orders the tailnet name before the mDNS name", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
Expand Down
27 changes: 21 additions & 6 deletions apps/server/src/environment/RemoteOpenTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
* RemoteOpenTargets - resolves the SSH hostnames this environment advertises
* for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`).
*
* The server can only check itself: sshd listening locally, tailscaled
* reporting a MagicDNS name, and the machine hostname for mDNS. Whether a
* given name resolves from the viewer's machine is inherently client-side.
* Targets are ordered most-reachable first (tailnet name works from anywhere
* on the tailnet; `<hostname>.local` only on the same LAN).
* An operator-configured host (`T3CODE_REMOTE_OPEN_HOST`) comes first: an
* ssh config alias there resolves user, port and key through the viewer's
* own `~/.ssh/config`, which a bare hostname cannot express. The probed names
* still follow it so clients that predate the `configured` kind keep the
* target they had.
*
* For probing, the server can only check itself: sshd listening locally,
* tailscaled reporting a MagicDNS name, and the machine hostname for mDNS.
* Whether a given name resolves from the viewer's machine is inherently
* client-side. Targets are ordered most-reachable first (tailnet name works
* from anywhere on the tailnet; `<hostname>.local` only on the same LAN).
*/
import { type RemoteOpenTarget } from "@t3tools/contracts";
import { HostProcessHostname } from "@t3tools/shared/hostProcess";
Expand All @@ -17,6 +23,8 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import { ServerConfig } from "../config.ts";

const SSH_PORT = 22;

export class RemoteOpenTargets extends Context.Service<
Expand All @@ -30,8 +38,9 @@ export class RemoteOpenTargets extends Context.Service<
export const make = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const net = yield* NetService.NetService;
const { remoteOpenHost } = yield* ServerConfig;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import the local service module as a namespace and acquire it through its public tag (ServerConfig.ServerConfig). The new yield* ServerConfig uses a named service import, which loses the module boundary convention. This needs changes at both the import and acquisition sites.

Posted via Macroscope — Effect Service Conventions


const resolveTargets = Effect.gen(function* () {
const probeTargets = Effect.gen(function* () {
// No local sshd means no name can work; advertise nothing so clients
// render a clear "no SSH route" state instead of links that hang.
// Check both loopback families: sshd can be bound IPv6-only.
Expand Down Expand Up @@ -67,6 +76,12 @@ export const make = Effect.gen(function* () {
return targets;
});

const resolveTargets = Effect.map(probeTargets, (probed): ReadonlyArray<RemoteOpenTarget> =>
remoteOpenHost === undefined
? probed
: [{ kind: "configured", host: remoteOpenHost }, ...probed],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets });
});

Expand Down
12 changes: 12 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ For a plain HTTP LAN endpoint, use the direct pairing URL in a browser that can
open it, or pair from the desktop app. On mobile, an IP address entered without a
scheme uses HTTP, so include `https://` when your server uses HTTPS.

## Open a remote project in your editor

When you view a server from another machine, the **Open** button in the thread
header opens the project in your local editor over SSH instead of launching an
editor on the server.

The link uses a hostname the server advertises for itself. If that name does not
resolve from your machine, or your server uses a non-default SSH port or user,
set `T3CODE_REMOTE_OPEN_HOST` in the server's environment to a hostname or an
alias from your `~/.ssh/config`. An alias lets the editor resolve the port, user
and key from your SSH config. Your SSH key must be authorized on the server.

## Desktop-managed SSH

In the desktop app, open **Settings → Connections → Add environment**, choose
Expand Down
11 changes: 7 additions & 4 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,14 @@ export const buildRemoteOpenUrl = (input: {

/**
* SSH hostnames an environment advertises for remote open links. Reachability
* is client-side; the server only advertises names that resolve to itself and
* gates them on a local sshd listen check. Ordered most-reachable first
* (tailnet MagicDNS name, then mDNS `<hostname>.local`).
* is client-side. A `configured` host is operator-declared
* (`T3CODE_REMOTE_OPEN_HOST`) and comes first; as an ssh config alias it is
* the only way to carry a non-default port or user through a `vscode-remote`
* URI. Probed names follow: the server advertises names that resolve to
* itself, gated on a local sshd listen check and ordered most-reachable
* first (tailnet MagicDNS name, then mDNS `<hostname>.local`).
*/
export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]);
export const RemoteOpenTargetKind = Schema.Literals(["configured", "tailscale", "mdns"]);
export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type;

export const RemoteOpenTarget = Schema.Struct({
Expand Down
Loading