| author | |
| committer | |
| log | 03bd839554c9da27aae37b027bcdb2fa9896af09 |
| tree | 25da84173d3f061de3b8cdf2180438ecb244af18 |
| parent | aacefcd7e92a784c179b3ca2473a7608f783784e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 325 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 326 | 326 | ); |
| 327 | 327 | |
| 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 | 343 | it.effect("resolveWsl reuses the primary's bootstrap token", () => |
| 329 | 344 | withHarness( |
| 330 | 345 | Effect.gen(function* () { |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+4| ... | ... | @@ -567,6 +567,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv |
| 567 | 567 | host: backendExposure.bindHost, |
| 568 | 568 | desktopBootstrapToken: input.bootstrapToken, |
| 569 | 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 | 574 | tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, |
| 571 | 575 | tailscaleServePort: backendExposure.tailscaleServePort, |
| 572 | 576 | desktopTelemetryFd: 4, |
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+98-9| ... | ... | @@ -9,10 +9,14 @@ import * as Sink from "effect/Sink"; |
| 9 | 9 | import * as Stream from "effect/Stream"; |
| 10 | 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 11 | 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | import * as NodeOS from "node:os"; | |
| 13 | import { vi } from "vite-plus/test"; | |
| 12 | 14 | |
| 13 | 15 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 14 | 16 | import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; |
| 15 | 17 | |
| 18 | vi.mock("node:os", { spy: true }); | |
| 19 | ||
| 16 | 20 | const textEncoder = new TextEncoder(); |
| 17 | 21 | |
| 18 | 22 | const isDesktopShellEnvironmentCommandError = Schema.is( |
| ... | ... | @@ -29,14 +33,14 @@ function envOutput(values: Readonly<Record<string, string>>): string { |
| 29 | 33 | .join("\n"); |
| 30 | 34 | } |
| 31 | 35 | |
| 32 | function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { | |
| 36 | function makeProcess(output: string, exitCode = 0): ChildProcessSpawner.ChildProcessHandle { | |
| 33 | 37 | const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output)); |
| 34 | 38 | return ChildProcessSpawner.makeHandle({ |
| 35 | 39 | pid: ChildProcessSpawner.ProcessId(123), |
| 36 | 40 | stdout, |
| 37 | 41 | stderr: Stream.empty, |
| 38 | 42 | all: stdout, |
| 39 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), | |
| 43 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | |
| 40 | 44 | isRunning: Effect.succeed(false), |
| 41 | 45 | kill: () => Effect.void, |
| 42 | 46 | stdin: Sink.drain, |
| ... | ... | @@ -67,7 +71,10 @@ function withProcessEnv<A, E, R>( |
| 67 | 71 | function runShellEnvironment(input: { |
| 68 | 72 | readonly env: NodeJS.ProcessEnv; |
| 69 | 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 | 78 | readonly failure?: PlatformError.PlatformError; |
| 72 | 79 | }) { |
| 73 | 80 | const layerEnvironment = Layer.succeed( |
| ... | ... | @@ -78,11 +85,15 @@ function runShellEnvironment(input: { |
| 78 | 85 | ); |
| 79 | 86 | const layerSpawner = Layer.succeed( |
| 80 | 87 | ChildProcessSpawner.ChildProcessSpawner, |
| 81 | ChildProcessSpawner.make((command) => | |
| 82 | input.failure === undefined | |
| 83 | ? Effect.succeed(makeProcess(input.handler(command))) | |
| 84 | : Effect.fail(input.failure), | |
| 85 | ), | |
| 88 | ChildProcessSpawner.make((command) => { | |
| 89 | if (input.failure !== undefined) return Effect.fail(input.failure); | |
| 90 | const result = input.handler(command); | |
| 91 | return Effect.succeed( | |
| 92 | typeof result === "string" | |
| 93 | ? makeProcess(result) | |
| 94 | : makeProcess(result.output, result.exitCode), | |
| 95 | ); | |
| 96 | }), | |
| 86 | 97 | ); |
| 87 | 98 | |
| 88 | 99 | const program = Effect.gen(function* () { |
| ... | ... | @@ -96,10 +107,88 @@ function runShellEnvironment(input: { |
| 96 | 107 | ), |
| 97 | 108 | ); |
| 98 | 109 | |
| 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 | } |
| 101 | 121 | |
| 102 | 122 | describe("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 | 192 | it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => |
| 104 | 193 | Effect.gen(function* () { |
| 105 | 194 | const env: NodeJS.ProcessEnv = { |
apps/desktop/src/shell/DesktopShellEnvironment.ts+46-49| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { listLoginShellCandidates } from "@t3tools/shared/shell"; | |
| 1 | 2 | import * as Context from "effect/Context"; |
| 2 | 3 | import * as Duration from "effect/Duration"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -5,6 +6,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 5 | 6 | import * as Layer from "effect/Layer"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Stream from "effect/Stream"; | |
| 8 | 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 9 | 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 10 | 12 | |
| ... | ... | @@ -15,7 +17,6 @@ type EnvironmentPatch = Record<string, string>; |
| 15 | 17 | interface ShellEnvironmentConfig { |
| 16 | 18 | readonly env: NodeJS.ProcessEnv; |
| 17 | 19 | readonly platform: NodeJS.Platform; |
| 18 | readonly userShell: Option.Option<string>; | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | interface WindowsProbeOptions { |
| ... | ... | @@ -157,25 +158,6 @@ const mergePaths = ( |
| 157 | 158 | return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); |
| 158 | 159 | }; |
| 159 | 160 | |
| 160 | const 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 | ||
| 179 | 161 | const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [ |
| 180 | 162 | ...trimNonEmpty(env.APPDATA).pipe( |
| 181 | 163 | Option.match({ |
| ... | ... | @@ -267,33 +249,49 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( |
| 267 | 249 | readonly shell?: boolean; |
| 268 | 250 | }): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> { |
| 269 | 251 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 270 | const output = yield* spawner | |
| 271 | .string( | |
| 272 | ChildProcess.make(input.command, input.args, { | |
| 273 | shell: input.shell ?? false, | |
| 274 | stdin: "ignore", | |
| 275 | stdout: "pipe", | |
| 276 | stderr: "pipe", | |
| 277 | killSignal: "SIGTERM", | |
| 278 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 279 | }), | |
| 280 | ) | |
| 281 | .pipe( | |
| 282 | Effect.mapError( | |
| 283 | (cause) => | |
| 284 | new DesktopShellEnvironmentCommandError({ | |
| 285 | probe: input.probe, | |
| 286 | executable: executableName(input.command), | |
| 287 | argumentCount: input.args.length, | |
| 288 | cause, | |
| 289 | }), | |
| 290 | ), | |
| 291 | Effect.catchTags({ | |
| 292 | DesktopShellEnvironmentCommandError: (error) => | |
| 293 | logShellEnvironmentCommandError(error).pipe(Effect.as("")), | |
| 294 | }), | |
| 295 | Effect.timeoutOption(input.timeout), | |
| 296 | ); | |
| 252 | const output = yield* Effect.scoped( | |
| 253 | Effect.gen(function* () { | |
| 254 | const child = yield* spawner.spawn( | |
| 255 | ChildProcess.make(input.command, input.args, { | |
| 256 | shell: input.shell ?? false, | |
| 257 | stdin: "ignore", | |
| 258 | stdout: "pipe", | |
| 259 | stderr: "pipe", | |
| 260 | killSignal: "SIGTERM", | |
| 261 | forceKillAfter: PROCESS_TERMINATE_GRACE, | |
| 262 | }), | |
| 263 | ); | |
| 264 | return yield* Effect.all([Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode], { | |
| 265 | concurrency: 2, | |
| 266 | }); | |
| 267 | }), | |
| 268 | ).pipe( | |
| 269 | Effect.mapError( | |
| 270 | (cause) => | |
| 271 | new DesktopShellEnvironmentCommandError({ | |
| 272 | probe: input.probe, | |
| 273 | executable: executableName(input.command), | |
| 274 | argumentCount: input.args.length, | |
| 275 | cause, | |
| 276 | }), | |
| 277 | ), | |
| 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 | 295 | if (Option.isSome(output)) { |
| 298 | 296 | return output.value; |
| 299 | 297 | } |
| ... | ... | @@ -406,7 +404,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix |
| 406 | 404 | const fileSystem = yield* FileSystem.FileSystem; |
| 407 | 405 | const shellEnvironment: EnvironmentPatch = {}; |
| 408 | 406 | |
| 409 | for (const shell of listLoginShellCandidates(config)) { | |
| 407 | for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) { | |
| 410 | 408 | Object.assign( |
| 411 | 409 | shellEnvironment, |
| 412 | 410 | yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), |
| ... | ... | @@ -521,7 +519,6 @@ export const make = Effect.gen(function* () { |
| 521 | 519 | installShellEnvironment({ |
| 522 | 520 | env: process.env, |
| 523 | 521 | platform: environment.platform, |
| 524 | userShell: Option.none(), | |
| 525 | 522 | }).pipe( |
| 526 | 523 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 527 | 524 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
apps/server/src/cli/config.test.ts+45| ... | ... | @@ -26,6 +26,21 @@ import { resolveServerConfig } from "./config.ts"; |
| 26 | 26 | const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => |
| 27 | 27 | deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); |
| 28 | 28 | |
| 29 | const 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 | ||
| 29 | 44 | const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); |
| 30 | 45 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 31 | 46 | |
| ... | ... | @@ -515,6 +530,36 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 515 | 530 | }), |
| 516 | 531 | ); |
| 517 | 532 | |
| 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 | 563 | it.effect("creates derived runtime directories during config resolution", () => |
| 519 | 564 | Effect.gen(function* () { |
| 520 | 565 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/cli/config.ts+2| ... | ... | @@ -360,6 +360,7 @@ export const resolveServerConfig = ( |
| 360 | 360 | ); |
| 361 | 361 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; |
| 362 | 362 | const desktopBootstrapSecret = bootstrap?.desktopBootstrapSecret; |
| 363 | const shellEnvironmentPrepared = bootstrap?.shellEnvironmentPrepared; | |
| 363 | 364 | const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; |
| 364 | 365 | const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; |
| 365 | 366 | const desktopBrowserFd = bootstrap?.desktopBrowserFd; |
| ... | ... | @@ -468,6 +469,7 @@ export const resolveServerConfig = ( |
| 468 | 469 | startupPresentation, |
| 469 | 470 | desktopBootstrapToken, |
| 470 | 471 | ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), |
| 472 | ...(shellEnvironmentPrepared === undefined ? {} : { shellEnvironmentPrepared }), | |
| 471 | 473 | desktopTelemetryFd, |
| 472 | 474 | desktopTelemetryControlFd, |
| 473 | 475 | desktopBrowserFd, |
apps/server/src/config.ts+1| ... | ... | @@ -95,6 +95,7 @@ export class ServerConfig extends Context.Service< |
| 95 | 95 | readonly startupPresentation: StartupPresentation; |
| 96 | 96 | readonly desktopBootstrapToken: string | undefined; |
| 97 | 97 | readonly desktopBootstrapSecret?: string | undefined; |
| 98 | readonly shellEnvironmentPrepared?: boolean | undefined; | |
| 98 | 99 | readonly desktopTelemetryFd?: number | undefined; |
| 99 | 100 | readonly desktopTelemetryControlFd?: number | undefined; |
| 100 | 101 | readonly desktopBrowserFd?: number | undefined; |
apps/server/src/os-jank.test.ts+84-2| ... | ... | @@ -1,7 +1,16 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it as effectIt } from "@effect/vitest"; | |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 4 | import { CommandAvailability, WindowsShellEnvironment } from "@t3tools/shared/shell"; | |
| 5 | import * 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. | |
| 7 | import * as NodeChildProcess from "node:child_process"; | |
| 1 | 8 | import * as NodeOS from "node:os"; |
| 2 | import { assert, it } from "vite-plus/test"; | |
| 9 | import { assert, it, vi } from "vite-plus/test"; | |
| 3 | 10 | |
| 4 | import { hydratePosixHome } from "./os-jank.ts"; | |
| 11 | import { fixPath, hydratePosixHome } from "./os-jank.ts"; | |
| 12 | ||
| 13 | vi.mock("node:child_process", { spy: true }); | |
| 5 | 14 | |
| 6 | 15 | it("hydrates HOME for minimal service environments from the user account", () => { |
| 7 | 16 | const env: NodeJS.ProcessEnv = {}; |
| ... | ... | @@ -38,3 +47,76 @@ it("preserves an explicitly configured HOME", () => { |
| 38 | 47 | |
| 39 | 48 | assert.equal(env.HOME, "/custom/home"); |
| 40 | 49 | }); |
| 50 | ||
| 51 | effectIt.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 | ||
| 77 | effectIt.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 | ||
| 101 | effectIt.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 | 48 | } |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< | |
| 52 | void, | |
| 53 | never, | |
| 54 | FileSystem.FileSystem | Path.Path | |
| 55 | > { | |
| 51 | export const fixPath = Effect.fn("fixPath")(function* (options?: { | |
| 52 | readonly shellEnvironmentPrepared?: boolean | undefined; | |
| 53 | }): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> { | |
| 56 | 54 | const platform = yield* HostProcess.Platform; |
| 57 | 55 | const env = yield* HostProcess.Environment; |
| 58 | 56 | |
| ... | ... | @@ -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 | 84 | yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( |
| 86 | 85 | Effect.catchDefect((defect) => |
| 87 | 86 | Effect.sync(() => { |
apps/server/src/server.ts+1-1| ... | ... | @@ -741,7 +741,7 @@ const layerMakeServer = Layer.unwrap( |
| 741 | 741 | const routesReady = yield* Deferred.make<void>(); |
| 742 | 742 | const layerLauncher = ServiceLauncherClient.layer; |
| 743 | 743 | |
| 744 | yield* fixPath(); | |
| 744 | yield* fixPath({ shellEnvironmentPrepared: config.shellEnvironmentPrepared }); | |
| 745 | 745 | |
| 746 | 746 | const layerHttpListening = Layer.effectDiscard( |
| 747 | 747 | Effect.gen(function* () { |
packages/contracts/src/desktopBootstrap.ts+4| ... | ... | @@ -17,6 +17,10 @@ export const DesktopBackendBootstrap = Schema.Struct({ |
| 17 | 17 | // `desktopBootstrapToken` for its whole run. See |
| 18 | 18 | // `@t3tools/shared/desktopBootstrapToken`. |
| 19 | 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 | 24 | tailscaleServeEnabled: Schema.Boolean, |
| 21 | 25 | tailscaleServePort: PortSchema, |
| 22 | 26 | otlpTracesUrl: Schema.optional(Schema.String), |