1import * as NodeOS from "node:os";
2
3import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
4import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
5import * as Clock from "effect/Clock";
6import * as Context from "effect/Context";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import * as Hex from "effect/encoding/Hex";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as PlatformError from "effect/PlatformError";
14import * as Schema from "effect/Schema";
15import * as SynchronizedRef from "effect/SynchronizedRef";
16
17import serverPackageJson from "../../../server/package.json" with { type: "json" };
18
19import * as DesktopBackendManager from "./DesktopBackendManager.ts";
20import * as DesktopCliShim from "../app/DesktopCliShim.ts";
21import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
22import * as DesktopServerExposure from "./DesktopServerExposure.ts";
23import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
24import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
25import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
26
27export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedError<DesktopBackendObservabilitySettingsReadError>()(
28 "DesktopBackendObservabilitySettingsReadError",
29 {
30 settingsPath: Schema.String,
31 cause: Schema.Defect(),
32 },
33) {
34 override get message(): string {
35 return `Failed to read persisted backend observability settings at ${this.settingsPath}.`;
36 }
37}
38
39export class DesktopBackendConfiguration extends Context.Service<
40 DesktopBackendConfiguration,
41 {
42 // Build the Windows-native primary backend's start config. Reads the
43 // primary's port/host/exposure from DesktopServerExposure. Can fail
44 // with PlatformError because bootstrap token generation now uses
45 // crypto.randomBytes under the hood (post Effect 4 migration).
46 readonly resolvePrimary: Effect.Effect<
47 DesktopBackendManager.DesktopBackendStartConfig,
48 PlatformError.PlatformError
49 >;
50 // Build a WSL backend start config for the given distro on the given
51 // port. The WSL backend is always loopback-only (the primary owns LAN
52 // exposure when the user opts in), so this takes the port directly and
53 // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is
54 // forwarded to wsl.exe with no -d flag.
55 readonly resolveWsl: (input: {
56 readonly port: number;
57 readonly distro: string | null;
58 }) => Effect.Effect<
59 DesktopBackendManager.DesktopBackendStartConfig,
60 PlatformError.PlatformError
61 >;
62 // The renderer-facing label for the primary instance, derived from the
63 // same decision resolvePrimary makes (including the WSL-availability
64 // fall-back to Windows), so the env switcher can't show "WSL" for a
65 // backend that actually resolved to Windows.
66 readonly resolvePrimaryLabel: Effect.Effect<string>;
67 // The bootstrap token the renderer should present right now. It rotates
68 // every window (derived from the secret every backend was launched with),
69 // so the renderer never holds one long-lived admin credential.
70 readonly currentBootstrapToken: Effect.Effect<string, PlatformError.PlatformError>;
71 }
72>()("@t3tools/desktop/backend/DesktopBackendConfiguration") {}
73
74interface BackendObservabilitySettings {
75 readonly otlpTracesUrl: Option.Option<string>;
76 readonly otlpMetricsUrl: Option.Option<string>;
77 readonly otlpLogsUrl: Option.Option<string>;
78}
79
80const emptyBackendObservabilitySettings: BackendObservabilitySettings = {
81 otlpTracesUrl: Option.none(),
82 otlpMetricsUrl: Option.none(),
83 otlpLogsUrl: Option.none(),
84};
85
86const DESKTOP_BACKEND_ENV_NAMES = [
87 "T3CODE_PORT",
88 "T3CODE_MODE",
89 "T3CODE_NO_BROWSER",
90 "T3CODE_HOST",
91 "T3CODE_DESKTOP_WS_URL",
92 "T3CODE_DESKTOP_LAN_ACCESS",
93 "T3CODE_DESKTOP_LAN_HOST",
94 "T3CODE_DESKTOP_HTTPS_ENDPOINTS",
95 "T3CODE_TAILSCALE_SERVE",
96 "T3CODE_TAILSCALE_SERVE_PORT",
97] as const;
98
99// Env vars that the WSL backend needs but Windows process.env won't forward
100// across the wsl.exe boundary without WSLENV. The dev-server URL travels as
101// the `--dev-url` CLI flag instead.
102const WSL_FORWARDED_ENV_NAMES = [
103 "OPENAI_API_KEY",
104 "ANTHROPIC_API_KEY",
105 // Otherwise the WSL server keeps exporting to endpoints from the bootstrap.
106 "T3CODE_OTEL_SDK_DISABLED",
107 "OTEL_SDK_DISABLED",
108 "T3CODE_OTLP_HEADERS",
109 "T3CODE_OTLP_PROTOCOL",
110 // Forwarded without a WSLENV flag, so the values arrive untranslated. The
111 // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs
112 // travel as variables to keep winning inside the distro as they do on Windows.
113 "T3CODE_OTLP_TRACES_URL",
114 "T3CODE_OTLP_METRICS_URL",
115 "T3CODE_OTLP_LOGS_URL",
116 "OTEL_EXPORTER_OTLP_ENDPOINT",
117 "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
118 "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
119 "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
120 "OTEL_EXPORTER_OTLP_HEADERS",
121 "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
122 "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
123 "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
124 "OTEL_EXPORTER_OTLP_PROTOCOL",
125 "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
126 "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
127 "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
128 "OTEL_TRACES_EXPORTER",
129 "OTEL_METRICS_EXPORTER",
130 "OTEL_LOGS_EXPORTER",
131] as const;
132
133const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
134
135const nodeBinDirOf = (nodePath: string): string => {
136 const lastSlash = nodePath.lastIndexOf("/");
137 return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin";
138};
139
140const backendChildEnvPatch = (): Record<string, string | undefined> =>
141 Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));
142
143const getWslEnvEntryName = (entry: string): string => {
144 const slashIndex = entry.indexOf("/");
145 return slashIndex === -1 ? entry : entry.slice(0, slashIndex);
146};
147
148const mergeWslEnv = (
149 existingWslEnv: string | undefined,
150 forwardedEnvNames: ReadonlyArray<string>,
151): string | undefined => {
152 const existing = existingWslEnv?.trim() ?? "";
153
154 // Names already declared, so we don't forward a duplicate. We parse the
155 // existing value only for this membership test — the string itself is
156 // preserved verbatim below rather than re-serialized.
157 const seenNames = new Set(
158 existing
159 .split(":")
160 .map((entry) => getWslEnvEntryName(entry.trim()))
161 .filter((name) => name.length > 0),
162 );
163
164 const additions = forwardedEnvNames.filter((name) => !seenNames.has(name));
165
166 // Preserve the user's WSLENV exactly as Windows handed it to us — empty
167 // "::" segments and duplicate entries are harmless no-ops to WSL and not
168 // ours to normalize — and only append the secrets we need to forward
169 // across the wsl.exe boundary.
170 const parts = [existing, ...additions].filter((part) => part.length > 0);
171 return parts.length > 0 ? parts.join(":") : undefined;
172};
173
174const logBackendObservabilitySettingsReadFailure = (
175 settingsPath: string,
176 cause: PlatformError.PlatformError,
177) => {
178 const error = new DesktopBackendObservabilitySettingsReadError({ settingsPath, cause });
179 return Effect.logWarning(error).pipe(
180 Effect.annotateLogs({
181 component: "desktop-backend-configuration",
182 error,
183 }),
184 );
185};
186
187function resourceMonitorBinaryName(platform: NodeJS.Platform): string {
188 return platform === "win32" ? "t3-resource-monitor.exe" : "t3-resource-monitor";
189}
190
191const resolveResourceMonitorPath = Effect.fn(
192 "desktop.backendConfiguration.resolveResourceMonitorPath",
193)(function* () {
194 const environment = yield* DesktopEnvironment.DesktopEnvironment;
195 const fileSystem = yield* FileSystem.FileSystem;
196 const binaryName = resourceMonitorBinaryName(environment.platform);
197 const candidates = environment.isDevelopment
198 ? [
199 environment.path.join(
200 environment.rootDir,
201 "native/resource-monitor/target/release",
202 binaryName,
203 ),
204 environment.path.join(
205 environment.rootDir,
206 "native/resource-monitor/target/debug",
207 binaryName,
208 ),
209 ]
210 : environment.isPackaged
211 ? [environment.path.join(environment.resourcesPath, "resource-monitor", binaryName)]
212 : environment.resolveResourcePathCandidates(
213 environment.path.join("resource-monitor", binaryName),
214 );
215
216 for (const candidate of candidates) {
217 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
218 return Option.some(candidate);
219 }
220 }
221
222 return Option.none<string>();
223});
224
225const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
226 const fileSystem = yield* FileSystem.FileSystem;
227 const environment = yield* DesktopEnvironment.DesktopEnvironment;
228 const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(
229 Effect.asSome,
230 Effect.catchTags({
231 PlatformError: (cause) =>
232 cause.reason._tag === "NotFound"
233 ? Effect.succeedNone
234 : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe(
235 Effect.as(Option.none()),
236 ),
237 }),
238 );
239 if (Option.isNone(raw)) {
240 return emptyBackendObservabilitySettings;
241 }
242
243 const parsed = parsePersistedServerObservabilitySettings(raw.value);
244 return {
245 otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl),
246 otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl),
247 otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl),
248 };
249});
250
251// The bootstrap carries the OTLP endpoints to every backend, including a WSL
252// child that lacks the variables. The T3 URLs also travel as variables in
253// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats
254// the persisted settings file, matching the precedence resolveServerConfig and
255// DesktopObservability apply.
256const readBackendObservabilitySettings = Effect.gen(function* () {
257 const environment = yield* DesktopEnvironment.DesktopEnvironment;
258 const persisted = yield* readPersistedBackendObservabilitySettings;
259 return {
260 otlpTracesUrl: Option.orElse(environment.otlpTracesUrl, () => persisted.otlpTracesUrl),
261 otlpMetricsUrl: Option.orElse(environment.otlpMetricsUrl, () => persisted.otlpMetricsUrl),
262 otlpLogsUrl: Option.orElse(environment.otlpLogsUrl, () => persisted.otlpLogsUrl),
263 } satisfies BackendObservabilitySettings;
264});
265
266interface SharedBootstrapInput {
267 readonly bootstrapToken: string;
268 readonly bootstrapSecret: string;
269 readonly observabilitySettings: BackendObservabilitySettings;
270}
271
272// What the launch runs inside the distro. The staged runtime is the release's
273// self-contained `t3` executable (Node inside); the mounted server tree is a
274// script that needs the distro's own Node.
275type WslPreflightRuntime =
276 | {
277 readonly kind: "executable";
278 readonly entryPath: string;
279 }
280 | {
281 readonly kind: "node-script";
282 // Absolute path to the node binary the preflight validated after the
283 // shared remote resolver repaired PATH. The launch must use this exact
284 // path so it doesn't fall through to a different/old node than the one
285 // node-pty was probed with.
286 readonly nodePath: string;
287 readonly linuxEntryPath: string;
288 };
289
290interface WslPreflightSuccess {
291 readonly _tag: "Ready";
292 readonly runningDistro: string;
293 readonly windowsEntryPath: string;
294 readonly runtime: WslPreflightRuntime;
295 // PATH captured from the user's login shell. The launch forwards this value
296 // directly without a shell so the server can spawn provider CLIs by name.
297 readonly resolvedPath: string;
298 // Identifies the distro-local runtime cache selected from the packaged archive.
299 readonly runtimeId?: string;
300}
301
302interface WslPreflightFailure {
303 readonly _tag: "Failed";
304 readonly reason: string;
305 // Fatal: the WSL distro is misconfigured (no node, wrong version, missing
306 // build tools) and retrying won't help — surface it and (wsl-only) fall back
307 // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it
308 // boots), with a bounded window for self-healing before fallback.
309 readonly fatal: boolean;
310 readonly retryLimit?: number;
311}
312
313const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12;
314const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";
315const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`;
316const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i;
317
318const parseWslRuntimeArchiveHash = (value: string): string | null => {
319 const trimmed = value.trim();
320 return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
321};
322
323type FailedNodePtyResult = Extract<
324 DesktopWslEnvironment.EnsureWslNodePtyResult,
325 { readonly ok: false }
326>;
327
328const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: {
329 readonly distro: string | null;
330 readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null;
331 readonly allowBuild: boolean;
332}): Effect.fn.Return<
333 WslPreflightSuccess | WslPreflightFailure,
334 never,
335 | DesktopEnvironment.DesktopEnvironment
336 | DesktopWslEnvironment.DesktopWslEnvironment
337 | DesktopWslServerTree.DesktopWslServerTree
338 | FileSystem.FileSystem
339> {
340 const environment = yield* DesktopEnvironment.DesktopEnvironment;
341 const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment;
342 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
343 const fileSystem = yield* FileSystem.FileSystem;
344
345 const wslAvailable = yield* wslEnv.isAvailable;
346 if (!wslAvailable) {
347 return {
348 _tag: "Failed",
349 reason: "WSL is not available on this system",
350 fatal: false,
351 } as const;
352 }
353
354 const distroProbe = yield* wslEnv.probeDistros.pipe(
355 Effect.map((distros) => ({ _tag: "Success", distros }) as const),
356 Effect.catch((error) => Effect.succeed({ _tag: "Failure", error } as const)),
357 );
358 if (distroProbe._tag === "Failure") {
359 return {
360 _tag: "Failed",
361 reason: `Unable to list WSL distributions: ${distroProbe.error.message}`,
362 fatal: false,
363 } as const;
364 }
365
366 const installedDistros = distroProbe.distros;
367 const runningDistro = input.distro
368 ? (installedDistros.find(
369 (installed) => installed.name.toLowerCase() === input.distro?.toLowerCase(),
370 )?.name ?? null)
371 : (installedDistros.find((installed) => installed.isDefault)?.name ?? null);
372 if (runningDistro === null) {
373 return {
374 _tag: "Failed",
375 reason: input.distro
376 ? `WSL distro is not installed: ${input.distro}`
377 : installedDistros.length === 0
378 ? "WSL has no installed distributions"
379 : "WSL has no default distribution",
380 fatal: true,
381 } as const;
382 }
383
384 const nodePtyOptions = {
385 allowBuild: input.allowBuild,
386 nodeEngineRange: serverPackageJson.engines.node,
387 };
388 const failedNodePty = (result: FailedNodePtyResult) =>
389 ({
390 _tag: "Failed",
391 reason: `WSL node-pty unavailable: ${result.reason}`,
392 fatal: result.fatal,
393 ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }),
394 }) as const;
395
396 // The mounted server tree is the fallback runtime: the Windows-side copy the
397 // distro reads over /mnt. Slower to launch from, but always installed.
398 const resolveMountedAppRoot = Effect.gen(function* () {
399 const serverTree = yield* wslServerTree.ensure;
400 if (!serverTree.ok) {
401 return { ok: false, reason: serverTree.reason, fatal: serverTree.fatal } as const;
402 }
403 const windowsEntryPath = environment.path.join(serverTree.root, "apps/server/dist/bin.mjs");
404 const entryExists = yield* fileSystem
405 .exists(windowsEntryPath)
406 .pipe(Effect.orElseSucceed(() => false));
407 if (!entryExists) {
408 return {
409 ok: false,
410 reason: `missing server entry at ${windowsEntryPath}`,
411 fatal: true,
412 } as const;
413 }
414 const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, serverTree.root);
415 return Option.isNone(mountedAppRoot)
416 ? ({
417 ok: false,
418 reason: `wslpath conversion failed for ${serverTree.root}`,
419 fatal: false,
420 } as const)
421 : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const);
422 });
423
424 // Set once a staged runtime has been ruled out by the probe, and carried
425 // through the mounted attempt: if the mounted tree works the cache is the
426 // broken part and gets invalidated, and if the mounted tree returns its own
427 // fatal verdict the cached reason is the more actionable one to report.
428 // A transient mounted failure is neither — it rules nothing out, so it stays
429 // retryable and the staged verdict waits for an attempt that can answer.
430 let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined;
431 const failedStaged = (failure: { readonly reason: string }) =>
432 ({
433 _tag: "Failed",
434 reason: `WSL runtime unavailable: ${failure.reason}`,
435 fatal: true,
436 }) as const;
437
438 if (input.runtimeArchive !== null) {
439 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
440 if (runtime.ok) {
441 // The staged runtime supplies its own Node and node-pty. Provider PATH
442 // discovery must not require either dependency for runtime readiness.
443 const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot);
444 if (stagedProbe.ok) {
445 yield* wslServerTree.cleanupLegacy;
446 return {
447 _tag: "Ready",
448 runningDistro,
449 windowsEntryPath: environment.backendEntryPath,
450 runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` },
451 resolvedPath: stagedProbe.resolvedPath,
452 runtimeId: input.runtimeArchive.runtimeId,
453 } as const;
454 }
455 yield* Effect.logWarning(
456 "The staged WSL runtime did not start; retrying from the mounted server tree.",
457 { reason: stagedProbe.reason },
458 );
459 stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason };
460 } else {
461 yield* Effect.logWarning(
462 "Could not stage the WSL runtime; launching from the mounted server tree instead.",
463 { reason: runtime.reason },
464 );
465 }
466 }
467
468 const mounted = yield* resolveMountedAppRoot;
469 if (!mounted.ok) {
470 return stagedFailure && mounted.fatal
471 ? failedStaged(stagedFailure)
472 : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);
473 }
474
475 const nodePtyResult = yield* wslEnv.ensureNodePty(
476 runningDistro,
477 mounted.linuxAppRoot,
478 nodePtyOptions,
479 );
480 if (!nodePtyResult.ok) {
481 // Substituting the staged verdict for a transient mounted failure would
482 // turn a retryable failure into a fatal one, ending the WSL attempt (and,
483 // in wsl-only mode, persisting Windows) before the slow /mnt path had a
484 // chance to answer and clear the bad cache.
485 return stagedFailure && nodePtyResult.fatal
486 ? failedStaged(stagedFailure)
487 : failedNodePty(nodePtyResult);
488 }
489
490 // The mounted tree runs what the cache could not, so the cache is the broken
491 // copy: revoke its ready marker so the next launch reinstalls it instead of
492 // reusing a tree that has already been proven unloadable.
493 if (stagedFailure) {
494 yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId);
495 }
496
497 return {
498 _tag: "Ready",
499 runningDistro,
500 windowsEntryPath: mounted.windowsEntryPath,
501 runtime: {
502 kind: "node-script",
503 nodePath: nodePtyResult.nodePath,
504 linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
505 },
506 resolvedPath: nodePtyResult.resolvedPath,
507 } as const;
508});
509
510// True when the given IPv4 belongs to a Windows-side network
511// interface. In WSL2 mirrored mode the distro's eth0 IP equals the
512// host's, which is the signature we use to detect that mode and
513// switch the renderer URL to loopback.
514const isLocalHostIpv4 = (ip: string): boolean => {
515 const interfaces = NodeOS.networkInterfaces();
516 for (const list of Object.values(interfaces)) {
517 if (!list) continue;
518 for (const entry of list) {
519 // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on
520 // the Node build Electron ships (41 / Node 22, verified), but some Node
521 // builds report the numeric 4. Normalize to a string so a future runtime
522 // bump can't silently break mirrored-mode detection and leave the
523 // renderer pointed at the distro IP instead of loopback.
524 const family = String(entry.family);
525 if ((family === "IPv4" || family === "4") && entry.address === ip) return true;
526 }
527 }
528 return false;
529};
530
531const buildObservabilityFragment = (observabilitySettings: BackendObservabilitySettings) => ({
532 ...Option.match(observabilitySettings.otlpTracesUrl, {
533 onNone: () => ({}),
534 onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),
535 }),
536 ...Option.match(observabilitySettings.otlpMetricsUrl, {
537 onNone: () => ({}),
538 onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),
539 }),
540 ...Option.match(observabilitySettings.otlpLogsUrl, {
541 onNone: () => ({}),
542 onSome: (otlpLogsUrl) => ({ otlpLogsUrl }),
543 }),
544});
545
546const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(
547 function* (
548 input: SharedBootstrapInput & {
549 readonly resourceMonitorPath: Option.Option<string>;
550 readonly cliPath: Option.Option<string>;
551 },
552 ): Effect.fn.Return<
553 DesktopBackendManager.DesktopBackendStartConfig,
554 never,
555 DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure
556 > {
557 const environment = yield* DesktopEnvironment.DesktopEnvironment;
558 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
559 const backendExposure = yield* serverExposure.backendConfig;
560
561 const bootstrap = {
562 mode: "desktop" as const,
563 noBrowser: true,
564 port: backendExposure.port,
565 t3Home: environment.baseDir,
566 host: backendExposure.bindHost,
567 desktopBootstrapToken: input.bootstrapToken,
568 desktopBootstrapSecret: input.bootstrapSecret,
569 // DesktopApp installs the shell environment into process.env before any
570 // backend starts, and this child extends it (extendEnv below). WSL gets
571 // a different environment, so its bootstrap never sets this.
572 shellEnvironmentPrepared: true,
573 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
574 tailscaleServePort: backendExposure.tailscaleServePort,
575 desktopTelemetryFd: 4,
576 desktopTelemetryControlFd: 5,
577 desktopBrowserFd: 6,
578 desktopBrowserControlFd: 7,
579 desktopTerminalFd: 8,
580 desktopTerminalControlFd: 9,
581 ...Option.match(input.resourceMonitorPath, {
582 onNone: () => ({}),
583 onSome: (resourceMonitorPath) => ({ resourceMonitorPath }),
584 }),
585 ...buildObservabilityFragment(input.observabilitySettings),
586 };
587
588 return {
589 executablePath: process.execPath,
590 // Packaged builds only, so a dev instance never shares the cache with the
591 // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE,
592 // so the setting does not leak into the provider and terminal processes
593 // the backend starts.
594 args: [
595 ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []),
596 environment.backendEntryPath,
597 "--bootstrap-fd",
598 "3",
599 ],
600 entryPath: environment.backendEntryPath,
601 cwd: environment.backendCwd,
602 env: {
603 ...backendChildEnvPatch(),
604 ELECTRON_RUN_AS_NODE: "1",
605 T3CODE_VERSION_OVERRIDE: environment.isPackaged ? environment.appVersion : undefined,
606 // The server names this launcher in commands it asks a person to run.
607 T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),
608 },
609 // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.).
610 extendEnv: true,
611 bootstrap,
612 bootstrapDelivery: "fd3",
613 httpBaseUrl: backendExposure.httpBaseUrl,
614 captureOutput: true,
615 preflightFailure: Option.none(),
616 } satisfies DesktopBackendManager.DesktopBackendStartConfig;
617 },
618);
619
620const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* (
621 input: SharedBootstrapInput & {
622 readonly port: number;
623 readonly distro: string | null;
624 },
625): Effect.fn.Return<
626 DesktopBackendManager.DesktopBackendStartConfig,
627 never,
628 | DesktopEnvironment.DesktopEnvironment
629 | DesktopWslEnvironment.DesktopWslEnvironment
630 | DesktopWslServerTree.DesktopWslServerTree
631 | FileSystem.FileSystem
632> {
633 const environment = yield* DesktopEnvironment.DesktopEnvironment;
634 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
635 const fileSystem = yield* FileSystem.FileSystem;
636
637 // Bind to 0.0.0.0 inside WSL so the backend is reachable both via
638 // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
639 // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from
640 // Windows. wslhost forwarding is unreliable on some Windows hosts:
641 // the desktop's readiness probe and the renderer's saved-env-style
642 // fetch both saw "Failed to fetch" when the backend only bound to
643 // 127.0.0.1 inside WSL. Binding to 0.0.0.0 plus advertising the
644 // WSL IP as the renderer-visible URL avoids that dependency.
645 // Security-wise this is acceptable for the local-only WSL backend:
646 // the network it exposes on is the WSL-vEthernet network, not the
647 // LAN; the primary owns LAN exposure when the user opts in.
648 const wslBindHost = "0.0.0.0";
649
650 const bootstrap = {
651 mode: "desktop" as const,
652 noBrowser: true,
653 port: input.port,
654 // Omit t3Home so the Linux backend uses its own home dir instead of
655 // the Windows-side baseDir (which would be a /mnt/c path and share
656 // the SQLite file with the primary).
657 host: wslBindHost,
658 desktopBootstrapToken: input.bootstrapToken,
659 desktopBootstrapSecret: input.bootstrapSecret,
660 // PortSchema rejects 0, so when tailscale serve is disabled we still
661 // need a valid number in this slot. The backend reads tailscaleServePort
662 // only when tailscaleServeEnabled is true, so the actual value here is
663 // inert.
664 tailscaleServeEnabled: false,
665 tailscaleServePort: 443,
666 // The packaged sidecar is a Windows executable and cannot run inside the
667 // Linux WSL backend. Keep the field absent instead of passing an unusable
668 // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable.
669 // See docs/internals/resource-telemetry.md.
670 ...buildObservabilityFragment(input.observabilitySettings),
671 };
672
673 // The archive is the primary packaged WSL path: it installs directly into
674 // the distro's ext4 filesystem. The server.asar extraction service is only
675 // consulted lazily if the archive is unavailable or cannot be staged.
676 const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME);
677 const archiveHashPath = environment.path.join(
678 environment.resourcesPath,
679 WSL_RUNTIME_ARCHIVE_HASH_NAME,
680 );
681
682 const hasArchive = environment.isPackaged
683 ? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false))
684 : false;
685 const archiveHash = hasArchive
686 ? yield* fileSystem.readFileString(archiveHashPath).pipe(
687 Effect.map(parseWslRuntimeArchiveHash),
688 Effect.orElseSucceed(() => null),
689 )
690 : null;
691 if (hasArchive && archiveHash === null) {
692 yield* Effect.logWarning(
693 "Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.",
694 { hashPath: archiveHashPath },
695 );
696 }
697
698 const preflight = yield* runWslPreflight({
699 distro: input.distro,
700 runtimeArchive:
701 archiveHash === null
702 ? null
703 : {
704 windowsPath: archivePath,
705 // The verified archive bytes are the cache identity. Release builds
706 // embed the release version and pnpm install metadata, so the
707 // archive changes on every update even when application logic does
708 // not. Later launches of that update still reuse this directory.
709 runtimeId: `sha256-${archiveHash}`,
710 sha256: archiveHash,
711 },
712 // Packaged builds run the self-contained Linux runtime and, on fallback,
713 // whatever Linux node-pty the mounted tree carries, so the WSL backend never
714 // needs a compiler, node-gyp, or network on first launch. Compiling from
715 // source is a dev-only convenience: a checkout has no Linux binary, and
716 // developers have the toolchain. In packaged builds we instead surface a
717 // clear diagnostic if the binary can't load (unsupported arch/distro),
718 // rather than silently dropping into a fragile runtime build.
719 allowBuild: !environment.isPackaged,
720 });
721
722 // Every operation after preflight uses the same concrete distro. In
723 // default-tracking mode this closes the race where the system default
724 // changes between probing and spawning the backend.
725 const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null;
726 const distroForConfig = runningDistro ?? input.distro;
727
728 // Resolve the selected distro's IPv4 address. In mirrored mode the distro
729 // reports a host interface, so use loopback instead; a failed probe also
730 // falls back to loopback and preserves the previous behavior.
731 const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig);
732 const usesSharedNetworkStack = Option.match(distroIp, {
733 onNone: () => false,
734 onSome: (ip) => isLocalHostIpv4(ip),
735 });
736 const rendererHost = usesSharedNetworkStack
737 ? "127.0.0.1"
738 : Option.getOrElse(distroIp, () => "127.0.0.1");
739 const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`);
740
741 const distroArgs = distroForConfig ? ["-d", distroForConfig] : [];
742 const forwardedEnv: Record<string, string> = {};
743 const forwardedEnvNames: string[] = [];
744 for (const name of WSL_FORWARDED_ENV_NAMES) {
745 const value = process.env[name];
746 if (value !== undefined && value.length > 0) {
747 forwardedEnv[name] = value;
748 forwardedEnvNames.push(name);
749 }
750 }
751
752 // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner
753 // exports the Windows-side base dir for the primary; if it leaks into
754 // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via
755 // /mnt/c, which means both backends read/write the same database and
756 // their env-ids collide).
757 const parentEnvWithoutT3Home: Record<string, string | undefined> = {};
758 for (const [key, value] of Object.entries(process.env)) {
759 if (key === "T3CODE_HOME") continue;
760 parentEnvWithoutT3Home[key] = value;
761 }
762 const wslEnv = mergeWslEnv(parentEnvWithoutT3Home.WSLENV, forwardedEnvNames);
763
764 const baseConfig = {
765 executablePath: "wsl.exe",
766 entryPath:
767 preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath,
768 cwd: environment.backendCwd,
769 env: {
770 ...parentEnvWithoutT3Home,
771 ...backendChildEnvPatch(),
772 ...forwardedEnv,
773 ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}),
774 },
775 // env is already a complete process.env minus T3CODE_HOME; pass it
776 // verbatim instead of letting the spawner re-merge process.env on top.
777 extendEnv: false,
778 bootstrap,
779 bootstrapDelivery: "stdin" as const,
780 httpBaseUrl,
781 captureOutput: true,
782 ...(runningDistro !== null ? { runningDistro } : {}),
783 };
784
785 // Forward the dev-server URL as an explicit CLI flag so the WSL backend's
786 // config resolution lands in dev/ instead of userdata/. The packaged build
787 // leaves devServerUrl as None.
788 const devUrlArgs = Option.match(environment.devServerUrl, {
789 onNone: () => [] as ReadonlyArray<string>,
790 onSome: (url) => ["--dev-url", url.href],
791 });
792
793 if (preflight._tag === "Failed") {
794 const retryLimit =
795 preflight.retryLimit ?? (preflight.fatal ? undefined : WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT);
796 return {
797 ...baseConfig,
798 args: [...distroArgs, "--", "node", "--version"],
799 preflightFailure: Option.some({
800 reason: preflight.reason,
801 fatal: preflight.fatal,
802 ...(retryLimit === undefined ? {} : { retryLimit }),
803 }),
804 } satisfies DesktopBackendManager.DesktopBackendStartConfig;
805 }
806
807 // The WSL server spawns commands its providers reference by name — `npm`/`npx`
808 // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those
809 // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on
810 // the process PATH, so `npm install -g ...` fails with NotFound. Pass the
811 // user PATH entries captured by the preflight. Every dynamic value is a
812 // separate argv entry under `wsl.exe --exec`; no shell command is involved,
813 // so Windows cannot mangle nested quotes and stdin remains reserved for the
814 // bootstrap envelope. A node-script runtime additionally leads with the
815 // probed Node's bin dir so the server cannot pick up a different node than
816 // the one node-pty was probed with.
817 const runtime = preflight.runtime;
818 const launchPath =
819 runtime.kind === "executable"
820 ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`
821 : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`;
822 const command =
823 runtime.kind === "executable"
824 ? [runtime.entryPath]
825 : [runtime.nodePath, runtime.linuxEntryPath];
826
827 return {
828 ...baseConfig,
829 args: [
830 ...distroArgs,
831 "--exec",
832 "env",
833 `PATH=${launchPath}`,
834 ...command,
835 "--bootstrap-fd",
836 "0",
837 ...devUrlArgs,
838 ],
839 preflightFailure: Option.none(),
840 ...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }),
841 } satisfies DesktopBackendManager.DesktopBackendStartConfig;
842});
843
844/** @public Service construction is part of the canonical Effect module API. */
845export const make = Effect.gen(function* () {
846 const environment = yield* DesktopEnvironment.DesktopEnvironment;
847 const fileSystem = yield* FileSystem.FileSystem;
848 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
849 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
850 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
851 const settings = yield* DesktopAppSettings.DesktopAppSettings;
852 const crypto = yield* Crypto.Crypto;
853 // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic.
854 // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can
855 // resolve concurrently; with a plain Ref both could observe None, generate
856 // distinct secrets, and one would overwrite the other — leaving the two
857 // backends deriving mismatched tokens and breaking the shared-token
858 // invariant the renderer relies on. modifyEffect serializes the whole
859 // get-or-create so the first caller wins and the rest reuse its secret.
860 const secretRef = yield* SynchronizedRef.make(Option.none<string>());
861 const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) =>
862 Option.match(current, {
863 onSome: (secret) => Effect.succeed([secret, current] as const),
864 onNone: () =>
865 crypto.randomBytes(32).pipe(
866 Effect.map((bytes) => {
867 const secret = Hex.encode(bytes);
868 return [secret, Option.some(secret)] as const;
869 }),
870 ),
871 }),
872 );
873
874 // Both resolvers share the same bootstrap token: the renderer holds a
875 // single token and uses it against whichever backend it's currently
876 // talking to. Observability settings get re-read each resolve so a
877 // hot-swap of the server-settings file is picked up on the next
878 // restart cycle without having to bounce the desktop process.
879 const sharedInputs = Effect.gen(function* () {
880 const bootstrapSecret = yield* getOrCreateBootstrapSecret;
881 const bootstrapToken = currentDesktopBootstrapToken(
882 bootstrapSecret,
883 yield* Clock.currentTimeMillis,
884 );
885 const observabilitySettings = yield* readBackendObservabilitySettings.pipe(
886 Effect.provideService(FileSystem.FileSystem, fileSystem),
887 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
888 );
889 return {
890 bootstrapToken,
891 bootstrapSecret,
892 observabilitySettings,
893 } satisfies SharedBootstrapInput;
894 });
895
896 const buildWslPrimaryConfig = Effect.gen(function* () {
897 // wsl-only mode pipes the WSL backend through the same port the
898 // Windows primary would normally take. That way the renderer
899 // still loads from the local-only endpoint advertised by
900 // DesktopServerExposure, and primary-aware code paths (cookie
901 // auth, the env switcher's "primary" id) keep working without
902 // a parallel "secondary" registration.
903 const backendExposure = yield* serverExposure.backendConfig;
904 const persistedSettings = yield* settings.get;
905 const shared = yield* sharedInputs;
906 yield* wslEnvironment.preWarm(persistedSettings.wslDistro);
907 return yield* resolveWslStartConfig({
908 ...shared,
909 port: backendExposure.port,
910 distro: persistedSettings.wslDistro,
911 }).pipe(
912 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
913 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
914 Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
915 Effect.provideService(FileSystem.FileSystem, fileSystem),
916 );
917 });
918
919 const buildWindowsPrimaryConfig = Effect.gen(function* () {
920 const shared = yield* sharedInputs;
921 const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe(
922 Effect.provideService(FileSystem.FileSystem, fileSystem),
923 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
924 );
925 const cliPath = yield* DesktopCliShim.install.pipe(
926 Effect.provideService(FileSystem.FileSystem, fileSystem),
927 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
928 );
929 return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe(
930 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
931 Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
932 );
933 });
934
935 // Single source of truth for what the primary actually runs as. Both
936 // the start-config dispatch and the renderer-facing label derive from
937 // this, so they can't disagree — e.g. the label reading "WSL" while the
938 // config silently fell back to Windows because WSL is unavailable.
939 // Dispatch happens at resolve time so toggling wsl-only between restarts
940 // is picked up on the next start cycle (the pool's primary instance is
941 // created once at layer init, but configResolve fires on each restart).
942 const describePrimary = Effect.gen(function* () {
943 const persistedSettings = yield* settings.get;
944 const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled;
945 // Only honor wsl-only when WSL is actually usable. If the user
946 // persisted wsl-only but WSL has since become unavailable (wsl.exe
947 // removed, no distro), fall back to the Windows primary instead of
948 // looping forever on preflight failures: the Connections backend
949 // control is hidden while WSL is unavailable, so a stuck WSL primary
950 // would otherwise leave no in-app way back to Windows.
951 const useWsl = wslRequested && (yield* wslEnvironment.isAvailable);
952 return { useWsl, wslRequested, distro: persistedSettings.wslDistro };
953 });
954
955 const currentBootstrapToken = Effect.gen(function* () {
956 const secret = yield* getOrCreateBootstrapSecret;
957 return currentDesktopBootstrapToken(secret, yield* Clock.currentTimeMillis);
958 });
959
960 return DesktopBackendConfiguration.of({
961 currentBootstrapToken,
962 resolvePrimary: Effect.gen(function* () {
963 const { useWsl, wslRequested } = yield* describePrimary;
964 if (useWsl) {
965 return yield* buildWslPrimaryConfig;
966 }
967 if (wslRequested) {
968 yield* Effect.logWarning(
969 "WSL-only backend requested but WSL is unavailable; starting the Windows primary instead.",
970 );
971 }
972 return yield* buildWindowsPrimaryConfig;
973 }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimary")),
974 resolvePrimaryLabel: Effect.gen(function* () {
975 const { useWsl, distro } = yield* describePrimary;
976 if (!useWsl) {
977 return environment.platform === "win32" ? "Windows" : "Local environment";
978 }
979 return distro ? `WSL (${distro})` : "WSL";
980 }).pipe(Effect.withSpan("desktop.backendConfiguration.resolvePrimaryLabel")),
981 resolveWsl: (input) =>
982 Effect.gen(function* () {
983 const shared = yield* sharedInputs;
984 return yield* resolveWslStartConfig({ ...shared, ...input }).pipe(
985 Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
986 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
987 Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree),
988 Effect.provideService(FileSystem.FileSystem, fileSystem),
989 );
990 }).pipe(
991 Effect.withSpan("desktop.backendConfiguration.resolveWsl", {
992 attributes: { port: input.port, distro: input.distro ?? null },
993 }),
994 ),
995 });
996});
997
998export const layer = Layer.effect(DesktopBackendConfiguration, make);