authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-25 09:45:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-25 09:45:13-07:00
logb3bca04caf8449c4d10da1c70cd1f3a0c9070280
tree26f2fd22c4d604c75828908111a8e2f784705249
parentbf71e0bc5eb0af9494a5969302f27f3d95b694c5
signature Signed by PGP key B5690EEEBB952194

fix(security): send sensitive config over a bootstrap fd (#1398)

Co-authored-by: codex <codex@users.noreply.github.com>

9 files changed, 590 insertions(+), 66 deletions(-)

REMOTE.md+12-9
...@@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth...@@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth
66
7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
88
9| CLI flag | Env var | Notes |9| CLI flag | Env var | Notes |
10| ----------------------- | --------------------- | ---------------------------------- |10| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. |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. |
1819
19> TIP: Use the `--help` flag to see all available options and their descriptions.20> TIP: Use the `--help` flag to see all available options and their descriptions.
2021
21## Security First22## Security First
2223
23- Always set `--auth-token` before exposing the server outside localhost.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.
24- Treat the token like a password.27- Treat the token like a password.
25- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.28- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
2629
apps/desktop/src/main.ts+42-17
...@@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";...@@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
59const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
59const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");60const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
60const STATE_DIR = Path.join(BASE_DIR, "userdata");61const STATE_DIR = Path.join(BASE_DIR, "userdata");
61const DESKTOP_SCHEME = "t3";62const DESKTOP_SCHEME = "t3";
...@@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string {...@@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string {
113 return value.replace(/\s+/g, " ").trim();114 return value.replace(/\s+/g, " ").trim();
114}115}
115116
117function backendChildEnv(): NodeJS.ProcessEnv {
118 const env = { ...process.env };
119 delete env.T3CODE_PORT;
120 delete env.T3CODE_AUTH_TOKEN;
121 delete env.T3CODE_MODE;
122 delete env.T3CODE_NO_BROWSER;
123 delete env.T3CODE_HOST;
124 delete env.T3CODE_DESKTOP_WS_URL;
125 return env;
126}
127
116function writeDesktopLogHeader(message: string): void {128function writeDesktopLogHeader(message: string): void {
117 if (!desktopLogSink) return;129 if (!desktopLogSink) return;
118 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);130 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
...@@ -918,17 +930,6 @@ function configureAutoUpdater(): void {...@@ -918,17 +930,6 @@ function configureAutoUpdater(): void {
918 }, AUTO_UPDATE_POLL_INTERVAL_MS);930 }, AUTO_UPDATE_POLL_INTERVAL_MS);
919 updatePollTimer.unref();931 updatePollTimer.unref();
920}932}
921function backendEnv(): NodeJS.ProcessEnv {
922 return {
923 ...process.env,
924 T3CODE_MODE: "desktop",
925 T3CODE_NO_BROWSER: "1",
926 T3CODE_PORT: String(backendPort),
927 T3CODE_HOME: BASE_DIR,
928 T3CODE_AUTH_TOKEN: backendAuthToken,
929 };
930}
931
932function scheduleBackendRestart(reason: string): void {933function scheduleBackendRestart(reason: string): void {
933 if (isQuitting || restartTimer) return;934 if (isQuitting || restartTimer) return;
934935
...@@ -952,16 +953,35 @@ function startBackend(): void {...@@ -952,16 +953,35 @@ function startBackend(): void {
952 }953 }
953954
954 const captureBackendLogs = app.isPackaged && backendLogSink !== null;955 const captureBackendLogs = app.isPackaged && backendLogSink !== null;
955 const child = ChildProcess.spawn(process.execPath, [backendEntry], {956 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
956 cwd: resolveBackendCwd(),957 cwd: resolveBackendCwd(),
957 // In Electron main, process.execPath points to the Electron binary.958 // In Electron main, process.execPath points to the Electron binary.
958 // Run the child in Node mode so this backend process does not become a GUI app instance.959 // Run the child in Node mode so this backend process does not become a GUI app instance.
959 env: {960 env: {
960 ...backendEnv(),961 ...backendChildEnv(),
961 ELECTRON_RUN_AS_NODE: "1",962 ELECTRON_RUN_AS_NODE: "1",
962 },963 },
963 stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",964 stdio: captureBackendLogs
965 ? ["ignore", "pipe", "pipe", "pipe"]
966 : ["ignore", "inherit", "inherit", "pipe"],
964 });967 });
968 const bootstrapStream = child.stdio[3];
969 if (bootstrapStream && "write" in bootstrapStream) {
970 bootstrapStream.write(
971 `${JSON.stringify({
972 mode: "desktop",
973 noBrowser: true,
974 port: backendPort,
975 t3Home: BASE_DIR,
976 authToken: backendAuthToken,
977 })}\n`,
978 );
979 bootstrapStream.end();
980 } else {
981 child.kill("SIGTERM");
982 scheduleBackendRestart("missing desktop bootstrap pipe");
983 return;
984 }
965 backendProcess = child;985 backendProcess = child;
966 let backendSessionClosed = false;986 let backendSessionClosed = false;
967 const closeBackendSession = (details: string) => {987 const closeBackendSession = (details: string) => {
...@@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {...@@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1072}1092}
10731093
1074function registerIpcHandlers(): void {1094function registerIpcHandlers(): void {
1095 ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
1096 ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
1097 event.returnValue = backendWsUrl;
1098 });
1099
1075 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);1100 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1076 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {1101 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
1077 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;1102 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
...@@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> {...@@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> {
1320 );1345 );
1321 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);1346 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
1322 backendAuthToken = Crypto.randomBytes(24).toString("hex");1347 backendAuthToken = Crypto.randomBytes(24).toString("hex");
1323 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;1348 const baseUrl = `ws://127.0.0.1:${backendPort}`;
1324 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;1349 backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
1325 writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);1350 writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);
13261351
1327 registerIpcHandlers();1352 registerIpcHandlers();
1328 writeDesktopLogHeader("bootstrap ipc handlers registered");1353 writeDesktopLogHeader("bootstrap ipc handlers registered");
apps/desktop/src/preload.ts+5-2
...@@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";...@@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";12const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
13const UPDATE_INSTALL_CHANNEL = "desktop:update-install";13const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
14const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;14const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
1515
16contextBridge.exposeInMainWorld("desktopBridge", {16contextBridge.exposeInMainWorld("desktopBridge", {
17 getWsUrl: () => wsUrl,17 getWsUrl: () => {
18 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
19 return typeof result === "string" ? result : null;
20 },
18 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),21 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
19 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),22 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
20 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),23 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/server/src/bootstrap.test.ts created+96
...@@ -0,0 +1,96 @@
1import * as NFS from "node:fs";
2import * as path from "node:path";
3import { execFileSync, spawn } from "node:child_process";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { assert, it } from "@effect/vitest";
6import { FileSystem, Schema } from "effect";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import { TestClock } from "effect/testing";
11
12import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap";
13import { assertNone, assertSome } from "@effect/vitest/utils";
14
15const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });
16
17it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
18 it.effect("uses platform-specific fd paths", () =>
19 Effect.sync(() => {
20 assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3");
21 assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3");
22 assert.equal(resolveFdPath(3, "win32"), undefined);
23 }),
24 );
25
26 it.effect("reads a bootstrap envelope from a provided fd", () =>
27 Effect.gen(function* () {
28 const fs = yield* FileSystem.FileSystem;
29 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
30
31 yield* fs.writeFileString(
32 filePath,
33 `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({
34 mode: "desktop",
35 })}\n`,
36 );
37
38 const fd = yield* Effect.acquireRelease(
39 Effect.sync(() => NFS.openSync(filePath, "r")),
40 (fd) => Effect.sync(() => NFS.closeSync(fd)),
41 );
42
43 const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
44 assertSome(payload, {
45 mode: "desktop",
46 });
47 }),
48 );
49
50 it.effect("returns none when the fd is unavailable", () =>
51 Effect.gen(function* () {
52 const fd = NFS.openSync("/dev/null", "r");
53 NFS.closeSync(fd);
54
55 const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
56 assertNone(payload);
57 }),
58 );
59
60 it.effect("returns none when the bootstrap read times out before any value arrives", () =>
61 Effect.gen(function* () {
62 const fs = yield* FileSystem.FileSystem;
63 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" });
64 const fifoPath = path.join(tempDir, "bootstrap.pipe");
65
66 yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath]));
67
68 const _writer = yield* Effect.acquireRelease(
69 Effect.sync(() =>
70 spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], {
71 stdio: ["ignore", "ignore", "ignore"],
72 }),
73 ),
74 (writer) =>
75 Effect.sync(() => {
76 writer.kill("SIGKILL");
77 }),
78 );
79
80 const fd = yield* Effect.acquireRelease(
81 Effect.sync(() => NFS.openSync(fifoPath, "r")),
82 (fd) => Effect.sync(() => NFS.closeSync(fd)),
83 );
84
85 const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
86 timeoutMs: 100,
87 }).pipe(Effect.forkScoped);
88
89 yield* Effect.yieldNow;
90 yield* TestClock.adjust(Duration.millis(100));
91
92 const payload = yield* Fiber.join(fiber);
93 assertNone(payload);
94 }).pipe(Effect.provide(TestClock.layer())),
95 );
96});
apps/server/src/bootstrap.ts created+145
...@@ -0,0 +1,145 @@
1import * as NFS from "node:fs";
2import * as Net from "node:net";
3import * as readline from "node:readline";
4import type { Readable } from "node:stream";
5
6import { Data, Effect, Option, Predicate, Result, Schema } from "effect";
7import { decodeJsonResult } from "@t3tools/shared/schemaJson";
8
9class BootstrapError extends Data.TaggedError("BootstrapError")<{
10 readonly message: string;
11 readonly cause?: unknown;
12}> {}
13
14export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* <A, I>(
15 schema: Schema.Codec<A, I>,
16 fd: number,
17 options?: {
18 timeoutMs?: number;
19 },
20): Effect.fn.Return<Option.Option<A>, BootstrapError> {
21 const fdReady = yield* isFdReady(fd);
22 if (!fdReady) return Option.none();
23
24 const stream = yield* makeBootstrapInputStream(fd);
25
26 const timeoutMs = options?.timeoutMs ?? 1000;
27
28 return yield* Effect.callback<Option.Option<A>, BootstrapError>((resume) => {
29 const input = readline.createInterface({
30 input: stream,
31 crlfDelay: Infinity,
32 });
33
34 const cleanup = () => {
35 stream.removeListener("error", handleError);
36 input.removeListener("line", handleLine);
37 input.removeListener("close", handleClose);
38 input.close();
39 stream.destroy();
40 };
41
42 const handleError = (error: Error) => {
43 if (isUnavailableBootstrapFdError(error)) {
44 resume(Effect.succeedNone);
45 return;
46 }
47 resume(
48 Effect.fail(
49 new BootstrapError({
50 message: "Failed to read bootstrap envelope.",
51 cause: error,
52 }),
53 ),
54 );
55 };
56
57 const handleLine = (line: string) => {
58 const parsed = decodeJsonResult(schema)(line);
59 if (Result.isSuccess(parsed)) {
60 resume(Effect.succeedSome(parsed.success));
61 } else {
62 resume(
63 Effect.fail(
64 new BootstrapError({
65 message: "Failed to decode bootstrap envelope.",
66 cause: parsed.failure,
67 }),
68 ),
69 );
70 }
71 };
72
73 const handleClose = () => {
74 resume(Effect.succeedNone);
75 };
76
77 stream.once("error", handleError);
78 input.once("line", handleLine);
79 input.once("close", handleClose);
80
81 return Effect.sync(cleanup);
82 }).pipe(Effect.timeoutOption(timeoutMs), Effect.map(Option.flatten));
83});
84
85const isUnavailableBootstrapFdError = Predicate.compose(
86 Predicate.hasProperty("code"),
87 (_) => _.code === "EBADF" || _.code === "ENOENT",
88);
89
90const isFdReady = (fd: number) =>
91 Effect.try({
92 try: () => NFS.fstatSync(fd),
93 catch: (error) =>
94 new BootstrapError({
95 message: "Failed to stat bootstrap fd.",
96 cause: error,
97 }),
98 }).pipe(
99 Effect.as(true),
100 Effect.catchIf(
101 (error) => isUnavailableBootstrapFdError(error.cause),
102 () => Effect.succeed(false),
103 ),
104 );
105
106const makeBootstrapInputStream = (fd: number) =>
107 Effect.try<Readable, BootstrapError>({
108 try: () => {
109 const fdPath = resolveFdPath(fd);
110 if (fdPath === undefined) {
111 const stream = new Net.Socket({
112 fd,
113 readable: true,
114 writable: false,
115 });
116 stream.setEncoding("utf8");
117 return stream;
118 }
119
120 const streamFd = NFS.openSync(fdPath, "r");
121 return NFS.createReadStream("", {
122 fd: streamFd,
123 encoding: "utf8",
124 autoClose: true,
125 });
126 },
127 catch: (error) =>
128 new BootstrapError({
129 message: "Failed to duplicate bootstrap fd.",
130 cause: error,
131 }),
132 });
133
134export function resolveFdPath(
135 fd: number,
136 platform: NodeJS.Platform = process.platform,
137): string | undefined {
138 if (platform === "linux") {
139 return `/proc/self/fd/${fd}`;
140 }
141 if (platform === "win32") {
142 return undefined;
143 }
144 return `/dev/fd/${fd}`;
145}
apps/server/src/main.test.ts+94
...@@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest";...@@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest";
4import type { OrchestrationReadModel } from "@t3tools/contracts";4import type { OrchestrationReadModel } from "@t3tools/contracts";
5import * as ConfigProvider from "effect/ConfigProvider";5import * as ConfigProvider from "effect/ConfigProvider";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
8import * as Command from "effect/unstable/cli/Command";9import * as Command from "effect/unstable/cli/Command";
9import { FetchHttpClient } from "effect/unstable/http";10import { FetchHttpClient } from "effect/unstable/http";
...@@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => {
151 }),152 }),
152 );153 );
153154
155 const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
156 const fs = yield* FileSystem.FileSystem;
157 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
158 yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
159 const { fd } = yield* fs.open(filePath, { flag: "r" });
160 return fd;
161 });
162
163 it.effect("recognizes bootstrap fd from environment config", () =>
164 Effect.gen(function* () {
165 const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" });
166
167 yield* runCli([], {
168 T3CODE_MODE: "web",
169 T3CODE_BOOTSTRAP_FD: String(fd),
170 T3CODE_AUTH_TOKEN: "env-token",
171 T3CODE_NO_BROWSER: "true",
172 });
173
174 assert.equal(start.mock.calls.length, 1);
175 assert.equal(resolvedConfig?.mode, "web");
176 assert.equal(resolvedConfig?.authToken, "env-token");
177 }),
178 );
179
180 it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () =>
181 Effect.gen(function* () {
182 const fd = yield* openBootstrapFd({
183 mode: "desktop",
184 port: 4888,
185 host: "127.0.0.2",
186 t3Home: "/tmp/t3-bootstrap-home",
187 devUrl: "http://127.0.0.1:5173",
188 noBrowser: true,
189 authToken: "bootstrap-token",
190 autoBootstrapProjectFromCwd: false,
191 logWebSocketEvents: true,
192 });
193
194 yield* runCli([], {
195 T3CODE_BOOTSTRAP_FD: String(fd),
196 });
197
198 assert.equal(start.mock.calls.length, 1);
199 assert.equal(resolvedConfig?.mode, "desktop");
200 assert.equal(resolvedConfig?.port, 4888);
201 assert.equal(resolvedConfig?.host, "127.0.0.2");
202 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home");
203 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev");
204 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
205 assert.equal(resolvedConfig?.noBrowser, true);
206 assert.equal(resolvedConfig?.authToken, "bootstrap-token");
207 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false);
208 assert.equal(resolvedConfig?.logWebSocketEvents, true);
209 }),
210 );
211
212 it.effect("applies CLI then env precedence over bootstrap envelope values", () =>
213 Effect.gen(function* () {
214 const fd = yield* openBootstrapFd({
215 mode: "desktop",
216 port: 4888,
217 host: "127.0.0.2",
218 t3Home: "/tmp/t3-bootstrap-home",
219 devUrl: "http://127.0.0.1:5173",
220 noBrowser: false,
221 authToken: "bootstrap-token",
222 autoBootstrapProjectFromCwd: false,
223 logWebSocketEvents: false,
224 });
225
226 yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], {
227 T3CODE_MODE: "web",
228 T3CODE_BOOTSTRAP_FD: String(fd),
229 T3CODE_HOME: "/tmp/t3-env-home",
230 T3CODE_NO_BROWSER: "true",
231 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
232 T3CODE_LOG_WS_EVENTS: "true",
233 });
234
235 assert.equal(start.mock.calls.length, 1);
236 assert.equal(resolvedConfig?.mode, "web");
237 assert.equal(resolvedConfig?.port, 4999);
238 assert.equal(resolvedConfig?.host, "0.0.0.0");
239 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
240 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
241 assert.equal(resolvedConfig?.noBrowser, true);
242 assert.equal(resolvedConfig?.authToken, "cli-token");
243 assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true);
244 assert.equal(resolvedConfig?.logWebSocketEvents, true);
245 }),
246 );
247
154 it.effect("prefers --mode over T3CODE_MODE", () =>248 it.effect("prefers --mode over T3CODE_MODE", () =>
155 Effect.gen(function* () {249 Effect.gen(function* () {
156 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));250 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
apps/server/src/main.ts+137-31
...@@ -27,12 +27,27 @@ import { Server } from "./wsServer";...@@ -27,12 +27,27 @@ import { Server } from "./wsServer";
27import { ServerLoggerLive } from "./serverLogger";27import { ServerLoggerLive } from "./serverLogger";
28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";28import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";29import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
30import { readBootstrapEnvelope } from "./bootstrap";
3031
31export class StartupError extends Data.TaggedError("StartupError")<{32export class StartupError extends Data.TaggedError("StartupError")<{
32 readonly message: string;33 readonly message: string;
33 readonly cause?: unknown;34 readonly cause?: unknown;
34}> {}35}> {}
3536
37const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
38
39const BootstrapEnvelopeSchema = Schema.Struct({
40 mode: Schema.optional(Schema.String),
41 port: Schema.optional(PortSchema),
42 host: Schema.optional(Schema.String),
43 t3Home: Schema.optional(Schema.String),
44 devUrl: Schema.optional(Schema.URLFromString),
45 noBrowser: Schema.optional(Schema.Boolean),
46 authToken: Schema.optional(Schema.String),
47 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
48 logWebSocketEvents: Schema.optional(Schema.Boolean),
49});
50
36interface CliInput {51interface CliInput {
37 readonly mode: Option.Option<RuntimeMode>;52 readonly mode: Option.Option<RuntimeMode>;
38 readonly port: Option.Option<number>;53 readonly port: Option.Option<number>;
...@@ -41,6 +56,7 @@ interface CliInput {...@@ -41,6 +56,7 @@ interface CliInput {
41 readonly devUrl: Option.Option<URL>;56 readonly devUrl: Option.Option<URL>;
42 readonly noBrowser: Option.Option<boolean>;57 readonly noBrowser: Option.Option<boolean>;
43 readonly authToken: Option.Option<string>;58 readonly authToken: Option.Option<string>;
59 readonly bootstrapFd: Option.Option<number>;
44 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;60 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
45 readonly logWebSocketEvents: Option.Option<boolean>;61 readonly logWebSocketEvents: Option.Option<boolean>;
46}62}
...@@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(...@@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
91const CliEnvConfig = Config.all({107const CliEnvConfig = Config.all({
92 mode: Config.string("T3CODE_MODE").pipe(108 mode: Config.string("T3CODE_MODE").pipe(
93 Config.option,109 Config.option,
94 Config.map(110 Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))),
95 Option.match<RuntimeMode, string>({111 Config.map(Option.getOrUndefined),
96 onNone: () => "web",
97 onSome: (value) => (value === "desktop" ? "desktop" : "web"),
98 }),
99 ),
100 ),112 ),
101 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),113 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
102 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),114 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
...@@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({...@@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({
110 Config.option,122 Config.option,
111 Config.map(Option.getOrUndefined),123 Config.map(Option.getOrUndefined),
112 ),124 ),
125 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
126 Config.option,
127 Config.map(Option.getOrUndefined),
128 ),
113 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(129 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
114 Config.option,130 Config.option,
115 Config.map(Option.getOrUndefined),131 Config.map(Option.getOrUndefined),
...@@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({...@@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({
123const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>139const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
124 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);140 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
125141
142const resolveOptionPrecedence = <Value>(
143 ...values: ReadonlyArray<Option.Option<Value>>
144): Option.Option<Value> => Option.firstSomeOf(values);
145
146const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535;
147const isRuntimeMode = (value: string): value is RuntimeMode =>
148 value === "web" || value === "desktop";
149
126const ServerConfigLive = (input: CliInput) =>150const ServerConfigLive = (input: CliInput) =>
127 Layer.effect(151 Layer.effect(
128 ServerConfig,152 ServerConfig,
...@@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) =>...@@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) =>
136 ),160 ),
137 );161 );
138162
139 const mode = Option.getOrElse(input.mode, () => env.mode);163 const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd;
140164 const bootstrapEnvelope =
141 const port = yield* Option.match(input.port, {165 bootstrapFd !== undefined
142 onSome: (value) => Effect.succeed(value),166 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
143 onNone: () => {167 : Option.none();
144 if (env.port) {168
145 return Effect.succeed(env.port);169 const mode: RuntimeMode = Option.getOrElse(
146 }170 resolveOptionPrecedence(
147 if (mode === "desktop") {171 input.mode,
148 return Effect.succeed(DEFAULT_PORT);172 Option.fromUndefinedOr(env.mode),
149 }173 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
150 return findAvailablePort(DEFAULT_PORT);174 Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode),
175 ),
176 ),
177 () => "web",
178 );
179 const port = yield* Option.match(
180 resolveOptionPrecedence(
181 input.port,
182 Option.fromUndefinedOr(env.port),
183 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
184 Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort),
185 ),
186 ),
187 {
188 onSome: (value) => Effect.succeed(value),
189 onNone: () => {
190 if (mode === "desktop") {
191 return Effect.succeed(DEFAULT_PORT);
192 }
193 return findAvailablePort(DEFAULT_PORT);
194 },
151 },195 },
152 });196 );
153197
154 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);198 const devUrl = Option.getOrElse(
155 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home);199 resolveOptionPrecedence(
200 input.devUrl,
201 Option.fromUndefinedOr(env.devUrl),
202 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
203 Option.fromUndefinedOr(bootstrap.devUrl),
204 ),
205 ),
206 () => undefined,
207 );
208 const baseDir = yield* resolveBaseDir(
209 Option.getOrUndefined(
210 resolveOptionPrecedence(
211 input.t3Home,
212 Option.fromUndefinedOr(env.t3Home),
213 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
214 Option.fromUndefinedOr(bootstrap.t3Home),
215 ),
216 ),
217 ),
218 );
156 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);219 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
157 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");220 const noBrowser = resolveBooleanFlag(
158 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;221 input.noBrowser,
222 Option.getOrElse(
223 resolveOptionPrecedence(
224 Option.fromUndefinedOr(env.noBrowser),
225 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
226 Option.fromUndefinedOr(bootstrap.noBrowser),
227 ),
228 ),
229 () => mode === "desktop",
230 ),
231 );
232 const authToken = resolveOptionPrecedence(
233 input.authToken,
234 Option.fromUndefinedOr(env.authToken),
235 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
236 Option.fromUndefinedOr(bootstrap.authToken),
237 ),
238 );
159 const autoBootstrapProjectFromCwd = resolveBooleanFlag(239 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
160 input.autoBootstrapProjectFromCwd,240 input.autoBootstrapProjectFromCwd,
161 env.autoBootstrapProjectFromCwd ?? mode === "web",241 Option.getOrElse(
242 resolveOptionPrecedence(
243 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
244 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
245 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
246 ),
247 ),
248 () => mode === "web",
249 ),
162 );250 );
163 const logWebSocketEvents = resolveBooleanFlag(251 const logWebSocketEvents = resolveBooleanFlag(
164 input.logWebSocketEvents,252 input.logWebSocketEvents,
165 env.logWebSocketEvents ?? Boolean(devUrl),253 Option.getOrElse(
254 resolveOptionPrecedence(
255 Option.fromUndefinedOr(env.logWebSocketEvents),
256 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
257 Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
258 ),
259 ),
260 () => Boolean(devUrl),
261 ),
166 );262 );
167 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;263 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
168 const host =264 const host = Option.getOrElse(
169 Option.getOrUndefined(input.host) ??265 resolveOptionPrecedence(
170 env.host ??266 input.host,
171 (mode === "desktop" ? "127.0.0.1" : undefined);267 Option.fromUndefinedOr(env.host),
268 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
269 ),
270 () => (mode === "desktop" ? "127.0.0.1" : undefined),
271 );
172272
173 const config: ServerConfigShape = {273 const config: ServerConfigShape = {
174 mode,274 mode,
...@@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) =>...@@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) =>
180 staticDir,280 staticDir,
181 devUrl,281 devUrl,
182 noBrowser,282 noBrowser,
183 authToken,283 authToken: Option.getOrUndefined(authToken),
184 autoBootstrapProjectFromCwd,284 autoBootstrapProjectFromCwd,
185 logWebSocketEvents,285 logWebSocketEvents,
186 } satisfies ServerConfigShape;286 } satisfies ServerConfigShape;
...@@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(...@@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
287 Flag.optional,387 Flag.optional,
288);388);
289const portFlag = Flag.integer("port").pipe(389const portFlag = Flag.integer("port").pipe(
290 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),390 Flag.withSchema(PortSchema),
291 Flag.withDescription("Port for the HTTP/WebSocket server."),391 Flag.withDescription("Port for the HTTP/WebSocket server."),
292 Flag.optional,392 Flag.optional,
293);393);
...@@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe(...@@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe(
313 Flag.withAlias("token"),413 Flag.withAlias("token"),
314 Flag.optional,414 Flag.optional,
315);415);
416const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
417 Flag.withSchema(Schema.Int),
418 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
419 Flag.optional,
420);
316const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(421const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
317 Flag.withDescription(422 Flag.withDescription(
318 "Create a project for the current working directory on startup when missing.",423 "Create a project for the current working directory on startup when missing.",
...@@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", {...@@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", {
335 devUrl: devUrlFlag,440 devUrl: devUrlFlag,
336 noBrowser: noBrowserFlag,441 noBrowser: noBrowserFlag,
337 authToken: authTokenFlag,442 authToken: authTokenFlag,
443 bootstrapFd: bootstrapFdFlag,
338 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,444 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
339 logWebSocketEvents: logWebSocketEventsFlag,445 logWebSocketEvents: logWebSocketEventsFlag,
340}).pipe(446}).pipe(
scripts/dev-runner.test.ts+37
...@@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
160 assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3"));160 assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3"));
161 }),161 }),
162 );162 );
163
164 it.effect("does not export backend bootstrap env for dev:desktop", () =>
165 Effect.gen(function* () {
166 const env = yield* createDevRunnerEnv({
167 mode: "dev:desktop",
168 baseEnv: {
169 T3CODE_PORT: "3773",
170 T3CODE_AUTH_TOKEN: "stale-token",
171 T3CODE_MODE: "web",
172 T3CODE_NO_BROWSER: "0",
173 T3CODE_HOST: "0.0.0.0",
174 VITE_WS_URL: "ws://localhost:3773",
175 },
176 serverOffset: 0,
177 webOffset: 0,
178 t3Home: "/tmp/my-t3",
179 authToken: "fresh-token",
180 noBrowser: true,
181 autoBootstrapProjectFromCwd: undefined,
182 logWebSocketEvents: undefined,
183 host: "127.0.0.1",
184 port: 4222,
185 devUrl: undefined,
186 });
187
188 assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3"));
189 assert.equal(env.PORT, "5733");
190 assert.equal(env.ELECTRON_RENDERER_PORT, "5733");
191 assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733");
192 assert.equal(env.T3CODE_PORT, undefined);
193 assert.equal(env.T3CODE_AUTH_TOKEN, undefined);
194 assert.equal(env.T3CODE_MODE, undefined);
195 assert.equal(env.T3CODE_NO_BROWSER, undefined);
196 assert.equal(env.T3CODE_HOST, undefined);
197 assert.equal(env.VITE_WS_URL, undefined);
198 }),
199 );
163 });200 });
164201
165 describe("findFirstAvailableOffset", () => {202 describe("findFirstAvailableOffset", () => {
scripts/dev-runner.ts+22-7
...@@ -147,30 +147,41 @@ export function createDevRunnerEnv({...@@ -147,30 +147,41 @@ export function createDevRunnerEnv({
147 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;147 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
148 const webPort = BASE_WEB_PORT + webOffset;148 const webPort = BASE_WEB_PORT + webOffset;
149 const resolvedBaseDir = yield* resolveBaseDir(t3Home);149 const resolvedBaseDir = yield* resolveBaseDir(t3Home);
150 const isDesktopMode = mode === "dev:desktop";
150151
151 const output: NodeJS.ProcessEnv = {152 const output: NodeJS.ProcessEnv = {
152 ...baseEnv,153 ...baseEnv,
153 T3CODE_PORT: String(serverPort),
154 PORT: String(webPort),154 PORT: String(webPort),
155 ELECTRON_RENDERER_PORT: String(webPort),155 ELECTRON_RENDERER_PORT: String(webPort),
156 VITE_WS_URL: `ws://localhost:${serverPort}`,
157 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,156 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
158 T3CODE_HOME: resolvedBaseDir,157 T3CODE_HOME: resolvedBaseDir,
159 };158 };
160159
161 if (host !== undefined) {160 if (!isDesktopMode) {
161 output.T3CODE_PORT = String(serverPort);
162 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
163 } else {
164 delete output.T3CODE_PORT;
165 delete output.VITE_WS_URL;
166 delete output.T3CODE_AUTH_TOKEN;
167 delete output.T3CODE_MODE;
168 delete output.T3CODE_NO_BROWSER;
169 delete output.T3CODE_HOST;
170 }
171
172 if (!isDesktopMode && host !== undefined) {
162 output.T3CODE_HOST = host;173 output.T3CODE_HOST = host;
163 }174 }
164175
165 if (authToken !== undefined) {176 if (!isDesktopMode && authToken !== undefined) {
166 output.T3CODE_AUTH_TOKEN = authToken;177 output.T3CODE_AUTH_TOKEN = authToken;
167 } else {178 } else if (!isDesktopMode) {
168 delete output.T3CODE_AUTH_TOKEN;179 delete output.T3CODE_AUTH_TOKEN;
169 }180 }
170181
171 if (noBrowser !== undefined) {182 if (!isDesktopMode && noBrowser !== undefined) {
172 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";183 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";
173 } else {184 } else if (!isDesktopMode) {
174 delete output.T3CODE_NO_BROWSER;185 delete output.T3CODE_NO_BROWSER;
175 }186 }
176187
...@@ -196,6 +207,10 @@ export function createDevRunnerEnv({...@@ -196,6 +207,10 @@ export function createDevRunnerEnv({
196 delete output.T3CODE_DESKTOP_WS_URL;207 delete output.T3CODE_DESKTOP_WS_URL;
197 }208 }
198209
210 if (isDesktopMode) {
211 delete output.T3CODE_DESKTOP_WS_URL;
212 }
213
199 return output;214 return output;
200 });215 });
201}216}