Skip to content
Open
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
16 changes: 10 additions & 6 deletions REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The T3 Code CLI accepts the following configuration options, available either as
| `--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. |
| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | Web-mode HTTP/WebSocket auth token. |

> TIP: Use the `--help` flag to see all available options and their descriptions.

Expand All @@ -23,6 +23,7 @@ The T3 Code CLI accepts the following configuration options, available either as
- Always set `--auth-token` before exposing the server outside localhost.
- Treat the token like a password.
- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
- In built web mode, the token protects both the web UI HTTP routes and WebSocket connections.

## 1) Build + run server for remote access

Expand All @@ -34,19 +35,22 @@ TOKEN="$(openssl rand -hex 24)"
bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
```

Then open on your phone:
Then open the tokenized link from your startup log, or construct it like this:

`http://<your-machine-ip>:3773`
`http://<your-machine-ip>:3773/?token=<your-token>`

Example:

`http://192.168.1.42:3773`
`http://192.168.1.42:3773/?token=0123456789abcdef`

Notes:

- `--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.
- On first successful visit, T3 Code stores an auth cookie in the browser and redirects to a clean URL without `token=...`.
- If the server later starts with a different auth token, the old cookie is invalidated and the browser will ask for the new token again.
- If someone opens the app without a valid tokenized link or cookie, they will see a token entry page.

## 2) Tailnet / Tailscale access

Expand All @@ -58,8 +62,8 @@ TOKEN="$(openssl rand -hex 24)"
bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
```

Open from any device in your tailnet:
Open the tokenized link from any device in your tailnet:

`http://<tailnet-ip>:3773`
`http://<tailnet-ip>:3773/?token=<your-token>`

You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.
56 changes: 55 additions & 1 deletion apps/server/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Server, type ServerShape } from "./wsServer";

const start = vi.fn(() => undefined);
const stop = vi.fn(() => undefined);
const openBrowser = vi.fn((_target: string) => undefined);
let resolvedConfig: ServerConfigShape | null = null;
const serverStart = Effect.acquireRelease(
Effect.gen(function* () {
Expand Down Expand Up @@ -48,7 +49,10 @@ const testLayer = Layer.mergeAll(
stopSignal: Effect.void,
} satisfies ServerShape),
Layer.succeed(Open, {
openBrowser: (_target: string) => Effect.void,
openBrowser: (target: string) =>
Effect.sync(() => {
openBrowser(target);
}),
openInEditor: () => Effect.void,
} satisfies OpenShape),
AnalyticsService.layerTest,
Expand Down Expand Up @@ -78,6 +82,7 @@ beforeEach(() => {
resolvedConfig = null;
start.mockImplementation(() => undefined);
stop.mockImplementation(() => undefined);
openBrowser.mockImplementation(() => undefined);
findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
});

Expand Down Expand Up @@ -178,6 +183,55 @@ it.layer(testLayer)("server CLI command", (it) => {
}),
);

it.effect("opens a tokenized share link in protected built web mode", () =>
Effect.gen(function* () {
yield* runCli(["--mode", "web", "--port", "4010", "--auth-token", "auth-secret"], {
T3CODE_NO_BROWSER: "false",
});

assert.deepStrictEqual(openBrowser.mock.calls, [
["http://localhost:4010/?token=auth-secret"],
]);
}),
);

it.effect("does not tokenize browser auto-open when a dev server url is configured", () =>
Effect.gen(function* () {
yield* runCli(
[
"--mode",
"web",
"--port",
"4010",
"--dev-url",
"http://127.0.0.1:5173",
"--auth-token",
"auth-secret",
],
{
T3CODE_NO_BROWSER: "false",
},
);

assert.deepStrictEqual(openBrowser.mock.calls, [["http://127.0.0.1:5173/"]]);
}),
);

it.effect("keeps browser auto-open on localhost for wildcard protected web hosts", () =>
Effect.gen(function* () {
yield* runCli(
["--mode", "web", "--port", "4010", "--host", "0.0.0.0", "--auth-token", "auth-secret"],
{
T3CODE_NO_BROWSER: "false",
},
);

assert.deepStrictEqual(openBrowser.mock.calls, [
["http://localhost:4010/?token=auth-secret"],
]);
}),
);

it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
Effect.gen(function* () {
findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
Expand Down
31 changes: 25 additions & 6 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Server } from "./wsServer";
import { ServerLoggerLive } from "./serverLogger";
import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
import { isProtectedWebAuthEnabled } from "./webAuth";

export class StartupError extends Data.TaggedError("StartupError")<{
readonly message: string;
Expand Down Expand Up @@ -206,6 +207,13 @@ const isWildcardHost = (host: string | undefined): boolean =>
const formatHostForUrl = (host: string): string =>
host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;

const tokenizedUrl = (baseUrl: string, authToken: string | undefined): string => {
if (!authToken) return baseUrl;
const url = new URL(baseUrl);
url.searchParams.set("token", authToken);
return url.toString();
};

export const recordStartupHeartbeat = Effect.gen(function* () {
const analytics = yield* AnalyticsService;
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
Expand Down Expand Up @@ -253,23 +261,34 @@ const makeServerProgram = (input: CliInput) =>
yield* Effect.forkChild(recordStartupHeartbeat);

const localUrl = `http://localhost:${config.port}`;
const bindUrl =
config.host && !isWildcardHost(config.host)
const hostUrl =
config.host && config.host.length > 0
? `http://${formatHostForUrl(config.host)}:${config.port}`
: localUrl;
: undefined;
const protectedWebAuthEnabled = isProtectedWebAuthEnabled(config);
const openBaseUrl = config.host && !isWildcardHost(config.host) ? hostUrl! : localUrl;
const shareBaseUrl = hostUrl ?? localUrl;
const openUrl =
protectedWebAuthEnabled && config.authToken
? tokenizedUrl(openBaseUrl, config.authToken)
: (config.devUrl?.toString() ?? openBaseUrl);
const shareableUrl =
protectedWebAuthEnabled && config.authToken
? tokenizedUrl(shareBaseUrl, config.authToken)
: undefined;
const { authToken, devUrl, ...safeConfig } = config;
yield* Effect.logInfo("T3 Code running", {
...safeConfig,
devUrl: devUrl?.toString(),
authEnabled: Boolean(authToken),
...(shareableUrl ? { shareableUrl } : {}),
});

if (!config.noBrowser) {
const target = config.devUrl?.toString() ?? bindUrl;
yield* openDeps.openBrowser(target).pipe(
yield* openDeps.openBrowser(openUrl).pipe(
Effect.catch(() =>
Effect.logInfo("browser auto-open unavailable", {
hint: `Open ${target} in your browser.`,
hint: `Open ${openUrl} in your browser.`,
}),
),
);
Expand Down
66 changes: 66 additions & 0 deletions apps/server/src/webAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";

import type { ServerConfigShape } from "./config";
import { isProtectedWebAuthEnabled } from "./webAuth";

const makeConfig = (overrides: Partial<ServerConfigShape> = {}): ServerConfigShape => ({
mode: "web",
port: 3773,
cwd: "/tmp/t3-test-workspace",
host: undefined,
baseDir: "/tmp/t3-test-home",
stateDir: "/tmp/t3-test-home/userdata",
dbPath: "/tmp/t3-test-home/userdata/state.sqlite",
logsDir: "/tmp/t3-test-home/logs",
serverLogPath: "/tmp/t3-test-home/logs/server.log",
providerLogsDir: "/tmp/t3-test-home/logs/provider",
providerEventLogPath: "/tmp/t3-test-home/logs/provider/events.log",
terminalLogsDir: "/tmp/t3-test-home/logs/terminals",
attachmentsDir: "/tmp/t3-test-home/attachments",
keybindingsConfigPath: "/tmp/t3-test-home/keybindings.json",
worktreesDir: "/tmp/t3-test-home/worktrees",
anonymousIdPath: "/tmp/t3-test-home/userdata/anonymous-id",
staticDir: undefined,
devUrl: undefined,
noBrowser: true,
authToken: "auth-secret",
autoBootstrapProjectFromCwd: true,
logWebSocketEvents: false,
...overrides,
});

describe("isProtectedWebAuthEnabled", () => {
it("returns true for built web mode with an auth token", () => {
expect(isProtectedWebAuthEnabled(makeConfig())).toBe(true);
});

it("returns false when a dev server url is present", () => {
expect(
isProtectedWebAuthEnabled(
makeConfig({
devUrl: new URL("http://localhost:5173"),
}),
),
).toBe(false);
});

it("returns false without an auth token", () => {
expect(
isProtectedWebAuthEnabled(
makeConfig({
authToken: undefined,
}),
),
).toBe(false);
});

it("returns false outside web mode", () => {
expect(
isProtectedWebAuthEnabled(
makeConfig({
mode: "desktop",
}),
),
).toBe(false);
});
});
Loading