Skip to content

Commit 24c5c2f

Browse files
Add headless serve pairing output
- add `t3 serve` headless startup flow with pairing URL, token, and QR output - share QR code generation between web and server - update docs and config tests for the new startup presentation
1 parent e32077c commit 24c5c2f

File tree

13 files changed

+1362
-1082
lines changed

13 files changed

+1362
-1082
lines changed

REMOTE.md

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,67 @@
1-
# Remote Access Setup
1+
# Remote Access
22

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

5-
## CLI ↔ Env option map
5+
## Recommended Setup
66

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

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

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

22-
## Security First
15+
## Headless Server Flow
2316

24-
- Always set `--auth-token` before exposing the server outside localhost.
25-
- When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26-
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.
27-
- Treat the token like a password.
28-
- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
29-
30-
## 1) Build + run server for remote access
31-
32-
Remote access should use the built web app (not local Vite redirect mode).
17+
Run the server with `t3 serve`.
3318

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

40-
Then open on your phone:
23+
`t3 serve` starts the server without opening a browser and prints:
4124

42-
`http://<your-machine-ip>:3773`
25+
- a connection string
26+
- a pairing token
27+
- a pairing URL
28+
- a QR code for the pairing URL
4329

44-
Example:
30+
From there, connect from another device in either of these ways:
4531

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

48-
Notes:
36+
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.
4937

50-
- `--host 0.0.0.0` listens on all IPv4 interfaces.
51-
- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.
52-
- Ensure your OS firewall allows inbound TCP on the selected port.
38+
## How Pairing Works
5339

54-
## 2) Tailnet / Tailscale access
40+
The remote device does not need a long-lived secret up front.
5541

56-
If you use Tailscale, you can bind directly to your Tailnet address.
42+
Instead:
5743

58-
```bash
59-
TAILNET_IP="$(tailscale ip -4)"
60-
TOKEN="$(openssl rand -hex 24)"
61-
bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
62-
```
44+
1. `t3 serve` issues a one-time owner pairing token.
45+
2. The remote device exchanges that token with the server.
46+
3. The server creates an authenticated session for that device.
47+
48+
After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.
49+
50+
## Managing Access Later
51+
52+
Use `t3 auth` to manage access after the initial pairing flow.
53+
54+
Typical uses:
55+
56+
- issue additional pairing credentials
57+
- inspect active sessions
58+
- revoke old pairing links or sessions
6359

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

66-
`http://<tailnet-ip>:3773`
62+
## Security Notes
6763

68-
You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.
64+
- Treat pairing URLs and pairing tokens like passwords.
65+
- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
66+
- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
67+
- Use `t3 auth` to revoke credentials or sessions you no longer trust.

apps/server/src/cli-config.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
8383
staticDir: undefined,
8484
devUrl: new URL("http://127.0.0.1:5173"),
8585
noBrowser: true,
86+
startupPresentation: "browser",
8687
desktopBootstrapToken: undefined,
8788
autoBootstrapProjectFromCwd: false,
8889
logWebSocketEvents: true,
@@ -144,6 +145,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
144145
staticDir: undefined,
145146
devUrl: new URL("http://127.0.0.1:4173"),
146147
noBrowser: true,
148+
startupPresentation: "browser",
147149
desktopBootstrapToken: undefined,
148150
autoBootstrapProjectFromCwd: true,
149151
logWebSocketEvents: true,
@@ -212,6 +214,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
212214
staticDir: undefined,
213215
devUrl: new URL("http://127.0.0.1:5173"),
214216
noBrowser: true,
217+
startupPresentation: "browser",
215218
desktopBootstrapToken: undefined,
216219
autoBootstrapProjectFromCwd: false,
217220
logWebSocketEvents: true,
@@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
329332
staticDir: undefined,
330333
devUrl: new URL("http://127.0.0.1:4173"),
331334
noBrowser: true,
335+
startupPresentation: "browser",
332336
desktopBootstrapToken: undefined,
333337
autoBootstrapProjectFromCwd: true,
334338
logWebSocketEvents: true,
@@ -392,10 +396,69 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
392396
staticDir: resolved.staticDir,
393397
devUrl: undefined,
394398
noBrowser: true,
399+
startupPresentation: "browser",
395400
desktopBootstrapToken: undefined,
396401
autoBootstrapProjectFromCwd: false,
397402
logWebSocketEvents: false,
398403
});
399404
}),
400405
);
406+
407+
it.effect("forces noBrowser when resolving headless startup presentation", () =>
408+
Effect.gen(function* () {
409+
const { join } = yield* Path.Path;
410+
const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
411+
const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
412+
413+
const resolved = yield* resolveServerConfig(
414+
{
415+
mode: Option.some("web"),
416+
port: Option.some(3773),
417+
host: Option.none(),
418+
baseDir: Option.some(baseDir),
419+
cwd: Option.none(),
420+
devUrl: Option.none(),
421+
noBrowser: Option.none(),
422+
bootstrapFd: Option.none(),
423+
autoBootstrapProjectFromCwd: Option.none(),
424+
logWebSocketEvents: Option.none(),
425+
},
426+
Option.none(),
427+
{
428+
startupPresentation: "headless",
429+
},
430+
).pipe(
431+
Effect.provide(
432+
Layer.mergeAll(
433+
ConfigProvider.layer(
434+
ConfigProvider.fromEnv({
435+
env: {
436+
T3CODE_NO_BROWSER: "false",
437+
},
438+
}),
439+
),
440+
NetService.layer,
441+
),
442+
),
443+
);
444+
445+
expect(resolved).toEqual({
446+
logLevel: "Info",
447+
...defaultObservabilityConfig,
448+
mode: "web",
449+
port: 3773,
450+
cwd: process.cwd(),
451+
baseDir,
452+
...derivedPaths,
453+
host: undefined,
454+
staticDir: resolved.staticDir,
455+
devUrl: undefined,
456+
noBrowser: true,
457+
startupPresentation: "headless",
458+
desktopBootstrapToken: undefined,
459+
autoBootstrapProjectFromCwd: true,
460+
logWebSocketEvents: false,
461+
});
462+
}),
463+
);
401464
});

apps/server/src/cli.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
ServerConfig,
2626
RuntimeMode,
2727
type ServerConfigShape,
28+
type StartupPresentation,
2829
} from "./config";
2930
import { readBootstrapEnvelope } from "./bootstrap";
3031
import { expandHomePath, resolveBaseDir } from "./os-jank";
@@ -187,6 +188,9 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st
187188
export const resolveServerConfig = (
188189
flags: CliServerFlags,
189190
cliLogLevel: Option.Option<LogLevel.LogLevel>,
191+
options?: {
192+
readonly startupPresentation?: StartupPresentation;
193+
},
190194
) =>
191195
Effect.gen(function* () {
192196
const { findAvailablePort } = yield* NetService;
@@ -253,18 +257,22 @@ export const resolveServerConfig = (
253257
);
254258
const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
255259
yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
256-
const noBrowser = resolveBooleanFlag(
257-
flags.noBrowser,
258-
Option.getOrElse(
259-
resolveOptionPrecedence(
260-
Option.fromUndefinedOr(env.noBrowser),
261-
Option.flatMap(bootstrapEnvelope, (bootstrap) =>
262-
Option.fromUndefinedOr(bootstrap.noBrowser),
263-
),
264-
),
265-
() => mode === "desktop",
266-
),
267-
);
260+
const startupPresentation = options?.startupPresentation ?? "browser";
261+
const noBrowser =
262+
startupPresentation === "headless"
263+
? true
264+
: resolveBooleanFlag(
265+
flags.noBrowser,
266+
Option.getOrElse(
267+
resolveOptionPrecedence(
268+
Option.fromUndefinedOr(env.noBrowser),
269+
Option.flatMap(bootstrapEnvelope, (bootstrap) =>
270+
Option.fromUndefinedOr(bootstrap.noBrowser),
271+
),
272+
),
273+
() => mode === "desktop",
274+
),
275+
);
268276
const desktopBootstrapToken = Option.getOrUndefined(
269277
Option.flatMap(bootstrapEnvelope, (bootstrap) =>
270278
Option.fromUndefinedOr(bootstrap.desktopBootstrapToken),
@@ -340,6 +348,7 @@ export const resolveServerConfig = (
340348
staticDir,
341349
devUrl,
342350
noBrowser,
351+
startupPresentation,
343352
desktopBootstrapToken,
344353
autoBootstrapProjectFromCwd,
345354
logWebSocketEvents,
@@ -452,7 +461,7 @@ const runWithAuthControlPlane = <A, E>(
452461
);
453462
});
454463

455-
const commandFlags = {
464+
const sharedServerCommandFlags = {
456465
mode: modeFlag,
457466
port: portFlag,
458467
host: hostFlag,
@@ -674,25 +683,36 @@ const authCommand = Command.make("auth").pipe(
674683
Command.withSubcommands([pairingCommand, sessionCommand]),
675684
);
676685

677-
const startCommand = Command.make("start", commandFlags).pipe(
686+
const runServerCommand = (
687+
flags: CliServerFlags,
688+
options?: {
689+
readonly startupPresentation?: StartupPresentation;
690+
},
691+
) =>
692+
Effect.gen(function* () {
693+
const logLevel = yield* GlobalFlag.LogLevel;
694+
const config = yield* resolveServerConfig(flags, logLevel, options);
695+
return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
696+
});
697+
698+
const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
678699
Command.withDescription("Run the T3 Code server."),
700+
Command.withHandler((flags) => runServerCommand(flags)),
701+
);
702+
703+
const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
704+
Command.withDescription(
705+
"Run the T3 Code server without opening a browser and print headless pairing details.",
706+
),
679707
Command.withHandler((flags) =>
680-
Effect.gen(function* () {
681-
const logLevel = yield* GlobalFlag.LogLevel;
682-
const config = yield* resolveServerConfig(flags, logLevel);
683-
return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
708+
runServerCommand(flags, {
709+
startupPresentation: "headless",
684710
}),
685711
),
686712
);
687713

688-
export const cli = Command.make("t3", commandFlags).pipe(
714+
export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
689715
Command.withDescription("Run the T3 Code server."),
690-
Command.withHandler((flags) =>
691-
Effect.gen(function* () {
692-
const logLevel = yield* GlobalFlag.LogLevel;
693-
const config = yield* resolveServerConfig(flags, logLevel);
694-
return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
695-
}),
696-
),
697-
Command.withSubcommands([startCommand, authCommand]),
716+
Command.withHandler((flags) => runServerCommand(flags)),
717+
Command.withSubcommands([startCommand, serveCommand, authCommand]),
698718
);

apps/server/src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export const DEFAULT_PORT = 3773;
1313
export const RuntimeMode = Schema.Literals(["web", "desktop"]);
1414
export type RuntimeMode = typeof RuntimeMode.Type;
1515

16+
export const StartupPresentation = Schema.Literals(["browser", "headless"]);
17+
export type StartupPresentation = typeof StartupPresentation.Type;
18+
1619
/**
1720
* ServerDerivedPaths - Derived paths from the base directory.
1821
*/
@@ -56,6 +59,7 @@ export interface ServerConfigShape extends ServerDerivedPaths {
5659
readonly staticDir: string | undefined;
5760
readonly devUrl: URL | undefined;
5861
readonly noBrowser: boolean;
62+
readonly startupPresentation: StartupPresentation;
5963
readonly desktopBootstrapToken: string | undefined;
6064
readonly autoBootstrapProjectFromCwd: boolean;
6165
readonly logWebSocketEvents: boolean;
@@ -153,6 +157,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
153157
staticDir: undefined,
154158
devUrl,
155159
noBrowser: false,
160+
startupPresentation: "browser",
156161
} satisfies ServerConfigShape;
157162
}),
158163
);

apps/server/src/environment/Layers/ServerEnvironment.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
3636
staticDir: undefined,
3737
devUrl: undefined,
3838
noBrowser: false,
39+
startupPresentation: "browser",
3940
} satisfies ServerConfigShape;
4041
});
4142

apps/server/src/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ const buildAppUnderTest = (options?: {
331331
staticDir: undefined,
332332
devUrl,
333333
noBrowser: true,
334+
startupPresentation: "browser",
334335
desktopBootstrapToken: defaultDesktopBootstrapToken,
335336
autoBootstrapProjectFromCwd: false,
336337
logWebSocketEvents: false,

0 commit comments

Comments
 (0)