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
66
77The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
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. |
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. |
1819
1920> TIP: Use the `--help` flag to see all available options and their descriptions.
2021
2122## Security First
2223
2324- 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.
2427- Treat the token like a password.
2528- 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";
5656const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
5757const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
5858const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
59const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
5960const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6061const STATE_DIR = Path.join(BASE_DIR, "userdata");
6162const DESKTOP_SCHEME = "t3";
......@@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string {
113114 return value.replace(/\s+/g, " ").trim();
114115}
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
116128function writeDesktopLogHeader(message: string): void {
117129 if (!desktopLogSink) return;
118130 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
......@@ -918,17 +930,6 @@ function configureAutoUpdater(): void {
918930 }, AUTO_UPDATE_POLL_INTERVAL_MS);
919931 updatePollTimer.unref();
920932}
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
932933function scheduleBackendRestart(reason: string): void {
933934 if (isQuitting || restartTimer) return;
934935
......@@ -952,16 +953,35 @@ function startBackend(): void {
952953 }
953954
954955 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"], {
956957 cwd: resolveBackendCwd(),
957958 // In Electron main, process.execPath points to the Electron binary.
958959 // Run the child in Node mode so this backend process does not become a GUI app instance.
959960 env: {
960 ...backendEnv(),
961 ...backendChildEnv(),
961962 ELECTRON_RUN_AS_NODE: "1",
962963 },
963 stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit",
964 stdio: captureBackendLogs
965 ? ["ignore", "pipe", "pipe", "pipe"]
966 : ["ignore", "inherit", "inherit", "pipe"],
964967 });
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 }
965985 backendProcess = child;
966986 let backendSessionClosed = false;
967987 const closeBackendSession = (details: string) => {
......@@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
10721092}
10731093
10741094function registerIpcHandlers(): void {
1095 ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
1096 ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
1097 event.returnValue = backendWsUrl;
1098 });
1099
10751100 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
10761101 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
10771102 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
......@@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> {
13201345 );
13211346 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
13221347 backendAuthToken = Crypto.randomBytes(24).toString("hex");
1323 backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
1324 process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
1325 writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`);
1348 const baseUrl = `ws://127.0.0.1:${backendPort}`;
1349 backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
1350 writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);
13261351
13271352 registerIpcHandlers();
13281353 writeDesktopLogHeader("bootstrap ipc handlers registered");
apps/desktop/src/preload.ts+5-2
......@@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
1111const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1212const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1313const 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
1616contextBridge.exposeInMainWorld("desktopBridge", {
17 getWsUrl: () => wsUrl,
17 getWsUrl: () => {
18 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
19 return typeof result === "string" ? result : null;
20 },
1821 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
1922 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
2023 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";
44import type { OrchestrationReadModel } from "@t3tools/contracts";
55import * as ConfigProvider from "effect/ConfigProvider";
66import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
78import * as Layer from "effect/Layer";
89import * as Command from "effect/unstable/cli/Command";
910import { FetchHttpClient } from "effect/unstable/http";
......@@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => {
151152 }),
152153 );
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
154248 it.effect("prefers --mode over T3CODE_MODE", () =>
155249 Effect.gen(function* () {
156250 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666));
apps/server/src/main.ts+137-31
......@@ -27,12 +27,27 @@ import { Server } from "./wsServer";
2727import { ServerLoggerLive } from "./serverLogger";
2828import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService";
2929import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
30import { readBootstrapEnvelope } from "./bootstrap";
3031
3132export class StartupError extends Data.TaggedError("StartupError")<{
3233 readonly message: string;
3334 readonly cause?: unknown;
3435}> {}
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
3651interface CliInput {
3752 readonly mode: Option.Option<RuntimeMode>;
3853 readonly port: Option.Option<number>;
......@@ -41,6 +56,7 @@ interface CliInput {
4156 readonly devUrl: Option.Option<URL>;
4257 readonly noBrowser: Option.Option<boolean>;
4358 readonly authToken: Option.Option<string>;
59 readonly bootstrapFd: Option.Option<number>;
4460 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
4561 readonly logWebSocketEvents: Option.Option<boolean>;
4662}
......@@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
91107const CliEnvConfig = Config.all({
92108 mode: Config.string("T3CODE_MODE").pipe(
93109 Config.option,
94 Config.map(
95 Option.match<RuntimeMode, string>({
96 onNone: () => "web",
97 onSome: (value) => (value === "desktop" ? "desktop" : "web"),
98 }),
99 ),
110 Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))),
111 Config.map(Option.getOrUndefined),
100112 ),
101113 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
102114 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
......@@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({
110122 Config.option,
111123 Config.map(Option.getOrUndefined),
112124 ),
125 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
126 Config.option,
127 Config.map(Option.getOrUndefined),
128 ),
113129 autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe(
114130 Config.option,
115131 Config.map(Option.getOrUndefined),
......@@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({
123139const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
124140 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
126150const ServerConfigLive = (input: CliInput) =>
127151 Layer.effect(
128152 ServerConfig,
......@@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) =>
136160 ),
137161 );
138162
139 const mode = Option.getOrElse(input.mode, () => env.mode);
140
141 const port = yield* Option.match(input.port, {
142 onSome: (value) => Effect.succeed(value),
143 onNone: () => {
144 if (env.port) {
145 return Effect.succeed(env.port);
146 }
147 if (mode === "desktop") {
148 return Effect.succeed(DEFAULT_PORT);
149 }
150 return findAvailablePort(DEFAULT_PORT);
163 const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd;
164 const bootstrapEnvelope =
165 bootstrapFd !== undefined
166 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
167 : Option.none();
168
169 const mode: RuntimeMode = Option.getOrElse(
170 resolveOptionPrecedence(
171 input.mode,
172 Option.fromUndefinedOr(env.mode),
173 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
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 },
151195 },
152 });
196 );
153197
154 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);
155 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home);
198 const devUrl = Option.getOrElse(
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 );
156219 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
157 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");
158 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
220 const noBrowser = resolveBooleanFlag(
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 );
159239 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
160240 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 ),
162250 );
163251 const logWebSocketEvents = resolveBooleanFlag(
164252 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 ),
166262 );
167263 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
168 const host =
169 Option.getOrUndefined(input.host) ??
170 env.host ??
171 (mode === "desktop" ? "127.0.0.1" : undefined);
264 const host = Option.getOrElse(
265 resolveOptionPrecedence(
266 input.host,
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
173273 const config: ServerConfigShape = {
174274 mode,
......@@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) =>
180280 staticDir,
181281 devUrl,
182282 noBrowser,
183 authToken,
283 authToken: Option.getOrUndefined(authToken),
184284 autoBootstrapProjectFromCwd,
185285 logWebSocketEvents,
186286 } satisfies ServerConfigShape;
......@@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
287387 Flag.optional,
288388);
289389const portFlag = Flag.integer("port").pipe(
290 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
390 Flag.withSchema(PortSchema),
291391 Flag.withDescription("Port for the HTTP/WebSocket server."),
292392 Flag.optional,
293393);
......@@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe(
313413 Flag.withAlias("token"),
314414 Flag.optional,
315415);
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);
316421const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
317422 Flag.withDescription(
318423 "Create a project for the current working directory on startup when missing.",
......@@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", {
335440 devUrl: devUrlFlag,
336441 noBrowser: noBrowserFlag,
337442 authToken: authTokenFlag,
443 bootstrapFd: bootstrapFdFlag,
338444 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
339445 logWebSocketEvents: logWebSocketEventsFlag,
340446}).pipe(
scripts/dev-runner.test.ts+37
......@@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
160160 assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3"));
161161 }),
162162 );
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 );
163200 });
164201
165202 describe("findFirstAvailableOffset", () => {
scripts/dev-runner.ts+22-7
......@@ -147,30 +147,41 @@ export function createDevRunnerEnv({
147147 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
148148 const webPort = BASE_WEB_PORT + webOffset;
149149 const resolvedBaseDir = yield* resolveBaseDir(t3Home);
150 const isDesktopMode = mode === "dev:desktop";
150151
151152 const output: NodeJS.ProcessEnv = {
152153 ...baseEnv,
153 T3CODE_PORT: String(serverPort),
154154 PORT: String(webPort),
155155 ELECTRON_RENDERER_PORT: String(webPort),
156 VITE_WS_URL: `ws://localhost:${serverPort}`,
157156 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
158157 T3CODE_HOME: resolvedBaseDir,
159158 };
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) {
162173 output.T3CODE_HOST = host;
163174 }
164175
165 if (authToken !== undefined) {
176 if (!isDesktopMode && authToken !== undefined) {
166177 output.T3CODE_AUTH_TOKEN = authToken;
167 } else {
178 } else if (!isDesktopMode) {
168179 delete output.T3CODE_AUTH_TOKEN;
169180 }
170181
171 if (noBrowser !== undefined) {
182 if (!isDesktopMode && noBrowser !== undefined) {
172183 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";
173 } else {
184 } else if (!isDesktopMode) {
174185 delete output.T3CODE_NO_BROWSER;
175186 }
176187
......@@ -196,6 +207,10 @@ export function createDevRunnerEnv({
196207 delete output.T3CODE_DESKTOP_WS_URL;
197208 }
198209
210 if (isDesktopMode) {
211 delete output.T3CODE_DESKTOP_WS_URL;
212 }
213
199214 return output;
200215 });
201216}