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/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/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..9a72829ad3db 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 { it as effectIt } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { CommandAvailability, WindowsShellEnvironment } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +// @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 { assert, it } from "vite-plus/test"; +import { assert, it, vi } from "vite-plus/test"; -import { hydratePosixHome } from "./os-jank.ts"; +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 = {}; @@ -38,3 +47,76 @@ it("preserves an explicitly configured HOME", () => { assert.equal(env.HOME, "/custom/home"); }); + +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(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + 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("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(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + assert.equal(probe.mock.calls.length, 1); + assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + +effectIt.effect("repairs Windows PATH even when the desktop marks it prepared", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" }; + const commands: string[] = []; + + yield* fixPath({ shellEnvironmentPrepared: true }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, env), + Effect.provideService(WindowsShellEnvironment, () => ({ PATH: "C:\\Tools" })), + Effect.provideService(CommandAvailability, (command) => + Effect.sync(() => { + commands.push(command); + return true; + }), + ), + Effect.provide(NodeServices.layer), + ); + + 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 38bbcde011cb..a94e03ea871c 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -48,11 +48,9 @@ export function hydratePosixHome( } } -export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< - void, - never, - FileSystem.FileSystem | Path.Path -> { +export const fixPath = Effect.fn("fixPath")(function* (options?: { + readonly shellEnvironmentPrepared?: boolean | undefined; +}): Effect.fn.Return { const platform = yield* HostProcess.Platform; const env = yield* HostProcess.Environment; @@ -82,6 +80,7 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< }), ), ); + if (options?.shellEnvironmentPrepared === true) return; yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98a541409380..a9cbb6cfbbd2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -741,7 +741,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/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" /> } > 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),