Skip to content
Merged
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
96 changes: 50 additions & 46 deletions REMOTE.md
Original file line number Diff line number Diff line change
@@ -1,68 +1,72 @@
# Remote Access Setup
# Remote Access

Use this when you want to open T3 Code from another device (phone, tablet, another laptop).
Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.

## CLI ↔ Env option map
## Recommended Setup

The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
Use a trusted private network that meshes your devices together, such as a tailnet.

| CLI flag | Env var | Notes |
| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
That gives you:

> TIP: Use the `--help` flag to see all available options and their descriptions.
- a stable address to connect to
- transport security at the network layer
- less exposure than opening the server to the public internet

## Security First
## Headless Server Flow

- Always set `--auth-token` before exposing the server outside localhost.
- When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
- Treat the token like a password.
- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.

## 1) Build + run server for remote access

Remote access should use the built web app (not local Vite redirect mode).
Run the server with `t3 serve`.

```bash
bun run build
TOKEN="$(openssl rand -hex 24)"
bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
npx t3 serve --host "$(tailscale ip -4)"
```

Then open on your phone:
`t3 serve` starts the server without opening a browser and prints:

`http://<your-machine-ip>:3773`
- a connection string
- a pairing token
- a pairing URL
- a QR code for the pairing URL

Example:
From there, connect from another device in either of these ways:

`http://192.168.1.42:3773`
- scan the QR code on your phone
- in the desktop app, enter the full pairing URL
- in the desktop app, enter the host and token separately

Notes:
Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.

- `--host 0.0.0.0` listens on all IPv4 interfaces.
- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.
- Ensure your OS firewall allows inbound TCP on the selected port.
> Note
> The GUIs do not currently support adding projects on remote environments.
> For now, use `t3 project ...` on the server machine instead.
> Full GUI support for remote project management is coming soon.

## 2) Tailnet / Tailscale access
## How Pairing Works

If you use Tailscale, you can bind directly to your Tailnet address.
The remote device does not need a long-lived secret up front.

```bash
TAILNET_IP="$(tailscale ip -4)"
TOKEN="$(openssl rand -hex 24)"
bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
```
Instead:

1. `t3 serve` issues a one-time owner pairing token.
2. The remote device exchanges that token with the server.
3. The server creates an authenticated session for that device.

After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.

## Managing Access Later

Use `t3 auth` to manage access after the initial pairing flow.

Typical uses:

- issue additional pairing credentials
- inspect active sessions
- revoke old pairing links or sessions

Open from any device in your tailnet:
Use `t3 auth --help` and the nested subcommand help pages for the full reference.

`http://<tailnet-ip>:3773`
## Security Notes

You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.
- Treat pairing URLs and pairing tokens like passwords.
- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
- Use `t3 auth` to revoke credentials or sessions you no longer trust.
18 changes: 1 addition & 17 deletions apps/server/src/auth/Layers/ServerAuthPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,7 @@ import { Effect, Layer } from "effect";
import { ServerConfig } from "../../config.ts";
import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
import { SESSION_COOKIE_NAME } from "../utils.ts";

const isWildcardHost = (host: string | undefined): boolean =>
host === "0.0.0.0" || host === "::" || host === "[::]";

const isLoopbackHost = (host: string | undefined): boolean => {
if (!host || host.length === 0) {
return true;
}

return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "[::1]" ||
host.startsWith("127.")
);
};
import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";

export const makeServerAuthPolicy = Effect.gen(function* () {
const config = yield* ServerConfig;
Expand Down
41 changes: 21 additions & 20 deletions apps/server/src/auth/Layers/SessionCredentialService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const WebSocketClaims = Schema.Struct({
});
type WebSocketClaims = typeof WebSocketClaims.Type;

const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));

function createDefaultClientMetadata(): AuthClientMetadata {
return {
deviceType: "unknown",
Expand Down Expand Up @@ -259,15 +262,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {
});
}

const claims = yield* Effect.try({
try: () =>
Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))),
catch: (cause) =>
new SessionCredentialError({
message: "Invalid session token payload.",
cause,
}),
});
const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
Effect.mapError(
(cause) =>
new SessionCredentialError({
message: "Invalid session token payload.",
cause,
}),
),
);

const now = yield* Clock.currentTimeMillis;
if (claims.exp <= now) {
Expand Down Expand Up @@ -348,17 +351,15 @@ export const makeSessionCredentialService = Effect.gen(function* () {
});
}

const claims = yield* Effect.try({
try: () =>
Schema.decodeUnknownSync(WebSocketClaims)(
JSON.parse(base64UrlDecodeUtf8(encodedPayload)),
),
catch: (cause) =>
new SessionCredentialError({
message: "Invalid websocket token payload.",
cause,
}),
});
const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
Effect.mapError(
(cause) =>
new SessionCredentialError({
message: "Invalid websocket token payload.",
cause,
}),
),
);

const now = yield* Clock.currentTimeMillis;
if (claims.exp <= now) {
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/cli-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
staticDir: undefined,
devUrl: new URL("http://127.0.0.1:5173"),
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: true,
Expand Down Expand Up @@ -144,6 +145,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
staticDir: undefined,
devUrl: new URL("http://127.0.0.1:4173"),
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: true,
logWebSocketEvents: true,
Expand Down Expand Up @@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
staticDir: undefined,
devUrl: new URL("http://127.0.0.1:5173"),
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: true,
Expand Down Expand Up @@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
staticDir: undefined,
devUrl: new URL("http://127.0.0.1:4173"),
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: true,
logWebSocketEvents: true,
Expand Down Expand Up @@ -392,6 +396,66 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
staticDir: resolved.staticDir,
devUrl: undefined,
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
});
}),
);

it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
Effect.gen(function* () {
const { join } = yield* Path.Path;
const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
const derivedPaths = yield* deriveServerPaths(baseDir, undefined);

const resolved = yield* resolveServerConfig(
{
mode: Option.some("web"),
port: Option.some(3773),
host: Option.none(),
baseDir: Option.some(baseDir),
cwd: Option.none(),
devUrl: Option.none(),
noBrowser: Option.none(),
bootstrapFd: Option.none(),
autoBootstrapProjectFromCwd: Option.none(),
logWebSocketEvents: Option.none(),
},
Option.none(),
{
startupPresentation: "headless",
},
).pipe(
Effect.provide(
Layer.mergeAll(
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
T3CODE_NO_BROWSER: "false",
T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
},
}),
),
NetService.layer,
),
),
);

expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
mode: "web",
port: 3773,
cwd: process.cwd(),
baseDir,
...derivedPaths,
host: undefined,
staticDir: resolved.staticDir,
devUrl: undefined,
noBrowser: true,
startupPresentation: "headless",
desktopBootstrapToken: undefined,
autoBootstrapProjectFromCwd: false,
logWebSocketEvents: false,
Expand Down
Loading
Loading