Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
28 changes: 28 additions & 0 deletions .docs/remote-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ When no user default is saved, endpoint selection should prefer:

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 @@ -220,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 @@ -235,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 @@ -277,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
28 changes: 28 additions & 0 deletions REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ When no user default is saved, the app chooses the best reachable endpoint for p

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 @@ -66,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
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
30 changes: 28 additions & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ 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 @@ -79,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 Down Expand Up @@ -313,18 +315,23 @@ function getDesktopServerExposureState(): DesktopServerExposureState {
};
}

function getDesktopAdvertisedEndpoints() {
async function getDesktopAdvertisedEndpoints() {
const exposure = resolveDesktopServerExposure({
mode: desktopServerExposureMode,
port: backendPort,
networkInterfaces: OS.networkInterfaces(),
...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
});
return resolveDesktopCoreAdvertisedEndpoints({
const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
port: backendPort,
exposure,
customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
});
const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
port: backendPort,
networkInterfaces: OS.networkInterfaces(),
Comment thread
cursor[bot] marked this conversation as resolved.
});
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
return [...coreEndpoints, ...tailscaleEndpoints];
Comment thread
cursor[bot] marked this conversation as resolved.
}

function getDesktopSecretStorage() {
Expand Down Expand Up @@ -399,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 @@ -655,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 @@ -1673,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 Down Expand Up @@ -2048,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 @@ -2139,6 +2162,7 @@ app.on("before-quit", () => {
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
restoreStdIoCapture?.();
});

Expand Down Expand Up @@ -2188,6 +2212,7 @@ if (process.platform !== "win32") {
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
restoreStdIoCapture?.();
app.quit();
});
Expand All @@ -2198,6 +2223,7 @@ if (process.platform !== "win32") {
writeDesktopLogHeader("SIGTERM received");
clearUpdatePollTimer();
stopBackend();
void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
restoreStdIoCapture?.();
app.quit();
});
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re
const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
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";
Expand Down Expand Up @@ -52,6 +60,30 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
removeSavedEnvironmentSecret: (environmentId) =>
ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL),
ensureSshEnvironment: (target, options) =>
ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options),
fetchSshEnvironmentDescriptor: (httpBaseUrl) =>
ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl),
bootstrapSshBearerSession: (httpBaseUrl, credential) =>
ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential),
fetchSshSessionState: (httpBaseUrl, bearerToken) =>
ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken),
issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>
ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken),
onSshPasswordPrompt: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
if (typeof request !== "object" || request === null) return;
listener(request as Parameters<typeof listener>[0]);
};

ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
};
},
resolveSshPasswordPrompt: (requestId, password) =>
ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password),
getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL),
Expand Down
Loading
Loading