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