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
2 changes: 1 addition & 1 deletion CLAUDE.md
21 changes: 12 additions & 9 deletions REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth

The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:

| 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. |
| 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. |

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

## Security First

- 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.

Expand Down
59 changes: 42 additions & 17 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const LOG_DIR_CHANNEL = "desktop:log-dir";
const LOG_LIST_CHANNEL = "desktop:log-list";
const LOG_READ_CHANNEL = "desktop:log-read";
const LOG_OPEN_DIR_CHANNEL = "desktop:log-open-dir";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
const STATE_DIR = Path.join(BASE_DIR, "userdata");
const DESKTOP_SCHEME = "t3";
Expand Down Expand Up @@ -118,6 +119,17 @@ function sanitizeLogValue(value: string): string {
return value.replace(/\s+/g, " ").trim();
}

function backendChildEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.T3CODE_PORT;
delete env.T3CODE_AUTH_TOKEN;
delete env.T3CODE_MODE;
delete env.T3CODE_NO_BROWSER;
delete env.T3CODE_HOST;
delete env.T3CODE_DESKTOP_WS_URL;
return env;
}

function writeDesktopLogHeader(message: string): void {
if (!desktopLogSink) return;
desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
Expand Down Expand Up @@ -923,17 +935,6 @@ function configureAutoUpdater(): void {
}, AUTO_UPDATE_POLL_INTERVAL_MS);
updatePollTimer.unref();
}
function backendEnv(): NodeJS.ProcessEnv {
return {
...process.env,
T3CODE_MODE: "desktop",
T3CODE_NO_BROWSER: "1",
T3CODE_PORT: String(backendPort),
T3CODE_HOME: BASE_DIR,
T3CODE_AUTH_TOKEN: backendAuthToken,
};
}

function scheduleBackendRestart(reason: string): void {
if (isQuitting || restartTimer) return;

Expand All @@ -957,16 +958,35 @@ function startBackend(): void {
}

const captureBackendLogs = app.isPackaged && backendLogSink !== null;
const child = ChildProcess.spawn(process.execPath, [backendEntry], {
const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
cwd: resolveBackendCwd(),
// In Electron main, process.execPath points to the Electron binary.
// Run the child in Node mode so this backend process does not become a GUI app instance.
env: {
...backendEnv(),
...backendChildEnv(),
ELECTRON_RUN_AS_NODE: "1",
},
stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",
stdio: captureBackendLogs
? ["ignore", "pipe", "pipe", "pipe"]
: ["ignore", "inherit", "inherit", "pipe"],
});
const bootstrapStream = child.stdio[3];
if (bootstrapStream && "write" in bootstrapStream) {
bootstrapStream.write(
`${JSON.stringify({
mode: "desktop",
noBrowser: true,
port: backendPort,
t3Home: BASE_DIR,
authToken: backendAuthToken,
})}\n`,
);
bootstrapStream.end();
} else {
child.kill("SIGTERM");
scheduleBackendRestart("missing desktop bootstrap pipe");
return;
}
backendProcess = child;
let backendSessionClosed = false;
const closeBackendSession = (details: string) => {
Expand Down Expand Up @@ -1077,6 +1097,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
}

function registerIpcHandlers(): void {
ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
event.returnValue = backendWsUrl;
});

ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
Expand Down Expand Up @@ -1368,9 +1393,9 @@ async function bootstrap(): Promise<void> {
);
writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
backendAuthToken = Crypto.randomBytes(24).toString("hex");
backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
const baseUrl = `ws://127.0.0.1:${backendPort}`;
backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);

registerIpcHandlers();
writeDesktopLogHeader("bootstrap ipc handlers registered");
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ const LOG_DIR_CHANNEL = "desktop:log-dir";
const LOG_LIST_CHANNEL = "desktop:log-list";
const LOG_READ_CHANNEL = "desktop:log-read";
const LOG_OPEN_DIR_CHANNEL = "desktop:log-open-dir";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => wsUrl,
getWsUrl: () => {
const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
return typeof result === "string" && result !== "" ? result : null;
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
Expand Down
96 changes: 96 additions & 0 deletions apps/server/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as NFS from "node:fs";
import * as path from "node:path";
import { execFileSync, spawn } from "node:child_process";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { FileSystem, Schema } from "effect";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import { TestClock } from "effect/testing";

import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap";
import { assertNone, assertSome } from "@effect/vitest/utils";

const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });

it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
it.effect("uses platform-specific fd paths", () =>
Effect.sync(() => {
assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3");
assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3");
assert.equal(resolveFdPath(3, "win32"), undefined);
}),
);

it.effect("reads a bootstrap envelope from a provided fd", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });

yield* fs.writeFileString(
filePath,
`${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({
mode: "desktop",
})}\n`,
);

const fd = yield* Effect.acquireRelease(
Effect.sync(() => NFS.openSync(filePath, "r")),
(fd) => Effect.sync(() => NFS.closeSync(fd)),
);

const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
assertSome(payload, {
mode: "desktop",
});
}),
);

it.effect("returns none when the fd is unavailable", () =>
Effect.gen(function* () {
const fd = NFS.openSync("/dev/null", "r");
NFS.closeSync(fd);

const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
assertNone(payload);
}),
);

it.effect("returns none when the bootstrap read times out before any value arrives", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" });
const fifoPath = path.join(tempDir, "bootstrap.pipe");

yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath]));

const _writer = yield* Effect.acquireRelease(
Effect.sync(() =>
spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], {
stdio: ["ignore", "ignore", "ignore"],
}),
),
(writer) =>
Effect.sync(() => {
writer.kill("SIGKILL");
}),
);

const fd = yield* Effect.acquireRelease(
Effect.sync(() => NFS.openSync(fifoPath, "r")),
(fd) => Effect.sync(() => NFS.closeSync(fd)),
);

const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
timeoutMs: 100,
}).pipe(Effect.forkScoped);

yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.millis(100));

const payload = yield* Fiber.join(fiber);
assertNone(payload);
}).pipe(Effect.provide(TestClock.layer())),
);
Comment on lines +50 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard these fd tests on Windows.

These cases use /dev/null, mkfifo, and sh, so they will fail on win32 even though Lines 20-22 already acknowledge that fd-path handling is platform-specific there. Please skip or branch these tests for non-POSIX platforms.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/bootstrap.test.ts` around lines 50 - 95, The two fd-related
tests in bootstrap.test.ts that call NFS.openSync("/dev/null"),
execFileSync("mkfifo"), spawn("sh", ...) and use
readBootstrapEnvelope/TestEnvelopeSchema should be skipped on Windows; add a
platform guard at the start of each test body (check process.platform ===
"win32") and return an immediate Effect (e.g. Effect.unit) when on Windows so
the test is effectively no-op there, leaving the rest of the POSIX logic
(mkfifo, spawn, TestClock.adjust, Fiber.join) unchanged.

});
Loading
Loading