authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-09 23:08:57-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:08:57-05:00
log03bd839554c9da27aae37b027bcdb2fa9896af09
tree25da84173d3f061de3b8cdf2180438ecb244af18
parentaacefcd7e92a784c179b3ca2473a7608f783784e
signature Signed by PGP key B5690EEEBB952194

perf(desktop): reuse the prepared shell environment in the local backend (#17384)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

11 files changed, 304 insertions(+), 66 deletions(-)

apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+15
...@@ -325,6 +325,21 @@ describe("DesktopBackendConfiguration", () => {...@@ -325,6 +325,21 @@ describe("DesktopBackendConfiguration", () => {
325 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),325 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
326 );326 );
327327
328 it.effect("only the local primary bootstrap claims the prepared shell environment", () =>
329 withHarness(
330 Effect.gen(function* () {
331 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
332
333 const primary = yield* configuration.resolvePrimary;
334 const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null });
335
336 assert.equal(primary.bootstrap.shellEnvironmentPrepared, true);
337 // A WSL server runs on its own Linux user environment and must hydrate it.
338 assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared);
339 }),
340 ),
341 );
342
328 it.effect("resolveWsl reuses the primary's bootstrap token", () =>343 it.effect("resolveWsl reuses the primary's bootstrap token", () =>
329 withHarness(344 withHarness(
330 Effect.gen(function* () {345 Effect.gen(function* () {
apps/desktop/src/backend/DesktopBackendConfiguration.ts+4
...@@ -567,6 +567,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv...@@ -567,6 +567,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
567 host: backendExposure.bindHost,567 host: backendExposure.bindHost,
568 desktopBootstrapToken: input.bootstrapToken,568 desktopBootstrapToken: input.bootstrapToken,
569 desktopBootstrapSecret: input.bootstrapSecret,569 desktopBootstrapSecret: input.bootstrapSecret,
570 // DesktopApp installs the shell environment into process.env before any
571 // backend starts, and this child extends it (extendEnv below). WSL gets
572 // a different environment, so its bootstrap never sets this.
573 shellEnvironmentPrepared: true,
570 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,574 tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
571 tailscaleServePort: backendExposure.tailscaleServePort,575 tailscaleServePort: backendExposure.tailscaleServePort,
572 desktopTelemetryFd: 4,576 desktopTelemetryFd: 4,
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+98-9
...@@ -9,10 +9,14 @@ import * as Sink from "effect/Sink";...@@ -9,10 +9,14 @@ import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import * as NodeOS from "node:os";
13import { vi } from "vite-plus/test";
1214
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";16import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
1517
18vi.mock("node:os", { spy: true });
19
16const textEncoder = new TextEncoder();20const textEncoder = new TextEncoder();
1721
18const isDesktopShellEnvironmentCommandError = Schema.is(22const isDesktopShellEnvironmentCommandError = Schema.is(
...@@ -29,14 +33,14 @@ function envOutput(values: Readonly<Record<string, string>>): string {...@@ -29,14 +33,14 @@ function envOutput(values: Readonly<Record<string, string>>): string {
29 .join("\n");33 .join("\n");
30}34}
3135
32function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle {36function makeProcess(output: string, exitCode = 0): ChildProcessSpawner.ChildProcessHandle {
33 const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output));37 const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output));
34 return ChildProcessSpawner.makeHandle({38 return ChildProcessSpawner.makeHandle({
35 pid: ChildProcessSpawner.ProcessId(123),39 pid: ChildProcessSpawner.ProcessId(123),
36 stdout,40 stdout,
37 stderr: Stream.empty,41 stderr: Stream.empty,
38 all: stdout,42 all: stdout,
39 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),43 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
40 isRunning: Effect.succeed(false),44 isRunning: Effect.succeed(false),
41 kill: () => Effect.void,45 kill: () => Effect.void,
42 stdin: Sink.drain,46 stdin: Sink.drain,
...@@ -67,7 +71,10 @@ function withProcessEnv<A, E, R>(...@@ -67,7 +71,10 @@ function withProcessEnv<A, E, R>(
67function runShellEnvironment(input: {71function runShellEnvironment(input: {
68 readonly env: NodeJS.ProcessEnv;72 readonly env: NodeJS.ProcessEnv;
69 readonly platform: NodeJS.Platform;73 readonly platform: NodeJS.Platform;
70 readonly handler: (command: ChildProcess.Command) => string;74 readonly userShell?: string;
75 readonly handler: (
76 command: ChildProcess.Command,
77 ) => string | { output: string; exitCode: number };
71 readonly failure?: PlatformError.PlatformError;78 readonly failure?: PlatformError.PlatformError;
72}) {79}) {
73 const layerEnvironment = Layer.succeed(80 const layerEnvironment = Layer.succeed(
...@@ -78,11 +85,15 @@ function runShellEnvironment(input: {...@@ -78,11 +85,15 @@ function runShellEnvironment(input: {
78 );85 );
79 const layerSpawner = Layer.succeed(86 const layerSpawner = Layer.succeed(
80 ChildProcessSpawner.ChildProcessSpawner,87 ChildProcessSpawner.ChildProcessSpawner,
81 ChildProcessSpawner.make((command) =>88 ChildProcessSpawner.make((command) => {
82 input.failure === undefined89 if (input.failure !== undefined) return Effect.fail(input.failure);
83 ? Effect.succeed(makeProcess(input.handler(command)))90 const result = input.handler(command);
84 : Effect.fail(input.failure),91 return Effect.succeed(
85 ),92 typeof result === "string"
93 ? makeProcess(result)
94 : makeProcess(result.output, result.exitCode),
95 );
96 }),
86 );97 );
8798
88 const program = Effect.gen(function* () {99 const program = Effect.gen(function* () {
...@@ -96,10 +107,88 @@ function runShellEnvironment(input: {...@@ -96,10 +107,88 @@ function runShellEnvironment(input: {
96 ),107 ),
97 );108 );
98109
99 return withProcessEnv(input.env, program);110 return Effect.acquireUseRelease(
111 Effect.sync(() =>
112 vi.spyOn(NodeOS, "userInfo").mockReturnValue({
113 ...NodeOS.userInfo(),
114 shell: input.userShell ?? null,
115 }),
116 ),
117 () => withProcessEnv(input.env, program),
118 (spy) => Effect.sync(() => spy.mockRestore()),
119 );
100}120}
101121
102describe("DesktopShellEnvironment", () => {122describe("DesktopShellEnvironment", () => {
123 it.effect("rejects PATH output from a failed shell and tries the next candidate", () =>
124 Effect.gen(function* () {
125 const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" };
126 const commands: string[] = [];
127
128 yield* runShellEnvironment({
129 env,
130 platform: "linux",
131 userShell: "/usr/local/bin/fish",
132 handler: (command) => {
133 if (command._tag !== "StandardCommand") return "";
134 commands.push(command.command);
135 return command.command === "/bin/failing"
136 ? { output: envOutput({ PATH: "/failed/bin" }), exitCode: 7 }
137 : envOutput({ PATH: "/home/test/.local/bin:/usr/bin" });
138 },
139 }).pipe(Effect.provide(Logger.layer([])));
140
141 assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]);
142 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
143 }),
144 );
145
146 it.effect.each(["darwin", "linux"] as const)(
147 "uses the account shell when SHELL is missing on %s",
148 (platform) =>
149 Effect.gen(function* () {
150 const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
151 const commands: string[] = [];
152
153 yield* runShellEnvironment({
154 env,
155 platform,
156 userShell: "/usr/local/bin/fish",
157 handler: (command) => {
158 if (command._tag !== "StandardCommand") return "";
159 commands.push(command.command);
160 return envOutput({ PATH: "/home/test/.local/bin:/usr/bin" });
161 },
162 });
163
164 assert.deepEqual(commands, ["/usr/local/bin/fish"]);
165 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
166 }),
167 );
168
169 it.effect("tries the account shell before the platform fallback when SHELL has no PATH", () =>
170 Effect.gen(function* () {
171 const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", PATH: "/usr/bin" };
172 const commands: string[] = [];
173
174 yield* runShellEnvironment({
175 env,
176 platform: "linux",
177 userShell: "/usr/local/bin/fish",
178 handler: (command) => {
179 if (command._tag !== "StandardCommand") return "";
180 commands.push(command.command);
181 return command.command === "/usr/local/bin/fish"
182 ? envOutput({ PATH: "/home/test/.local/bin:/usr/bin" })
183 : "";
184 },
185 });
186
187 assert.deepEqual(commands, ["/bin/missing", "/usr/local/bin/fish"]);
188 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
189 }),
190 );
191
103 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>192 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>
104 Effect.gen(function* () {193 Effect.gen(function* () {
105 const env: NodeJS.ProcessEnv = {194 const env: NodeJS.ProcessEnv = {
apps/desktop/src/shell/DesktopShellEnvironment.ts+46-49
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { listLoginShellCandidates } from "@t3tools/shared/shell";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -5,6 +6,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -5,6 +6,7 @@ import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
8import * as ChildProcess from "effect/process/ChildProcess";10import * as ChildProcess from "effect/process/ChildProcess";
9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1012
...@@ -15,7 +17,6 @@ type EnvironmentPatch = Record<string, string>;...@@ -15,7 +17,6 @@ type EnvironmentPatch = Record<string, string>;
15interface ShellEnvironmentConfig {17interface ShellEnvironmentConfig {
16 readonly env: NodeJS.ProcessEnv;18 readonly env: NodeJS.ProcessEnv;
17 readonly platform: NodeJS.Platform;19 readonly platform: NodeJS.Platform;
18 readonly userShell: Option.Option<string>;
19}20}
2021
21interface WindowsProbeOptions {22interface WindowsProbeOptions {
...@@ -157,25 +158,6 @@ const mergePaths = (...@@ -157,25 +158,6 @@ const mergePaths = (
157 return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();158 return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();
158};159};
159160
160const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray<string> => {
161 const fallback =
162 config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : "";
163 const seen = new Set<string>();
164 const candidates: string[] = [];
165
166 for (const candidate of [
167 trimNonEmpty(config.env.SHELL),
168 config.userShell,
169 trimNonEmpty(fallback),
170 ]) {
171 if (Option.isNone(candidate) || seen.has(candidate.value)) continue;
172 seen.add(candidate.value);
173 candidates.push(candidate.value);
174 }
175
176 return candidates;
177};
178
179const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [161const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
180 ...trimNonEmpty(env.APPDATA).pipe(162 ...trimNonEmpty(env.APPDATA).pipe(
181 Option.match({163 Option.match({
...@@ -267,33 +249,49 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(...@@ -267,33 +249,49 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
267 readonly shell?: boolean;249 readonly shell?: boolean;
268}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {250}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
269 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;251 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
270 const output = yield* spawner252 const output = yield* Effect.scoped(
271 .string(253 Effect.gen(function* () {
272 ChildProcess.make(input.command, input.args, {254 const child = yield* spawner.spawn(
273 shell: input.shell ?? false,255 ChildProcess.make(input.command, input.args, {
274 stdin: "ignore",256 shell: input.shell ?? false,
275 stdout: "pipe",257 stdin: "ignore",
276 stderr: "pipe",258 stdout: "pipe",
277 killSignal: "SIGTERM",259 stderr: "pipe",
278 forceKillAfter: PROCESS_TERMINATE_GRACE,260 killSignal: "SIGTERM",
279 }),261 forceKillAfter: PROCESS_TERMINATE_GRACE,
280 )262 }),
281 .pipe(263 );
282 Effect.mapError(264 return yield* Effect.all([Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode], {
283 (cause) =>265 concurrency: 2,
284 new DesktopShellEnvironmentCommandError({266 });
285 probe: input.probe,267 }),
286 executable: executableName(input.command),268 ).pipe(
287 argumentCount: input.args.length,269 Effect.mapError(
288 cause,270 (cause) =>
289 }),271 new DesktopShellEnvironmentCommandError({
290 ),272 probe: input.probe,
291 Effect.catchTags({273 executable: executableName(input.command),
292 DesktopShellEnvironmentCommandError: (error) =>274 argumentCount: input.args.length,
293 logShellEnvironmentCommandError(error).pipe(Effect.as("")),275 cause,
294 }),276 }),
295 Effect.timeoutOption(input.timeout),277 ),
296 );278 Effect.filterOrFail(
279 ([, exitCode]) => exitCode === 0,
280 ([, exitCode]) =>
281 new DesktopShellEnvironmentCommandError({
282 probe: input.probe,
283 executable: executableName(input.command),
284 argumentCount: input.args.length,
285 cause: { exitCode },
286 }),
287 ),
288 Effect.map(([stdout]) => stdout),
289 Effect.catchTags({
290 DesktopShellEnvironmentCommandError: (error) =>
291 logShellEnvironmentCommandError(error).pipe(Effect.as("")),
292 }),
293 Effect.timeoutOption(input.timeout),
294 );
297 if (Option.isSome(output)) {295 if (Option.isSome(output)) {
298 return output.value;296 return output.value;
299 }297 }
...@@ -406,7 +404,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix...@@ -406,7 +404,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
406 const fileSystem = yield* FileSystem.FileSystem;404 const fileSystem = yield* FileSystem.FileSystem;
407 const shellEnvironment: EnvironmentPatch = {};405 const shellEnvironment: EnvironmentPatch = {};
408406
409 for (const shell of listLoginShellCandidates(config)) {407 for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) {
410 Object.assign(408 Object.assign(
411 shellEnvironment,409 shellEnvironment,
412 yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),410 yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),
...@@ -521,7 +519,6 @@ export const make = Effect.gen(function* () {...@@ -521,7 +519,6 @@ export const make = Effect.gen(function* () {
521 installShellEnvironment({519 installShellEnvironment({
522 env: process.env,520 env: process.env,
523 platform: environment.platform,521 platform: environment.platform,
524 userShell: Option.none(),
525 }).pipe(522 }).pipe(
526 Effect.provideService(FileSystem.FileSystem, fileSystem),523 Effect.provideService(FileSystem.FileSystem, fileSystem),
527 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),524 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
apps/server/src/cli/config.test.ts+45
...@@ -26,6 +26,21 @@ import { resolveServerConfig } from "./config.ts";...@@ -26,6 +26,21 @@ import { resolveServerConfig } from "./config.ts";
26const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>26const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
27 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });27 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
2828
29const minimalDesktopFlags = (baseDir: string) => ({
30 mode: Option.some("desktop" as const),
31 port: Option.some(4888),
32 host: Option.none<string>(),
33 baseDir: Option.some(baseDir),
34 cwd: Option.none<string>(),
35 devUrl: Option.none<URL>(),
36 noBrowser: Option.none<boolean>(),
37 bootstrapFd: Option.none<number>(),
38 autoBootstrapProjectFromCwd: Option.none<boolean>(),
39 logWebSocketEvents: Option.none<boolean>(),
40 tailscaleServeEnabled: Option.none<boolean>(),
41 tailscaleServePort: Option.none<number>(),
42});
43
29const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));44const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
30const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));45const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
3146
...@@ -515,6 +530,36 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -515,6 +530,36 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
515 }),530 }),
516 );531 );
517532
533 it.effect("carries the desktop's shell environment handoff only when the envelope sets it", () =>
534 Effect.gen(function* () {
535 const fs = yield* FileSystem.FileSystem;
536 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-shell-env-" });
537 const resolveWith = Effect.fn(function* (overrides: Partial<DesktopBackendBootstrapValue>) {
538 const fd = yield* openBootstrapFd(makeDesktopBootstrap(overrides));
539 return yield* resolveServerConfig(
540 {
541 ...minimalDesktopFlags(baseDir),
542 bootstrapFd: Option.some(fd),
543 },
544 Option.none(),
545 ).pipe(
546 Effect.provide(
547 Layer.mergeAll(
548 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
549 NetService.layer,
550 ),
551 ),
552 );
553 });
554
555 assert.equal((yield* resolveWith({})).shellEnvironmentPrepared, undefined);
556 assert.equal(
557 (yield* resolveWith({ shellEnvironmentPrepared: true })).shellEnvironmentPrepared,
558 true,
559 );
560 }),
561 );
562
518 it.effect("creates derived runtime directories during config resolution", () =>563 it.effect("creates derived runtime directories during config resolution", () =>
519 Effect.gen(function* () {564 Effect.gen(function* () {
520 const fs = yield* FileSystem.FileSystem;565 const fs = yield* FileSystem.FileSystem;
apps/server/src/cli/config.ts+2
...@@ -360,6 +360,7 @@ export const resolveServerConfig = (...@@ -360,6 +360,7 @@ export const resolveServerConfig = (
360 );360 );
361 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;361 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;362 const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret;
363 const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared;
363 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;364 const desktopTelemetryFd = bootstrap?.desktopTelemetryFd;
364 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;365 const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd;
365 const desktopBrowserFd = bootstrap?.desktopBrowserFd;366 const desktopBrowserFd = bootstrap?.desktopBrowserFd;
...@@ -468,6 +469,7 @@ export const resolveServerConfig = (...@@ -468,6 +469,7 @@ export const resolveServerConfig = (
468 startupPresentation,469 startupPresentation,
469 desktopBootstrapToken,470 desktopBootstrapToken,
470 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),471 ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }),
472 ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }),
471 desktopTelemetryFd,473 desktopTelemetryFd,
472 desktopTelemetryControlFd,474 desktopTelemetryControlFd,
473 desktopBrowserFd,475 desktopBrowserFd,
apps/server/src/config.ts+1
...@@ -95,6 +95,7 @@ export class ServerConfig extends Context.Service<...@@ -95,6 +95,7 @@ export class ServerConfig extends Context.Service<
95 readonly startupPresentation: StartupPresentation;95 readonly startupPresentation: StartupPresentation;
96 readonly desktopBootstrapToken: string | undefined;96 readonly desktopBootstrapToken: string | undefined;
97 readonly desktopBootstrapSecret?: string | undefined;97 readonly desktopBootstrapSecret?: string | undefined;
98 readonly shellEnvironmentPrepared?: boolean | undefined;
98 readonly desktopTelemetryFd?: number | undefined;99 readonly desktopTelemetryFd?: number | undefined;
99 readonly desktopTelemetryControlFd?: number | undefined;100 readonly desktopTelemetryControlFd?: number | undefined;
100 readonly desktopBrowserFd?: number | undefined;101 readonly desktopBrowserFd?: number | undefined;
apps/server/src/os-jank.test.ts+84-2
...@@ -1,7 +1,16 @@...@@ -1,7 +1,16 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { CommandAvailability, WindowsShellEnvironment } from "@t3tools/shared/shell";
5import * as Effect from "effect/Effect";
6// @effect-diagnostics-next-line nodeBuiltinImport:off -- Observe the existing synchronous shell probe without launching the user's login shell.
7import * as NodeChildProcess from "node:child_process";
1import * as NodeOS from "node:os";8import * as NodeOS from "node:os";
2import { assert, it } from "vite-plus/test";9import { assert, it, vi } from "vite-plus/test";
310
4import { hydratePosixHome } from "./os-jank.ts";11import { fixPath, hydratePosixHome } from "./os-jank.ts";
12
13vi.mock("node:child_process", { spy: true });
514
6it("hydrates HOME for minimal service environments from the user account", () => {15it("hydrates HOME for minimal service environments from the user account", () => {
7 const env: NodeJS.ProcessEnv = {};16 const env: NodeJS.ProcessEnv = {};
...@@ -38,3 +47,76 @@ it("preserves an explicitly configured HOME", () => {...@@ -38,3 +47,76 @@ it("preserves an explicitly configured HOME", () => {
3847
39 assert.equal(env.HOME, "/custom/home");48 assert.equal(env.HOME, "/custom/home");
40});49});
50
51effectIt.effect.each(["darwin", "linux"] as const)(
52 "keeps the prepared PATH and still hydrates HOME on %s",
53 (platform) =>
54 Effect.acquireUseRelease(
55 Effect.sync(() => vi.spyOn(NodeChildProcess, "execFileSync")),
56 (probe) =>
57 Effect.gen(function* () {
58 probe.mockImplementation(() => {
59 throw new Error("The prepared environment must not launch another shell");
60 });
61 const env: NodeJS.ProcessEnv = { PATH: "/prepared/bin:/usr/bin" };
62
63 yield* fixPath({ shellEnvironmentPrepared: true }).pipe(
64 Effect.provideService(HostProcess.Platform, platform),
65 Effect.provideService(HostProcess.Environment, env),
66 Effect.provide(NodeServices.layer),
67 );
68
69 assert.equal(probe.mock.calls.length, 0);
70 assert.equal(env.PATH, "/prepared/bin:/usr/bin");
71 assert.equal(env.HOME, NodeOS.userInfo().homedir);
72 }),
73 (probe) => Effect.sync(() => probe.mockRestore()),
74 ),
75);
76
77effectIt.effect("hydrates PATH when the desktop handoff is absent", () =>
78 Effect.acquireUseRelease(
79 Effect.sync(() =>
80 vi
81 .spyOn(NodeChildProcess, "execFileSync")
82 .mockReturnValue("__T3CODE_ENV_PATH_START__\n/opt/tools/bin\n__T3CODE_ENV_PATH_END__\n"),
83 ),
84 (probe) =>
85 Effect.gen(function* () {
86 const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", PATH: "/usr/bin" };
87
88 yield* fixPath().pipe(
89 Effect.provideService(HostProcess.Platform, "linux"),
90 Effect.provideService(HostProcess.Environment, env),
91 Effect.provide(NodeServices.layer),
92 );
93
94 assert.equal(probe.mock.calls.length, 1);
95 assert.equal(env.PATH, "/opt/tools/bin:/usr/bin");
96 }),
97 (probe) => Effect.sync(() => probe.mockRestore()),
98 ),
99);
100
101effectIt.effect("repairs Windows PATH even when the desktop marks it prepared", () =>
102 Effect.gen(function* () {
103 const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
104 const commands: string[] = [];
105
106 yield* fixPath({ shellEnvironmentPrepared: true }).pipe(
107 Effect.provideService(HostProcess.Platform, "win32"),
108 Effect.provideService(HostProcess.Environment, env),
109 Effect.provideService(WindowsShellEnvironment, () => ({ PATH: "C:\\Tools" })),
110 Effect.provideService(CommandAvailability, (command) =>
111 Effect.sync(() => {
112 commands.push(command);
113 return true;
114 }),
115 ),
116 Effect.provide(NodeServices.layer),
117 );
118
119 assert.deepEqual(commands, ["node"]);
120 assert.equal(env.PATH, "C:\\Tools;C:\\Windows");
121 }),
122);
apps/server/src/os-jank.ts+4-5
...@@ -48,11 +48,9 @@ export function hydratePosixHome(...@@ -48,11 +48,9 @@ export function hydratePosixHome(
48 }48 }
49}49}
5050
51export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return<51export const fixPath = Effect.fn("fixPath")(function* (options?: {
52 void,52 readonly shellEnvironmentPrepared?: boolean | undefined;
53 never,53}): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> {
54 FileSystem.FileSystem | Path.Path
55> {
56 const platform = yield* HostProcess.Platform;54 const platform = yield* HostProcess.Platform;
57 const env = yield* HostProcess.Environment;55 const env = yield* HostProcess.Environment;
5856
...@@ -82,6 +80,7 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return<...@@ -82,6 +80,7 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return<
82 }),80 }),
83 ),81 ),
84 );82 );
83 if (options?.shellEnvironmentPrepared === true) return;
85 yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe(84 yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe(
86 Effect.catchDefect((defect) =>85 Effect.catchDefect((defect) =>
87 Effect.sync(() => {86 Effect.sync(() => {
apps/server/src/server.ts+1-1
...@@ -741,7 +741,7 @@ const layerMakeServer = Layer.unwrap(...@@ -741,7 +741,7 @@ const layerMakeServer = Layer.unwrap(
741 const routesReady = yield* Deferred.make<void>();741 const routesReady = yield* Deferred.make<void>();
742 const layerLauncher = ServiceLauncherClient.layer;742 const layerLauncher = ServiceLauncherClient.layer;
743743
744 yield* fixPath();744 yield* fixPath({ shellEnvironmentPrepared: config.shellEnvironmentPrepared });
745745
746 const layerHttpListening = Layer.effectDiscard(746 const layerHttpListening = Layer.effectDiscard(
747 Effect.gen(function* () {747 Effect.gen(function* () {
packages/contracts/src/desktopBootstrap.ts+4
...@@ -17,6 +17,10 @@ export const DesktopBackendBootstrap = Schema.Struct({...@@ -17,6 +17,10 @@ export const DesktopBackendBootstrap = Schema.Struct({
17 // `desktopBootstrapToken` for its whole run. See17 // `desktopBootstrapToken` for its whole run. See
18 // `@t3tools/shared/desktopBootstrapToken`.18 // `@t3tools/shared/desktopBootstrapToken`.
19 desktopBootstrapSecret: Schema.optionalKey(Schema.String),19 desktopBootstrapSecret: Schema.optionalKey(Schema.String),
20 // Set only when the desktop already merged the user's shell environment into
21 // the environment it hands this backend. Absent means the backend hydrates
22 // PATH itself, which WSL and older desktops rely on.
23 shellEnvironmentPrepared: Schema.optionalKey(Schema.Boolean),
20 tailscaleServeEnabled: Schema.Boolean,24 tailscaleServeEnabled: Schema.Boolean,
21 tailscaleServePort: PortSchema,25 tailscaleServePort: PortSchema,
22 otlpTracesUrl: Schema.optional(Schema.String),26 otlpTracesUrl: Schema.optional(Schema.String),