From bdb3e46d931511aa8dfd3dbdd34a75d163139440 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 21:08:27 -0500 Subject: [PATCH 1/4] perf(desktop): skip duplicate shell PATH hydration in the prepared local backend The desktop merges the login shell environment into process.env before starting its local primary backend, then the server ran another synchronous interactive login shell through fixPath. The bootstrap now carries shellEnvironmentPrepared, which only the local primary sets, so the server skips PATH hydration there (HOME is still filled in). WSL, SSH, and standalone servers still hydrate their own environment. Standalone POSIX PATH capture now runs through ChildProcessSpawner with a 5s (login shell) and 2s (launchctl) timeout instead of execFileSync, so it no longer blocks the event loop. Co-Authored-By: Claude Sonnet 5.5 --- .../DesktopBackendConfiguration.test.ts | 15 + .../backend/DesktopBackendConfiguration.ts | 4 + apps/server/src/cli/config.test.ts | 45 +++ apps/server/src/cli/config.ts | 2 + apps/server/src/config.ts | 1 + apps/server/src/os-jank.test.ts | 129 +++++++- apps/server/src/os-jank.ts | 42 ++- apps/server/src/server.ts | 2 +- packages/contracts/src/desktopBootstrap.ts | 4 + packages/shared/src/shell.test.ts | 287 +++++++++++------- packages/shared/src/shell.ts | 103 ++++--- 11 files changed, 458 insertions(+), 176 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 3b282c1c1db9..090f6e0edf5e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -325,6 +325,21 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("only the local primary bootstrap claims the prepared shell environment", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + const primary = yield* configuration.resolvePrimary; + const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); + + assert.equal(primary.bootstrap.shellEnvironmentPrepared, true); + // A WSL server runs on its own Linux user environment and must hydrate it. + assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared); + }), + ), + ); + it.effect("resolveWsl reuses the primary's bootstrap token", () => withHarness( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index be923b47c5af..b306975beb24 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -567,6 +567,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, + // DesktopApp installs the shell environment into process.env before any + // backend starts, and this child extends it (extendEnv below). WSL gets + // a different environment, so its bootstrap never sets this. + shellEnvironmentPrepared: true, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, desktopTelemetryFd: 4, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 04f6c3ca7a8c..c0c032b192ef 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -26,6 +26,21 @@ import { resolveServerConfig } from "./config.ts"; const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); +const minimalDesktopFlags = (baseDir: string) => ({ + mode: Option.some("desktop" as const), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), +}); + const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -515,6 +530,36 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("carries the desktop's shell environment handoff only when the envelope sets it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-shell-env-" }); + const resolveWith = Effect.fn(function* (overrides: Partial) { + const fd = yield* openBootstrapFd(makeDesktopBootstrap(overrides)); + return yield* resolveServerConfig( + { + ...minimalDesktopFlags(baseDir), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + NetService.layer, + ), + ), + ); + }); + + assert.equal((yield* resolveWith({})).shellEnvironmentPrepared, undefined); + assert.equal( + (yield* resolveWith({ shellEnvironmentPrepared: true })).shellEnvironmentPrepared, + true, + ); + }), + ); + it.effect("creates derived runtime directories during config resolution", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 841962f0dc8e..be2dd5789627 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -360,6 +360,7 @@ export const resolveServerConfig = ( ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; + const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared; const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; const desktopBrowserFd = bootstrap?.desktopBrowserFd; @@ -468,6 +469,7 @@ export const resolveServerConfig = ( startupPresentation, desktopBootstrapToken, ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), + ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }), desktopTelemetryFd, desktopTelemetryControlFd, desktopBrowserFd, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 91a1775bdf22..ad2d182a3fad 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -95,6 +95,7 @@ export class ServerConfig extends Context.Service< readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; readonly desktopBootstrapSecret?: string | undefined; + readonly shellEnvironmentPrepared?: boolean | undefined; readonly desktopTelemetryFd?: number | undefined; readonly desktopTelemetryControlFd?: number | undefined; readonly desktopBrowserFd?: number | undefined; diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index a157efb665fa..62118a4257f2 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,7 +1,16 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it as effectIt } from "@effect/vitest"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeOS from "node:os"; -import { assert, it } from "vite-plus/test"; +import { it } from "vite-plus/test"; -import { hydratePosixHome } from "./os-jank.ts"; +import { fixPath, hydratePosixHome } from "./os-jank.ts"; it("hydrates HOME for minimal service environments from the user account", () => { const env: NodeJS.ProcessEnv = {}; @@ -38,3 +47,119 @@ it("preserves an explicitly configured HOME", () => { assert.equal(env.HOME, "/custom/home"); }); + +const textEncoder = new TextEncoder(); + +// Answers every login shell probe from `shells`; a shell it does not list fails +// to start. Returns the shells that were launched, in order. +const runFixPath = (input: { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; + readonly shells: Readonly>; + readonly shellEnvironmentPrepared?: boolean; +}) => + Effect.gen(function* () { + const launched: Array = []; + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") return Effect.die("unexpected command"); + launched.push(command.command); + const output = input.shells[command.command]; + if (output === undefined) { + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "ENOENT", + }), + ); + } + const stdout = Stream.make(textEncoder.encode(output)); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }), + ); + yield* fixPath({ shellEnvironmentPrepared: input.shellEnvironmentPrepared }).pipe( + Effect.provideService(HostProcessPlatform, input.platform), + Effect.provideService(HostProcessEnvironment, input.env), + Effect.provide(Layer.mergeAll(NodeServices.layer, layerSpawner)), + ); + return launched; + }); + +const shellPathOutput = (path: string) => + `__T3CODE_ENV_PATH_START__\n${path}\n__T3CODE_ENV_PATH_END__\n`; + +effectIt.effect("merges the login shell PATH when the environment was not prepared", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", HOME: "/home/test", PATH: "/usr/bin" }; + + const launched = yield* runFixPath({ + platform: "linux", + env, + shells: { "/bin/zsh": shellPathOutput("/opt/tools/bin:/usr/bin") }, + }); + + assert.deepEqual(launched, ["/bin/zsh"]); + assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); + }), +); + +effectIt.effect("tries the next login shell when one fails to start", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", HOME: "/home/test", PATH: "/usr/bin" }; + + const launched = yield* runFixPath({ + platform: "linux", + env, + shells: { "/bin/bash": shellPathOutput("/opt/tools/bin") }, + }); + + assert.equal(launched[0], "/bin/missing"); + assert.equal(launched.at(-1), "/bin/bash"); + assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); + }), +); + +effectIt.effect("keeps the inherited PATH when every login shell fails", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", HOME: "/home/test", PATH: "/usr/bin" }; + + yield* runFixPath({ platform: "linux", env, shells: {} }); + + assert.equal(env.PATH, "/usr/bin"); + }), +); + +effectIt.effect("skips every shell probe when the desktop prepared the environment", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/prepared/bin:/usr/bin" }; + + const launched = yield* runFixPath({ + platform: "darwin", + env, + shells: { "/bin/zsh": shellPathOutput("/other/bin") }, + shellEnvironmentPrepared: true, + }); + + assert.deepEqual(launched, []); + assert.equal(env.PATH, "/prepared/bin:/usr/bin"); + // HOME is not part of what the desktop prepares, so it is still filled in. + assert.equal(env.HOME, NodeOS.userInfo().homedir); + }), +); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..98bad2d638a3 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -9,6 +9,7 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { @@ -17,24 +18,30 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { +const hydratePosixPath = Effect.fn("hydratePosixPath")(function* ( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +) { let shellPath: string | undefined; for (const shell of listLoginShellCandidates(platform, env.SHELL)) { - try { - shellPath = readPathFromLoginShell(shell); - } catch (error) { - logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); - } - + shellPath = yield* readPathFromLoginShell(shell).pipe( + Effect.catch((error) => + Effect.sync(() => { + logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); + return undefined; + }), + ), + ); if (shellPath) break; } - const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; + const launchctlPath = + platform === "darwin" && !shellPath ? yield* readPathFromLaunchctl() : undefined; const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); if (mergedPath) { env.PATH = mergedPath; } -} +}); export function hydratePosixHome( env: NodeJS.ProcessEnv, @@ -48,15 +55,25 @@ export function hydratePosixHome( } } -export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< +/** + * Repairs the environment of a process launched without a login shell, such as + * a GUI app or a service. `shellEnvironmentPrepared` means the parent already + * merged the user's shell PATH into this process environment, so only the + * HOME fallback still runs. + */ +export const fixPath = Effect.fn("fixPath")(function* (options?: { + readonly shellEnvironmentPrepared?: boolean | undefined; +}): Effect.fn.Return< void, never, - FileSystem.FileSystem | Path.Path + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { const platform = yield* HostProcessPlatform; const env = yield* HostProcessEnvironment; + const shellEnvironmentPrepared = options?.shellEnvironmentPrepared === true; if (platform === "win32") { + if (shellEnvironmentPrepared) return; const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( Effect.catchDefect((defect) => Effect.sync(() => { @@ -82,7 +99,8 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< }), ), ); - yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( + if (shellEnvironmentPrepared) return; + yield* hydratePosixPath(env, platform).pipe( Effect.catchDefect((defect) => Effect.sync(() => { logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f91976f38ece..8676b7fdf3e9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -723,7 +723,7 @@ const layerMakeServer = Layer.unwrap( const routesReady = yield* Deferred.make(); const layerLauncher = ServiceLauncherClient.layer; - yield* fixPath(); + yield* fixPath({ shellEnvironmentPrepared: config.shellEnvironmentPrepared }); const layerHttpListening = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index 9a4670289cd0..a61379a3906e 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -17,6 +17,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ // `desktopBootstrapToken` for its whole run. See // `@t3tools/shared/desktopBootstrapToken`. desktopBootstrapSecret: Schema.optionalKey(Schema.String), + // Set only when the desktop already merged the user's shell environment into + // the environment it hands this backend. Absent means the backend hydrates + // PATH itself, which WSL and older desktops rely on. + shellEnvironmentPrepared: Schema.optionalKey(Schema.Boolean), tailscaleServeEnabled: Schema.Boolean, tailscaleServePort: PortSchema, otlpTracesUrl: Schema.optional(Schema.String), diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 4e27aa49fcfc..9c17ac882654 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,8 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -40,135 +47,189 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); +const textEncoder = new TextEncoder(); + +// A spawner that records each command and answers with the handler's output. +// A never-ending process stands in for a shell that hangs. +const makeSpawnerLayer = ( + handler: (command: ChildProcess.StandardCommand) => string | "hang" | "fail", + commands: Array = [], +) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") return Effect.die("unexpected command"); + commands.push(command); + const output = handler(command); + if (output === "fail") { + return Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "ENOENT", + }), + ); + } + const stdout = + output === "hang" + ? Stream.never + : output.length === 0 + ? Stream.empty + : Stream.make(textEncoder.encode(output)); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }), + ); + describe("readPathFromLoginShell", () => { - it("uses a shell-agnostic printenv PATH probe", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n"); - - expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b"); - expect(execFile).toHaveBeenCalledTimes(1); - - const firstCall = execFile.mock.calls[0] as - | [string, ReadonlyArray, { encoding: "utf8"; timeout: number }] - | undefined; - expect(firstCall).toBeDefined(); - if (!firstCall) { - throw new Error("Expected execFile to be called"); - } - - const [shell, args, options] = firstCall; - expect(shell).toBe("/opt/homebrew/bin/fish"); - expect(args).toHaveLength(2); - expect(args?.[0]).toBe("-ilc"); - expect(args?.[1]).toContain("printenv PATH || true"); - expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); - expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); - expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); - }); + effectIt.effect("uses a shell-agnostic printenv PATH probe", () => + Effect.gen(function* () { + const commands: Array = []; + + const path = yield* readPathFromLoginShell("/opt/homebrew/bin/fish").pipe( + Effect.provide( + makeSpawnerLayer( + () => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n", + commands, + ), + ), + ); + + expect(path).toBe("/a:/b"); + expect(commands).toHaveLength(1); + const [command] = commands; + expect(command?.command).toBe("/opt/homebrew/bin/fish"); + expect(command?.args).toHaveLength(2); + expect(command?.args[0]).toBe("-ilc"); + expect(command?.args[1]).toContain("printenv PATH || true"); + expect(command?.args[1]).toContain("__T3CODE_ENV_PATH_START__"); + expect(command?.args[1]).toContain("__T3CODE_ENV_PATH_END__"); + }), + ); + + effectIt.effect("fails when the shell cannot be started", () => + Effect.gen(function* () { + const error = yield* readPathFromLoginShell("/bin/missing").pipe( + Effect.provide(makeSpawnerLayer(() => "fail")), + Effect.flip, + ); + + expect(error._tag).toBe("PlatformError"); + }), + ); + + effectIt.effect("gives up on a shell that never exits after five seconds", () => + Effect.gen(function* () { + const fiber = yield* readPathFromLoginShell("/bin/zsh").pipe( + Effect.provide(makeSpawnerLayer(() => "hang")), + Effect.flip, + Effect.forkChild, + ); + + yield* TestClock.adjust("4999 millis"); + expect(fiber.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("1 millis"); + const error = yield* Fiber.join(fiber); + expect(error._tag).toBe("TimeoutError"); + }), + ); }); describe("readPathFromLaunchctl", () => { - it("returns a trimmed PATH value from launchctl", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => " /opt/homebrew/bin:/usr/bin \n"); + effectIt.effect("returns a trimmed PATH value from launchctl", () => + Effect.gen(function* () { + const commands: Array = []; - expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin"); - expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], { - encoding: "utf8", - timeout: 2000, - }); - }); + const path = yield* readPathFromLaunchctl().pipe( + Effect.provide(makeSpawnerLayer(() => " /opt/homebrew/bin:/usr/bin \n", commands)), + ); - it("returns undefined when launchctl is unavailable", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => { - throw new Error("spawn /bin/launchctl ENOENT"); - }); + expect(path).toBe("/opt/homebrew/bin:/usr/bin"); + expect(commands[0]?.command).toBe("/bin/launchctl"); + expect(commands[0]?.args).toEqual(["getenv", "PATH"]); + }), + ); - expect(readPathFromLaunchctl(execFile)).toBeUndefined(); - }); + effectIt.effect("returns undefined when launchctl is unavailable", () => + Effect.gen(function* () { + const path = yield* readPathFromLaunchctl().pipe( + Effect.provide(makeSpawnerLayer(() => "fail")), + ); + + expect(path).toBeUndefined(); + }), + ); }); describe("readEnvironmentFromLoginShell", () => { - it("extracts multiple environment variables from a login shell command", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => - [ - "__T3CODE_ENV_PATH_START__", - "/a:/b", - "__T3CODE_ENV_PATH_END__", - "__T3CODE_ENV_SSH_AUTH_SOCK_START__", - "/tmp/secretive.sock", - "__T3CODE_ENV_SSH_AUTH_SOCK_END__", - ].join("\n"), + const readFrom = (output: string, names: ReadonlyArray) => + readEnvironmentFromLoginShell("/bin/zsh", names).pipe( + Effect.provide(makeSpawnerLayer(() => output)), ); - expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({ - PATH: "/a:/b", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - }); - expect(execFile).toHaveBeenCalledTimes(1); - }); + effectIt.effect("extracts multiple environment variables from a login shell command", () => + Effect.gen(function* () { + const environment = yield* readFrom( + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "/tmp/secretive.sock", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ["PATH", "SSH_AUTH_SOCK"], + ); - it("omits environment variables that are missing or empty", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => - [ - "__T3CODE_ENV_PATH_START__", - "/a:/b", - "__T3CODE_ENV_PATH_END__", - "__T3CODE_ENV_SSH_AUTH_SOCK_START__", - "__T3CODE_ENV_SSH_AUTH_SOCK_END__", - ].join("\n"), - ); + expect(environment).toEqual({ PATH: "/a:/b", SSH_AUTH_SOCK: "/tmp/secretive.sock" }); + }), + ); - expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({ - PATH: "/a:/b", - }); - }); + effectIt.effect("omits environment variables that are missing or empty", () => + Effect.gen(function* () { + const environment = yield* readFrom( + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ["PATH", "SSH_AUTH_SOCK"], + ); - it("preserves surrounding whitespace in captured values", () => { - const execFile = vi.fn< - ( - file: string, - args: ReadonlyArray, - options: { encoding: "utf8"; timeout: number }, - ) => string - >(() => - ["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join( - "\n", - ), - ); + expect(environment).toEqual({ PATH: "/a:/b" }); + }), + ); - expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({ - CUSTOM_VAR: " padded value ", - }); - }); + effectIt.effect("preserves surrounding whitespace in captured values", () => + Effect.gen(function* () { + const environment = yield* readFrom( + [ + "__T3CODE_ENV_CUSTOM_VAR_START__", + " padded value ", + "__T3CODE_ENV_CUSTOM_VAR_END__", + ].join("\n"), + ["CUSTOM_VAR"], + ); + + expect(environment).toEqual({ CUSTOM_VAR: " padded value " }); + }), + ); }); describe("listLoginShellCandidates", () => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 1742aa33065b..4d98f7128d1f 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -6,10 +6,13 @@ import * as NodeFS from "node:fs"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; @@ -178,27 +181,38 @@ export function listLoginShellCandidates( return candidates; } -export function readPathFromLoginShell( +const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +const LAUNCHCTL_TIMEOUT = Duration.seconds(2); +const PROBE_KILL_GRACE = Duration.seconds(1); + +// Runs a short-lived probe without blocking the event loop. The timeout +// interrupts the effect, which signals the child and force-kills it after the +// grace period. +const runProbe = (command: string, args: ReadonlyArray, timeout: Duration.Duration) => + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => + spawner.string( + ChildProcess.make(command, args, { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROBE_KILL_GRACE, + }), + ), + ).pipe(Effect.timeout(timeout)); + +export const readPathFromLoginShell = Effect.fn("shell.readPathFromLoginShell")(function* ( shell: string, - execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, -): string | undefined { - return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; -} +) { + return (yield* readEnvironmentFromLoginShell(shell, ["PATH"])).PATH; +}); -export function readPathFromLaunchctl( - execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, -): string | undefined { - try { - return trimNonEmpty( - execFile("/bin/launchctl", ["getenv", "PATH"], { - encoding: "utf8", - timeout: 2000, - }), - ); - } catch { - return undefined; - } -} +export const readPathFromLaunchctl = Effect.fn("shell.readPathFromLaunchctl")(function* () { + return yield* runProbe("/bin/launchctl", ["getenv", "PATH"], LAUNCHCTL_TIMEOUT).pipe( + Effect.map(trimNonEmpty), + Effect.orElseSucceed(() => undefined), + ); +}); export function mergePathEntries( preferredPath: string | undefined, @@ -284,36 +298,29 @@ function extractEnvironmentValue(output: string, name: string): string | undefin return value.length > 0 ? value : undefined; } -export type ShellEnvironmentReader = ( - shell: string, - names: ReadonlyArray, - execFile?: ExecFileSyncLike, -) => Partial>; - -export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( - shell, - names, - execFile = NodeChildProcess.execFileSync, -) => { - if (names.length === 0) { - return {}; - } - - const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { - encoding: "utf8", - timeout: 5000, - }); - - const environment: Partial> = {}; - for (const name of names) { - const value = extractEnvironmentValue(output, name); - if (value !== undefined) { - environment[name] = value; +/** + * Reads variables from an interactive login shell. Fails when the shell cannot + * start, exits abnormally, or outlives the timeout. + */ +export const readEnvironmentFromLoginShell = Effect.fn("shell.readEnvironmentFromLoginShell")( + function* (shell: string, names: ReadonlyArray) { + const environment: Partial> = {}; + if (names.length === 0) return environment; + + const output = yield* runProbe( + shell, + ["-ilc", buildEnvironmentCaptureCommand(names)], + LOGIN_SHELL_TIMEOUT, + ); + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } } - } - - return environment; -}; + return environment; + }, +); export type WindowsShellEnvironmentReader = ( names: ReadonlyArray, From 708dff976106463feaed34629c9d3c1a0716c7f8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 21:20:52 -0500 Subject: [PATCH 2/4] fix(server): retain Windows environment validation for desktop hosts --- apps/server/src/os-jank.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 98bad2d638a3..95ce6722fd60 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -59,7 +59,8 @@ export function hydratePosixHome( * Repairs the environment of a process launched without a login shell, such as * a GUI app or a service. `shellEnvironmentPrepared` means the parent already * merged the user's shell PATH into this process environment, so only the - * HOME fallback still runs. + * HOME fallback still runs on POSIX. Windows keeps its Node availability + * check and registry/profile repair. */ export const fixPath = Effect.fn("fixPath")(function* (options?: { readonly shellEnvironmentPrepared?: boolean | undefined; @@ -73,7 +74,6 @@ export const fixPath = Effect.fn("fixPath")(function* (options?: { const shellEnvironmentPrepared = options?.shellEnvironmentPrepared === true; if (platform === "win32") { - if (shellEnvironmentPrepared) return; const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( Effect.catchDefect((defect) => Effect.sync(() => { From c93947346621042e08f66d5c7daf6a7d8ed6f803 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 9 Oct 2026 01:13:09 -0500 Subject: [PATCH 3/4] fix(desktop): preserve shell fallbacks before environment handoff --- .../src/shell/DesktopShellEnvironment.test.ts | 107 ++++++- .../src/shell/DesktopShellEnvironment.ts | 95 +++--- apps/server/src/os-jank.test.ts | 179 +++++------ apps/server/src/os-jank.ts | 43 +-- packages/shared/src/shell.test.ts | 287 +++++++----------- packages/shared/src/shell.ts | 103 +++---- 6 files changed, 385 insertions(+), 429 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index bf0fd1f6da70..83c4250de152 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -9,10 +9,14 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeOS from "node:os"; +import { vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; +vi.mock("node:os", { spy: true }); + const textEncoder = new TextEncoder(); const isDesktopShellEnvironmentCommandError = Schema.is( @@ -29,14 +33,14 @@ function envOutput(values: Readonly>): string { .join("\n"); } -function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { +function makeProcess(output: string, exitCode = 0): ChildProcessSpawner.ChildProcessHandle { const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(123), stdout, stderr: Stream.empty, all: stdout, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, stdin: Sink.drain, @@ -67,7 +71,10 @@ function withProcessEnv( function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; - readonly handler: (command: ChildProcess.Command) => string; + readonly userShell?: string; + readonly handler: ( + command: ChildProcess.Command, + ) => string | { output: string; exitCode: number }; readonly failure?: PlatformError.PlatformError; }) { const layerEnvironment = Layer.succeed( @@ -78,11 +85,15 @@ function runShellEnvironment(input: { ); const layerSpawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - input.failure === undefined - ? Effect.succeed(makeProcess(input.handler(command))) - : Effect.fail(input.failure), - ), + ChildProcessSpawner.make((command) => { + if (input.failure !== undefined) return Effect.fail(input.failure); + const result = input.handler(command); + return Effect.succeed( + typeof result === "string" + ? makeProcess(result) + : makeProcess(result.output, result.exitCode), + ); + }), ); const program = Effect.gen(function* () { @@ -96,10 +107,88 @@ function runShellEnvironment(input: { ), ); - return withProcessEnv(input.env, program); + return Effect.acquireUseRelease( + Effect.sync(() => + vi.spyOn(NodeOS, "userInfo").mockReturnValue({ + ...NodeOS.userInfo(), + shell: input.userShell ?? null, + }), + ), + () => withProcessEnv(input.env, program), + (spy) => Effect.sync(() => spy.mockRestore()), + ); } describe("DesktopShellEnvironment", () => { + it.effect("rejects PATH output from a failed shell and tries the next candidate", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; + const commands: string[] = []; + + yield* runShellEnvironment({ + env, + platform: "linux", + userShell: "/usr/local/bin/fish", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + return command.command === "/bin/failing" + ? { output: envOutput({ PATH: "/failed/bin" }), exitCode: 7 } + : envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }); + }, + }).pipe(Effect.provide(Logger.layer([]))); + + assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]); + assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); + }), + ); + + it.effect.each(["darwin", "linux"] as const)( + "uses the account shell when SHELL is missing on %s", + (platform) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" }; + const commands: string[] = []; + + yield* runShellEnvironment({ + env, + platform, + userShell: "/usr/local/bin/fish", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + return envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }); + }, + }); + + assert.deepEqual(commands, ["/usr/local/bin/fish"]); + assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); + }), + ); + + it.effect("tries the account shell before the platform fallback when SHELL has no PATH", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", PATH: "/usr/bin" }; + const commands: string[] = []; + + yield* runShellEnvironment({ + env, + platform: "linux", + userShell: "/usr/local/bin/fish", + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + return command.command === "/usr/local/bin/fish" + ? envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }) + : ""; + }, + }); + + assert.deepEqual(commands, ["/bin/missing", "/usr/local/bin/fish"]); + assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); + }), + ); + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 01f194ea0544..4fa98261fbcc 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,3 +1,4 @@ +import { listLoginShellCandidates } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -5,6 +6,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -15,7 +17,6 @@ type EnvironmentPatch = Record; interface ShellEnvironmentConfig { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; - readonly userShell: Option.Option; } interface WindowsProbeOptions { @@ -157,25 +158,6 @@ const mergePaths = ( return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); }; -const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray => { - const fallback = - config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : ""; - const seen = new Set(); - const candidates: string[] = []; - - for (const candidate of [ - trimNonEmpty(config.env.SHELL), - config.userShell, - trimNonEmpty(fallback), - ]) { - if (Option.isNone(candidate) || seen.has(candidate.value)) continue; - seen.add(candidate.value); - candidates.push(candidate.value); - } - - return candidates; -}; - const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.APPDATA).pipe( Option.match({ @@ -267,33 +249,49 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const output = yield* spawner - .string( - ChildProcess.make(input.command, input.args, { - shell: input.shell ?? false, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - killSignal: "SIGTERM", - forceKillAfter: PROCESS_TERMINATE_GRACE, - }), - ) - .pipe( - Effect.mapError( - (cause) => - new DesktopShellEnvironmentCommandError({ - probe: input.probe, - executable: executableName(input.command), - argumentCount: input.args.length, - cause, - }), - ), - Effect.catchTags({ - DesktopShellEnvironmentCommandError: (error) => - logShellEnvironmentCommandError(error).pipe(Effect.as("")), - }), - Effect.timeoutOption(input.timeout), - ); + const output = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ); + return yield* Effect.all([Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode], { + concurrency: 2, + }); + }), + ).pipe( + Effect.mapError( + (cause) => + new DesktopShellEnvironmentCommandError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + cause, + }), + ), + Effect.filterOrFail( + ([, exitCode]) => exitCode === 0, + ([, exitCode]) => + new DesktopShellEnvironmentCommandError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + cause: { exitCode }, + }), + ), + Effect.map(([stdout]) => stdout), + Effect.catchTags({ + DesktopShellEnvironmentCommandError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), + Effect.timeoutOption(input.timeout), + ); if (Option.isSome(output)) { return output.value; } @@ -406,7 +404,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; - for (const shell of listLoginShellCandidates(config)) { + for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { Object.assign( shellEnvironment, yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), @@ -521,7 +519,6 @@ export const make = Effect.gen(function* () { installShellEnvironment({ env: process.env, platform: environment.platform, - userShell: Option.none(), }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 62118a4257f2..c7f6e5ce77ca 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,17 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it as effectIt } from "@effect/vitest"; +import { it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CommandAvailability, WindowsShellEnvironment } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Observe the existing synchronous shell probe without launching the user's login shell. +import * as NodeChildProcess from "node:child_process"; import * as NodeOS from "node:os"; -import { it } from "vite-plus/test"; +import { assert, it, vi } from "vite-plus/test"; import { fixPath, hydratePosixHome } from "./os-jank.ts"; +vi.mock("node:child_process", { spy: true }); + it("hydrates HOME for minimal service environments from the user account", () => { const env: NodeJS.ProcessEnv = {}; @@ -48,118 +48,75 @@ it("preserves an explicitly configured HOME", () => { assert.equal(env.HOME, "/custom/home"); }); -const textEncoder = new TextEncoder(); - -// Answers every login shell probe from `shells`; a shell it does not list fails -// to start. Returns the shells that were launched, in order. -const runFixPath = (input: { - readonly platform: NodeJS.Platform; - readonly env: NodeJS.ProcessEnv; - readonly shells: Readonly>; - readonly shellEnvironmentPrepared?: boolean; -}) => - Effect.gen(function* () { - const launched: Array = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - if (command._tag !== "StandardCommand") return Effect.die("unexpected command"); - launched.push(command.command); - const output = input.shells[command.command]; - if (output === undefined) { - return Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "ENOENT", - }), +effectIt.effect.each(["darwin", "linux"] as const)( + "keeps the prepared PATH and still hydrates HOME on %s", + (platform) => + Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(NodeChildProcess, "execFileSync")), + (probe) => + Effect.gen(function* () { + probe.mockImplementation(() => { + throw new Error("The prepared environment must not launch another shell"); + }); + const env: NodeJS.ProcessEnv = { PATH: "/prepared/bin:/usr/bin" }; + + yield* fixPath({ shellEnvironmentPrepared: true }).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HostProcessEnvironment, env), + Effect.provide(NodeServices.layer), ); - } - const stdout = Stream.make(textEncoder.encode(output)); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - stdout, - stderr: Stream.empty, - all: stdout, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), - ); - }), - ); - yield* fixPath({ shellEnvironmentPrepared: input.shellEnvironmentPrepared }).pipe( - Effect.provideService(HostProcessPlatform, input.platform), - Effect.provideService(HostProcessEnvironment, input.env), - Effect.provide(Layer.mergeAll(NodeServices.layer, layerSpawner)), - ); - return launched; - }); - -const shellPathOutput = (path: string) => - `__T3CODE_ENV_PATH_START__\n${path}\n__T3CODE_ENV_PATH_END__\n`; - -effectIt.effect("merges the login shell PATH when the environment was not prepared", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", HOME: "/home/test", PATH: "/usr/bin" }; - - const launched = yield* runFixPath({ - platform: "linux", - env, - shells: { "/bin/zsh": shellPathOutput("/opt/tools/bin:/usr/bin") }, - }); - assert.deepEqual(launched, ["/bin/zsh"]); - assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); - }), + assert.equal(probe.mock.calls.length, 0); + assert.equal(env.PATH, "/prepared/bin:/usr/bin"); + assert.equal(env.HOME, NodeOS.userInfo().homedir); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), ); -effectIt.effect("tries the next login shell when one fails to start", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", HOME: "/home/test", PATH: "/usr/bin" }; - - const launched = yield* runFixPath({ - platform: "linux", - env, - shells: { "/bin/bash": shellPathOutput("/opt/tools/bin") }, - }); +effectIt.effect("hydrates PATH when the desktop handoff is absent", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi + .spyOn(NodeChildProcess, "execFileSync") + .mockReturnValue("__T3CODE_ENV_PATH_START__\n/opt/tools/bin\n__T3CODE_ENV_PATH_END__\n"), + ), + (probe) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", PATH: "/usr/bin" }; + + yield* fixPath().pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, env), + Effect.provide(NodeServices.layer), + ); - assert.equal(launched[0], "/bin/missing"); - assert.equal(launched.at(-1), "/bin/bash"); - assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); - }), + assert.equal(probe.mock.calls.length, 1); + assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), ); -effectIt.effect("keeps the inherited PATH when every login shell fails", () => +effectIt.effect("repairs Windows PATH even when the desktop marks it prepared", () => Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", HOME: "/home/test", PATH: "/usr/bin" }; - - yield* runFixPath({ platform: "linux", env, shells: {} }); - - assert.equal(env.PATH, "/usr/bin"); - }), -); + const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" }; + const commands: string[] = []; + + yield* fixPath({ shellEnvironmentPrepared: true }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessEnvironment, env), + Effect.provideService(WindowsShellEnvironment, () => ({ PATH: "C:\\Tools" })), + Effect.provideService(CommandAvailability, (command) => + Effect.sync(() => { + commands.push(command); + return true; + }), + ), + Effect.provide(NodeServices.layer), + ); -effectIt.effect("skips every shell probe when the desktop prepared the environment", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/prepared/bin:/usr/bin" }; - - const launched = yield* runFixPath({ - platform: "darwin", - env, - shells: { "/bin/zsh": shellPathOutput("/other/bin") }, - shellEnvironmentPrepared: true, - }); - - assert.deepEqual(launched, []); - assert.equal(env.PATH, "/prepared/bin:/usr/bin"); - // HOME is not part of what the desktop prepares, so it is still filled in. - assert.equal(env.HOME, NodeOS.userInfo().homedir); + assert.deepEqual(commands, ["node"]); + assert.equal(env.PATH, "C:\\Tools;C:\\Windows"); }), ); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 95ce6722fd60..177028868ff0 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -9,7 +9,6 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { @@ -18,30 +17,24 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } -const hydratePosixPath = Effect.fn("hydratePosixPath")(function* ( - env: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -) { +function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { let shellPath: string | undefined; for (const shell of listLoginShellCandidates(platform, env.SHELL)) { - shellPath = yield* readPathFromLoginShell(shell).pipe( - Effect.catch((error) => - Effect.sync(() => { - logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); - return undefined; - }), - ), - ); + try { + shellPath = readPathFromLoginShell(shell); + } catch (error) { + logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); + } + if (shellPath) break; } - const launchctlPath = - platform === "darwin" && !shellPath ? yield* readPathFromLaunchctl() : undefined; + const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined; const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); if (mergedPath) { env.PATH = mergedPath; } -}); +} export function hydratePosixHome( env: NodeJS.ProcessEnv, @@ -55,23 +48,11 @@ export function hydratePosixHome( } } -/** - * Repairs the environment of a process launched without a login shell, such as - * a GUI app or a service. `shellEnvironmentPrepared` means the parent already - * merged the user's shell PATH into this process environment, so only the - * HOME fallback still runs on POSIX. Windows keeps its Node availability - * check and registry/profile repair. - */ export const fixPath = Effect.fn("fixPath")(function* (options?: { readonly shellEnvironmentPrepared?: boolean | undefined; -}): Effect.fn.Return< - void, - never, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> { +}): Effect.fn.Return { const platform = yield* HostProcessPlatform; const env = yield* HostProcessEnvironment; - const shellEnvironmentPrepared = options?.shellEnvironmentPrepared === true; if (platform === "win32") { const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe( @@ -99,8 +80,8 @@ export const fixPath = Effect.fn("fixPath")(function* (options?: { }), ), ); - if (shellEnvironmentPrepared) return; - yield* hydratePosixPath(env, platform).pipe( + if (options?.shellEnvironmentPrepared === true) return; + yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 9c17ac882654..4e27aa49fcfc 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,15 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -47,189 +40,135 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); -const textEncoder = new TextEncoder(); - -// A spawner that records each command and answers with the handler's output. -// A never-ending process stands in for a shell that hangs. -const makeSpawnerLayer = ( - handler: (command: ChildProcess.StandardCommand) => string | "hang" | "fail", - commands: Array = [], -) => - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - if (command._tag !== "StandardCommand") return Effect.die("unexpected command"); - commands.push(command); - const output = handler(command); - if (output === "fail") { - return Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "ENOENT", - }), - ); - } - const stdout = - output === "hang" - ? Stream.never - : output.length === 0 - ? Stream.empty - : Stream.make(textEncoder.encode(output)); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - stdout, - stderr: Stream.empty, - all: stdout, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), - ); - }), - ); - describe("readPathFromLoginShell", () => { - effectIt.effect("uses a shell-agnostic printenv PATH probe", () => - Effect.gen(function* () { - const commands: Array = []; - - const path = yield* readPathFromLoginShell("/opt/homebrew/bin/fish").pipe( - Effect.provide( - makeSpawnerLayer( - () => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n", - commands, - ), - ), - ); - - expect(path).toBe("/a:/b"); - expect(commands).toHaveLength(1); - const [command] = commands; - expect(command?.command).toBe("/opt/homebrew/bin/fish"); - expect(command?.args).toHaveLength(2); - expect(command?.args[0]).toBe("-ilc"); - expect(command?.args[1]).toContain("printenv PATH || true"); - expect(command?.args[1]).toContain("__T3CODE_ENV_PATH_START__"); - expect(command?.args[1]).toContain("__T3CODE_ENV_PATH_END__"); - }), - ); - - effectIt.effect("fails when the shell cannot be started", () => - Effect.gen(function* () { - const error = yield* readPathFromLoginShell("/bin/missing").pipe( - Effect.provide(makeSpawnerLayer(() => "fail")), - Effect.flip, - ); - - expect(error._tag).toBe("PlatformError"); - }), - ); - - effectIt.effect("gives up on a shell that never exits after five seconds", () => - Effect.gen(function* () { - const fiber = yield* readPathFromLoginShell("/bin/zsh").pipe( - Effect.provide(makeSpawnerLayer(() => "hang")), - Effect.flip, - Effect.forkChild, - ); - - yield* TestClock.adjust("4999 millis"); - expect(fiber.pollUnsafe()).toBeUndefined(); - yield* TestClock.adjust("1 millis"); - const error = yield* Fiber.join(fiber); - expect(error._tag).toBe("TimeoutError"); - }), - ); + it("uses a shell-agnostic printenv PATH probe", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n"); + + expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b"); + expect(execFile).toHaveBeenCalledTimes(1); + + const firstCall = execFile.mock.calls[0] as + | [string, ReadonlyArray, { encoding: "utf8"; timeout: number }] + | undefined; + expect(firstCall).toBeDefined(); + if (!firstCall) { + throw new Error("Expected execFile to be called"); + } + + const [shell, args, options] = firstCall; + expect(shell).toBe("/opt/homebrew/bin/fish"); + expect(args).toHaveLength(2); + expect(args?.[0]).toBe("-ilc"); + expect(args?.[1]).toContain("printenv PATH || true"); + expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); + expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); + expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); + }); }); describe("readPathFromLaunchctl", () => { - effectIt.effect("returns a trimmed PATH value from launchctl", () => - Effect.gen(function* () { - const commands: Array = []; - - const path = yield* readPathFromLaunchctl().pipe( - Effect.provide(makeSpawnerLayer(() => " /opt/homebrew/bin:/usr/bin \n", commands)), - ); + it("returns a trimmed PATH value from launchctl", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => " /opt/homebrew/bin:/usr/bin \n"); - expect(path).toBe("/opt/homebrew/bin:/usr/bin"); - expect(commands[0]?.command).toBe("/bin/launchctl"); - expect(commands[0]?.args).toEqual(["getenv", "PATH"]); - }), - ); + expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin"); + expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }); + }); - effectIt.effect("returns undefined when launchctl is unavailable", () => - Effect.gen(function* () { - const path = yield* readPathFromLaunchctl().pipe( - Effect.provide(makeSpawnerLayer(() => "fail")), - ); + it("returns undefined when launchctl is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => { + throw new Error("spawn /bin/launchctl ENOENT"); + }); - expect(path).toBeUndefined(); - }), - ); + expect(readPathFromLaunchctl(execFile)).toBeUndefined(); + }); }); describe("readEnvironmentFromLoginShell", () => { - const readFrom = (output: string, names: ReadonlyArray) => - readEnvironmentFromLoginShell("/bin/zsh", names).pipe( - Effect.provide(makeSpawnerLayer(() => output)), + it("extracts multiple environment variables from a login shell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "/tmp/secretive.sock", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), ); - effectIt.effect("extracts multiple environment variables from a login shell command", () => - Effect.gen(function* () { - const environment = yield* readFrom( - [ - "__T3CODE_ENV_PATH_START__", - "/a:/b", - "__T3CODE_ENV_PATH_END__", - "__T3CODE_ENV_SSH_AUTH_SOCK_START__", - "/tmp/secretive.sock", - "__T3CODE_ENV_SSH_AUTH_SOCK_END__", - ].join("\n"), - ["PATH", "SSH_AUTH_SOCK"], - ); - - expect(environment).toEqual({ PATH: "/a:/b", SSH_AUTH_SOCK: "/tmp/secretive.sock" }); - }), - ); + expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({ + PATH: "/a:/b", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }); + expect(execFile).toHaveBeenCalledTimes(1); + }); - effectIt.effect("omits environment variables that are missing or empty", () => - Effect.gen(function* () { - const environment = yield* readFrom( - [ - "__T3CODE_ENV_PATH_START__", - "/a:/b", - "__T3CODE_ENV_PATH_END__", - "__T3CODE_ENV_SSH_AUTH_SOCK_START__", - "__T3CODE_ENV_SSH_AUTH_SOCK_END__", - ].join("\n"), - ["PATH", "SSH_AUTH_SOCK"], - ); + it("omits environment variables that are missing or empty", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + [ + "__T3CODE_ENV_PATH_START__", + "/a:/b", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ); - expect(environment).toEqual({ PATH: "/a:/b" }); - }), - ); + expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({ + PATH: "/a:/b", + }); + }); - effectIt.effect("preserves surrounding whitespace in captured values", () => - Effect.gen(function* () { - const environment = yield* readFrom( - [ - "__T3CODE_ENV_CUSTOM_VAR_START__", - " padded value ", - "__T3CODE_ENV_CUSTOM_VAR_END__", - ].join("\n"), - ["CUSTOM_VAR"], - ); + it("preserves surrounding whitespace in captured values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => + ["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join( + "\n", + ), + ); - expect(environment).toEqual({ CUSTOM_VAR: " padded value " }); - }), - ); + expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({ + CUSTOM_VAR: " padded value ", + }); + }); }); describe("listLoginShellCandidates", () => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 4d98f7128d1f..1742aa33065b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -6,13 +6,10 @@ import * as NodeFS from "node:fs"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; @@ -181,38 +178,27 @@ export function listLoginShellCandidates( return candidates; } -const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); -const LAUNCHCTL_TIMEOUT = Duration.seconds(2); -const PROBE_KILL_GRACE = Duration.seconds(1); - -// Runs a short-lived probe without blocking the event loop. The timeout -// interrupts the effect, which signals the child and force-kills it after the -// grace period. -const runProbe = (command: string, args: ReadonlyArray, timeout: Duration.Duration) => - ChildProcessSpawner.ChildProcessSpawner.use((spawner) => - spawner.string( - ChildProcess.make(command, args, { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - killSignal: "SIGTERM", - forceKillAfter: PROBE_KILL_GRACE, - }), - ), - ).pipe(Effect.timeout(timeout)); - -export const readPathFromLoginShell = Effect.fn("shell.readPathFromLoginShell")(function* ( +export function readPathFromLoginShell( shell: string, -) { - return (yield* readEnvironmentFromLoginShell(shell, ["PATH"])).PATH; -}); + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, +): string | undefined { + return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; +} -export const readPathFromLaunchctl = Effect.fn("shell.readPathFromLaunchctl")(function* () { - return yield* runProbe("/bin/launchctl", ["getenv", "PATH"], LAUNCHCTL_TIMEOUT).pipe( - Effect.map(trimNonEmpty), - Effect.orElseSucceed(() => undefined), - ); -}); +export function readPathFromLaunchctl( + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, +): string | undefined { + try { + return trimNonEmpty( + execFile("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }), + ); + } catch { + return undefined; + } +} export function mergePathEntries( preferredPath: string | undefined, @@ -298,29 +284,36 @@ function extractEnvironmentValue(output: string, name: string): string | undefin return value.length > 0 ? value : undefined; } -/** - * Reads variables from an interactive login shell. Fails when the shell cannot - * start, exits abnormally, or outlives the timeout. - */ -export const readEnvironmentFromLoginShell = Effect.fn("shell.readEnvironmentFromLoginShell")( - function* (shell: string, names: ReadonlyArray) { - const environment: Partial> = {}; - if (names.length === 0) return environment; - - const output = yield* runProbe( - shell, - ["-ilc", buildEnvironmentCaptureCommand(names)], - LOGIN_SHELL_TIMEOUT, - ); - for (const name of names) { - const value = extractEnvironmentValue(output, name); - if (value !== undefined) { - environment[name] = value; - } +export type ShellEnvironmentReader = ( + shell: string, + names: ReadonlyArray, + execFile?: ExecFileSyncLike, +) => Partial>; + +export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( + shell, + names, + execFile = NodeChildProcess.execFileSync, +) => { + if (names.length === 0) { + return {}; + } + + const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { + encoding: "utf8", + timeout: 5000, + }); + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; } - return environment; - }, -); + } + + return environment; +}; export type WindowsShellEnvironmentReader = ( names: ReadonlyArray, From 55e2da810a969ee4c8e729575a9cf5def68448cd Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 9 Oct 2026 22:41:27 -0500 Subject: [PATCH 4/4] fix(web): keep timeline divider focus ring inside its border --- apps/web/src/components/chat/TimelineSystemDivider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineSystemDivider.tsx b/apps/web/src/components/chat/TimelineSystemDivider.tsx index e680cab50c4c..273137244199 100644 --- a/apps/web/src/components/chat/TimelineSystemDivider.tsx +++ b/apps/web/src/components/chat/TimelineSystemDivider.tsx @@ -48,7 +48,7 @@ export function TimelineSystemDivider(props: { type="button" aria-label={props.actionLabel} onClick={props.onAction} - className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70" + className="flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 bg-background px-2.5 py-1 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" /> } >