Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
92fcad4
feat(remote): add advertised endpoint registry
juliusmarminge Apr 27, 2026
2d4d014
docs(remote): describe advertised endpoint selection
juliusmarminge Apr 27, 2026
973e9e7
fix(web): hide endpoint rows when network access is disabled
juliusmarminge Apr 27, 2026
3009dd0
feat(web): make advertised endpoint defaults explicit
juliusmarminge Apr 27, 2026
cf580ba
feat(remote): add advertised endpoint registry
juliusmarminge Apr 27, 2026
03b370a
feat(desktop): add tailscale endpoint addon
juliusmarminge Apr 27, 2026
c707921
fix(web): dedupe advertised pairing helpers
juliusmarminge Apr 27, 2026
3d78ae9
docs(remote): document tailscale endpoint add-on
juliusmarminge Apr 27, 2026
2d6e8cd
fix(desktop): include port in tailscale https endpoint
juliusmarminge Apr 27, 2026
3a0f6f4
Add remote SSH environment launch support
juliusmarminge Apr 14, 2026
c327220
Harden remote SSH environment recovery
juliusmarminge Apr 14, 2026
0827323
Respect desktop release channel for SSH bootstrap
juliusmarminge Apr 15, 2026
0771856
Pin nightly SSH bootstrap to desktop version
juliusmarminge Apr 15, 2026
9c1667d
Address SSH launch review feedback
juliusmarminge Apr 15, 2026
ac4ea4e
Use nightly remote T3 CLI in development
juliusmarminge Apr 15, 2026
9dff45a
Improve SSH launch diagnostics
juliusmarminge Apr 15, 2026
4f83ade
Tighten SSH setup dialog scrolling
juliusmarminge Apr 16, 2026
af84f7f
Format settings and IPC imports
juliusmarminge Apr 17, 2026
8aaec88
Extract desktop SSH bridge into dedicated class
juliusmarminge Apr 17, 2026
9c92d96
Extract SSH launch scripts
juliusmarminge Apr 17, 2026
5f8a717
Simplify ssh askpass to require cached secret
juliusmarminge Apr 17, 2026
45545d9
Fix PR CI failures
juliusmarminge Apr 17, 2026
e46a25f
docs(remote): document desktop ssh launch
juliusmarminge Apr 27, 2026
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
54 changes: 54 additions & 0 deletions .docs/remote-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,47 @@ A single environment may have many endpoints:

The environment stays the same. Only the access path changes.

### AdvertisedEndpoint

An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting.

`AdvertisedEndpoint` is deliberately narrower than the full access model:

- it describes a concrete HTTP and WebSocket base URL pair
- it can mark the endpoint as default, available, or unavailable
- it includes reachability hints such as loopback, LAN, private, public, or tunnel
- it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app

Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable.

The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration.

Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity.

When no user default is saved, endpoint selection should prefer:

1. endpoints compatible with the hosted HTTPS app
2. explicitly default endpoints
3. non-loopback endpoints
4. loopback endpoints only for same-machine clients

This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model.

### Endpoint providers

Endpoint providers are add-ons that contribute advertised endpoints for the current environment.

The provider boundary is intentionally outside the core environment model:

- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle
- providers discover or synthesize endpoints
- providers return normalized `AdvertisedEndpoint` records
- the UI and pairing logic select from those records without knowing provider-specific commands

The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path.

Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected.

### Hosted pairing request

A hosted pairing request is a bootstrap URL for the static web app, not a transport.
Expand Down Expand Up @@ -194,6 +235,8 @@ This is especially useful when:
- mobile must reach a desktop-hosted environment
- a machine should be reachable without exposing raw LAN or public ports

Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a T3-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection.

### 3. Desktop-managed SSH access

SSH is an access and launch helper, not a separate environment type.
Expand All @@ -209,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag

This keeps the renderer transport model consistent with every other access method.

The desktop main process owns the SSH bridge because it can spawn local SSH processes, manage askpass prompts, write temporary launch scripts, and clean up forwards. The renderer receives a saved environment record and connects through the forwarded URL; it should not need SSH-specific RPC paths for normal environment traffic.

## Launch methods

Launch methods answer a different question:
Expand Down Expand Up @@ -251,6 +296,15 @@ The recommended T3 flow is:
4. Desktop establishes local port forwarding.
5. Renderer connects to the forwarded WebSocket endpoint as a normal environment.

The saved environment should remember that it was created by desktop SSH launch only for reconnect and lifecycle UX. That metadata should not change the server protocol or the environment identity model.

Failure handling should be explicit:

- SSH authentication failure should surface before any environment is saved
- remote launch failure should include remote logs or the launcher command output when available
- forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint
- reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client

### 3. Client-managed local publish

This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel.
Expand Down
40 changes: 39 additions & 1 deletion REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,33 @@ If you are already running the desktop app and want to make it reachable from ot

1. Open **Settings** → **Connections**.
2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces.
3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`).
3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints.
4. Use **Create Link** to generate a pairing link you can share with another device.

The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks.

When no user default is saved, the app chooses the best reachable endpoint for pairing links:

- HTTPS/WSS-compatible endpoints are preferred because they work from `https://app.t3.codes`.
- Non-loopback HTTP endpoints are used for direct LAN pairing when HTTPS is not available.
- Loopback-only endpoints are not useful for another device unless that device is the same machine.

If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link.

### Tailscale Endpoints

When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list.

Depending on your Tailscale setup, this may include:

- the machine's `100.x.y.z` Tailnet IP
- a MagicDNS name
- an HTTPS MagicDNS endpoint when Tailscale HTTPS is available for the machine

The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow.

For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A plain `http://100.x.y.z:3773` endpoint can still work from a desktop client or another browser page served over HTTP, but it will not work from the hosted HTTPS app because of browser mixed-content rules.

### Option 2: Headless Server (CLI)

Use this when you want to run the server without a GUI, for example on a remote machine over SSH.
Expand Down Expand Up @@ -56,6 +80,20 @@ Use `t3 serve --help` for the full flag reference. It supports the same general
> For now, use `t3 project ...` on the server machine instead.
> Full GUI support for remote project management is coming soon.

### Option 3: Desktop-Managed SSH Launch

Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH.

1. Open **Settings** → **Connections**.
2. Under **Remote Environments**, choose **Add environment**.
3. Select the SSH launch flow.
4. Enter the SSH target, such as `user@example.com`.
5. Confirm the launch. The desktop app probes the host, starts or reuses a remote T3 server, opens a local port forward, and saves the environment.

After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. The remote host still owns the actual T3 server, projects, files, git state, terminals, and provider sessions.

SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments.

## How Pairing Works

The remote device does not need a long-lived secret up front.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"electron-updater": "^6.6.2"
},
"devDependencies": {
"@t3tools/client-runtime": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@types/node": "catalog:",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
wsBaseUrl: "wss://remote.example.com/",
createdAt: "2026-04-09T00:00:00.000Z",
lastConnectedAt: "2026-04-09T01:00:00.000Z",
desktopSsh: {
alias: "devbox",
hostname: "devbox.example.com",
username: "julius",
port: 22,
},
};

describe("clientPersistence", () => {
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/clientPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord(
typeof value.wsBaseUrl === "string" &&
typeof value.createdAt === "string" &&
(value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
(value.desktopSsh === undefined ||
(Predicate.isObject(value.desktopSsh) &&
typeof value.desktopSsh.alias === "string" &&
typeof value.desktopSsh.hostname === "string" &&
(value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") &&
(value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) &&
(value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
);
}
Expand All @@ -77,14 +83,15 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen
function toPersistedSavedEnvironmentRecord(
record: PersistedSavedEnvironmentStorageRecord,
): PersistedSavedEnvironmentRecord {
return {
const nextRecord = {
environmentId: record.environmentId,
label: record.label,
httpBaseUrl: record.httpBaseUrl,
wsBaseUrl: record.wsBaseUrl,
createdAt: record.createdAt,
lastConnectedAt: record.lastConnectedAt,
};
return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
}

export function readClientSettings(settingsPath: string): ClientSettings | null {
Expand Down Expand Up @@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry(
wsBaseUrl: record.wsBaseUrl,
createdAt: record.createdAt,
lastConnectedAt: record.lastConnectedAt,
...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
encryptedBearerToken,
}
: record;
Expand Down Expand Up @@ -189,15 +197,16 @@ export function writeSavedEnvironmentSecret(input: {
const encryptedBearerToken = input.secretStorage
.encryptString(input.secret)
.toString("base64");
return {
const nextRecord = {
environmentId: record.environmentId,
label: record.label,
httpBaseUrl: record.httpBaseUrl,
wsBaseUrl: record.wsBaseUrl,
createdAt: record.createdAt,
lastConnectedAt: record.lastConnectedAt,
encryptedBearerToken,
} satisfies PersistedSavedEnvironmentStorageRecord;
};
return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
}),
} satisfies SavedEnvironmentRegistryDocument);
return found;
Expand Down
57 changes: 56 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ import {
} from "./clientPersistence.ts";
import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
import { showDesktopConfirmDialog } from "./confirmDialog.ts";
import { resolveDesktopServerExposure } from "./serverExposure.ts";
import {
resolveDesktopCoreAdvertisedEndpoints,
resolveDesktopServerExposure,
} from "./serverExposure.ts";
import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts";
import { syncShellEnvironment } from "./syncShellEnvironment.ts";
import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
Expand All @@ -76,6 +80,7 @@ import {
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
import { resolveDesktopAppBranding } from "./appBranding.ts";
import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";

syncShellEnvironment();

Expand All @@ -102,6 +107,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr
const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
const STATE_DIR = Path.join(BASE_DIR, "userdata");
const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
Expand Down Expand Up @@ -297,6 +303,7 @@ function backendChildEnv(): NodeJS.ProcessEnv {
delete env.T3CODE_DESKTOP_WS_URL;
delete env.T3CODE_DESKTOP_LAN_ACCESS;
delete env.T3CODE_DESKTOP_LAN_HOST;
delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS;
return env;
}

Expand All @@ -308,6 +315,25 @@ function getDesktopServerExposureState(): DesktopServerExposureState {
};
}

async function getDesktopAdvertisedEndpoints() {
const exposure = resolveDesktopServerExposure({
mode: desktopServerExposureMode,
port: backendPort,
networkInterfaces: OS.networkInterfaces(),
...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
});
const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
port: backendPort,
exposure,
customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
});
const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
port: backendPort,
networkInterfaces: OS.networkInterfaces(),
});
return [...coreEndpoints, ...tailscaleEndpoints];
}

function getDesktopSecretStorage() {
return {
isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
Expand All @@ -321,6 +347,13 @@ function resolveAdvertisedHostOverride(): string | undefined {
return override && override.length > 0 ? override : undefined;
}

function resolveCustomHttpsEndpointUrls(): readonly string[] {
return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}

async function applyDesktopServerExposureMode(
mode: DesktopServerExposureMode,
options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },
Expand Down Expand Up @@ -373,6 +406,7 @@ function relaunchDesktopApp(reason: string): void {
`desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
);
})
.then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined))
.finally(() => {
restoreStdIoCapture?.();
if (isDevelopment) {
Expand Down Expand Up @@ -629,6 +663,16 @@ let updateInstallInFlight = false;
let updaterConfigured = false;
let updateState: DesktopUpdateState = initialUpdateState();

const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({
getMainWindow: () => mainWindow,
resolveCliPackageSpec: () =>
resolveRemoteT3CliPackageSpec({
appVersion: app.getVersion(),
updateChannel: desktopSettings.updateChannel,
isDevelopment,
}),
});

function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
if (updateInstallInFlight) return "install";
if (updateDownloadInFlight) return "download";
Expand Down Expand Up @@ -1647,6 +1691,8 @@ function registerIpcHandlers(): void {
},
);

desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain);

ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());

Expand All @@ -1669,6 +1715,9 @@ function registerIpcHandlers(): void {
return nextState;
});

ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL);
ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints());

ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
Expand Down Expand Up @@ -2019,6 +2068,9 @@ function createWindow(): BrowserWindow {
}

window.on("closed", () => {
desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
"SSH authentication was cancelled because the app window closed.",
);
if (mainWindow === window) {
mainWindow = null;
}
Expand Down Expand Up @@ -2110,6 +2162,7 @@ app.on("before-quit", () => {
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
restoreStdIoCapture?.();
});

Expand Down Expand Up @@ -2159,6 +2212,7 @@ if (process.platform !== "win32") {
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
restoreStdIoCapture?.();
app.quit();
});
Expand All @@ -2169,6 +2223,7 @@ if (process.platform !== "win32") {
writeDesktopLogHeader("SIGTERM received");
clearUpdatePollTimer();
stopBackend();
void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
restoreStdIoCapture?.();
app.quit();
});
Expand Down
Loading
Loading