From 79681133dcb4c33eccca0c0b8f3bd7537d6fefdc Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:29:12 -0800 Subject: [PATCH 1/3] refactor: Effect code reads env through HostProcess.Environment Enable the Effect language-service rule processEnvInEffect as an error and route every hit through the HostProcess.Environment reference. Tests provide the reference instead of mutating the global process.env. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopApp.ts | 5 +- .../desktop/src/app/DesktopCliCommand.test.ts | 36 ++-- apps/desktop/src/app/DesktopCliCommand.ts | 8 +- .../src/app/DesktopPreReadyPlatform.ts | 12 +- .../DesktopBackendConfiguration.test.ts | 175 +++++++----------- .../backend/DesktopBackendConfiguration.ts | 8 +- apps/desktop/src/snapShot/DesktopSnapShot.ts | 3 +- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 3 +- .../transferBudgetV2.integration.test.ts | 6 +- apps/server/scripts/acp-mock-agent.ts | 8 +- .../record-codex-app-server-replay-fixture.ts | 5 +- .../scripts/record-fx-acp-replay-fixture.ts | 16 +- .../scripts/record-grok-acp-replay-fixture.ts | 7 +- .../scripts/record-kiro-acp-replay-fixture.ts | 7 +- apps/server/src/cli/app.test.ts | 21 ++- apps/server/src/cli/uninstall.test.ts | 5 +- .../src/cloud/ManagedEndpointRuntime.test.ts | 34 ++-- .../src/cloud/ManagedEndpointRuntime.ts | 6 +- .../src/device/AgentDeviceTarget.test.ts | 3 +- apps/server/src/git/GitManager.ts | 4 +- .../ClaudeAgentSdkQueryRunner.test.ts | 4 +- .../src/project/ManagedProjectFolders.test.ts | 34 ++-- .../provider/ClaudeCapabilitiesProbe.test.ts | 3 +- .../src/provider/CodexManagedRuntime.test.ts | 5 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 22 ++- apps/server/src/serviceLauncher.test.ts | 3 +- .../ClaudeTextGeneration.test.ts | 150 +++------------ apps/server/src/vcs/GitVcsDriver.test.ts | 15 +- apps/server/src/vcs/GitVcsDriver.ts | 5 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 79 +++----- apps/server/src/vcs/GitVcsDriverCore.ts | 2 +- evals/mcp-tool-use/run.ts | 3 +- .../effect-acp/test/fixtures/acp-mock-peer.ts | 6 +- .../scripts/generate.ts | 7 +- .../test/examples/codex-app-server-probe.ts | 4 +- .../src/server/AcpRegistrySupport.test.ts | 12 +- .../src/server/ForgejoCli.ts | 7 +- scripts/build-npm-launcher.test.ts | 8 +- tsconfig.effect.json | 1 + 39 files changed, 296 insertions(+), 446 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 8bdd1957cbfb7..40202b76e2445 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -307,12 +307,13 @@ const startup = Effect.gen(function* () { yield* desktopSettings.load; if (linuxElectronOptions !== null) { + const env = yield* HostProcess.Environment; yield* logStartupInfo("linux password store configured", { passwordStore: hasCommandLinePasswordStore ? "command-line" : (linuxElectronOptions.passwordStore ?? "electron-default"), - xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, - xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + xdgCurrentDesktop: env.XDG_CURRENT_DESKTOP ?? null, + xdgSessionDesktop: env.XDG_SESSION_DESKTOP ?? null, }); } diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 8566613dd6069..ebb32bda43ced 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { afterEach, beforeEach, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -29,7 +29,13 @@ const environmentFor = ( } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); const commandIn = ( - input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, + input: { + home: string; + baseDir?: string; + platform?: NodeJS.Platform; + isPackaged?: boolean; + env?: NodeJS.ProcessEnv; + }, spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], ) => Effect.gen(function* () { @@ -42,6 +48,7 @@ const commandIn = ( DesktopEnvironment.DesktopEnvironment, environmentFor(path, { ...input, baseDir }), ), + Effect.provideService(HostProcess.Environment, input.env ?? { PATH: "" }), ); return yield* spawner ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) @@ -84,14 +91,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -const machinePath = process.env.PATH; -beforeEach(() => { - process.env.PATH = ""; -}); -afterEach(() => { - process.env.PATH = machinePath; -}); - it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("links the launcher onto PATH and removes only that link", () => Effect.gen(function* () { @@ -124,7 +123,8 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - const command = yield* commandIn({ home }); + const env: NodeJS.ProcessEnv = { PATH: "" }; + const command = yield* commandIn({ home, env }); const theirs = path.join(home, ".local", "bin", "t3"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); yield* fs.writeFileString(theirs, "npm's t3\n"); @@ -139,7 +139,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const launcher = path.join(home, ".t3", "bin", "t3"); yield* fs.writeFileString(launcher, "npm's t3\n"); - process.env.PATH = path.dirname(launcher); + env.PATH = path.dirname(launcher); expect(yield* command.state).toMatchObject({ onPath: false, shadowedBy: launcher }); expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); yield* command.uninstall; @@ -173,9 +173,9 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); + const env = { PATH: [shadow, path.join(home, ".local", "bin")].join(":") }; - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); const theirs = path.join(shadow, "t3"); expect((yield* command.state).shadowedBy).toBe(theirs); const error = yield* Effect.flip(command.install); @@ -209,9 +209,9 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); + const env = { PATH: [folder, plain, shadow, path.join(home, ".local", "bin")].join(":") }; - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); const error = yield* Effect.flip(command.install); @@ -231,9 +231,9 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); const launcher = path.join(home, ".t3", "bin", "t3"); - process.env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); + const env = { PATH: [path.dirname(launcher), path.join(home, ".local", "bin")].join(":") }; - const command = yield* commandIn({ home }); + const command = yield* commandIn({ home, env }); const installed = yield* command.install; expect(yield* fs.exists(launcher)).toBe(true); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index f53895a60a570..ea95c6a94cc27 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -2,6 +2,7 @@ import * as NodeFSP from "node:fs/promises"; import type { DesktopCliCommandState } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -66,6 +67,7 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const env = yield* HostProcess.Environment; const path = environment.path; const windows = environment.platform === "win32"; const launcher = DesktopCliShim.launcherPath(environment); @@ -108,7 +110,7 @@ export const make = Effect.gen(function* () { const powershell = (env: Record) => Effect.scoped( Effect.gen(function* () { - const root = process.env.SystemRoot ?? process.env.WINDIR; + const root = env.SystemRoot ?? env.WINDIR; const handle = yield* spawner.spawn( ChildProcess.make( root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", @@ -144,7 +146,7 @@ export const make = Effect.gen(function* () { ); const firstOnPath = Effect.gen(function* () { - for (const directory of pathEntries(process.env.PATH, ":")) { + for (const directory of pathEntries(env.PATH, ":")) { const candidate = path.join(directory, "t3"); if (yield* isExecutableFile(candidate)) return Option.some(candidate); } @@ -239,7 +241,7 @@ export const make = Effect.gen(function* () { `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, ); } - const onPath = pathEntries(process.env.PATH, ":"); + const onPath = pathEntries(env.PATH, ":"); const candidates = unixCandidates(environment.homeDirectory, environment.platform); for (const directory of [ ...candidates.filter((candidate) => onPath.includes(candidate)), diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index d77544f54b69a..f49e2c47a7c4d 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -53,6 +53,7 @@ export class DesktopPreReadyElectronOptions extends Context.Service< export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; + const env = yield* HostProcess.Environment; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { const linuxPasswordStoreCommandLine = platform === "linux" @@ -63,7 +64,7 @@ export const make = Effect.gen(function* () { if (linux !== null) { DesktopEarlyElectronStartup.restoreEarlyLinuxDeviceScaleFactor({ - env: process.env, + env, homeDirectory, joinPath: NodePath.posix.join, commandLine: Electron.app.commandLine, @@ -77,8 +78,7 @@ export const make = Effect.gen(function* () { // have removed the executable referenced by the previous launch's entry. try { const applicationsDir = NodePath.posix.join( - process.env.XDG_DATA_HOME?.trim() || - NodePath.posix.join(homeDirectory, ".local", "share"), + env.XDG_DATA_HOME?.trim() || NodePath.posix.join(homeDirectory, ".local", "share"), "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); @@ -110,9 +110,7 @@ export const make = Effect.gen(function* () { appVersion: Electron.app.getVersion(), }).displayName, execTarget: - process.env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || - process.env.APPIMAGE?.trim() || - process.execPath, + env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || env.APPIMAGE?.trim() || process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), @@ -126,7 +124,7 @@ export const make = Effect.gen(function* () { } // Chromium keeps only the last --disable-features value, so pass the merged list. const disableFeatures = DesktopEarlyElectronStartup.resolveLinuxDisableFeaturesSwitch({ - env: process.env, + env, ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"), enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"), disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index b0c042c05f5ca..e71d838188706 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -93,14 +94,6 @@ function layerEnvironment( ); } -const restoreEnv = (name: string, value: string | undefined) => { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -}; - const withHarness = ( effect: Effect.Effect< A, @@ -692,49 +685,38 @@ describe("DesktopBackendConfiguration", () => { prefix: "t3-desktop-backend-config-test-", }); - const previousWslEnv = process.env.WSLENV; - const previousDisabled = process.env.OTEL_SDK_DISABLED; - const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; - const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; - try { - delete process.env.WSLENV; - process.env.OTEL_SDK_DISABLED = "true"; - process.env.T3CODE_TELEMETRY_ENABLED = "false"; - process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.env.OTEL_SDK_DISABLED, "true"); - assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); - assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); - assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); - assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); - assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.env.OTEL_SDK_DISABLED, "true"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); + assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), ), - ); - } finally { - restoreEnv("WSLENV", previousWslEnv); - restoreEnv("OTEL_SDK_DISABLED", previousDisabled); - restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); - restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); - } + ), + Effect.provideService(HostProcess.Environment, { + OTEL_SDK_DISABLED: "true", + T3CODE_TELEMETRY_ENABLED: "false", + OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=staging", + }), + ); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); @@ -745,63 +727,48 @@ describe("DesktopBackendConfiguration", () => { prefix: "t3-desktop-backend-config-test-", }); - const previousWslEnv = process.env.WSLENV; - const previousOpenAiKey = process.env.OPENAI_API_KEY; - const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; - const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; - const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; - const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); - try { - for (const [name] of ambientOtel) delete process.env[name]; - process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; - process.env.OPENAI_API_KEY = "openai-key"; - process.env.ANTHROPIC_API_KEY = "anthropic-key"; - process.env.T3CODE_OTLP_HEADERS = 'authorization="Bearer%20my-token"'; - process.env.T3CODE_OTLP_PROTOCOL = "http/protobuf"; - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.executablePath, "wsl.exe"); - assert.equal(config.bootstrap.port, 5050); - assert.equal(config.bootstrap.host, "0.0.0.0"); - assert.equal(config.bootstrap.tailscaleServeEnabled, false); - assert.notProperty(config.bootstrap, "desktopTelemetryFd"); - assert.notProperty(config.bootstrap, "resourceMonitorPath"); - assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); - assert.equal(config.env.OPENAI_API_KEY, "openai-key"); - assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); - assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); - assert.equal( - config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", - ); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.executablePath, "wsl.exe"); + assert.equal(config.bootstrap.port, 5050); + assert.equal(config.bootstrap.host, "0.0.0.0"); + assert.equal(config.bootstrap.tailscaleServeEnabled, false); + assert.notProperty(config.bootstrap, "desktopTelemetryFd"); + assert.notProperty(config.bootstrap, "resourceMonitorPath"); + assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); + assert.equal(config.env.OPENAI_API_KEY, "openai-key"); + assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); + assert.equal( + config.env.WSLENV, + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", + ); + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), ), - ); - } finally { - restoreEnv("WSLENV", previousWslEnv); - restoreEnv("OPENAI_API_KEY", previousOpenAiKey); - restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); - restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); - restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); - for (const [name, value] of ambientOtel) restoreEnv(name, value); - } + ), + Effect.provideService(HostProcess.Environment, { + WSLENV: "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u", + OPENAI_API_KEY: "openai-key", + ANTHROPIC_API_KEY: "anthropic-key", + T3CODE_OTLP_HEADERS: 'authorization="Bearer%20my-token"', + T3CODE_OTLP_PROTOCOL: "http/protobuf", + }), + ); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 1789c31eb4074..abadf58f3f02f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -2,6 +2,7 @@ import * as NodeOS from "node:os"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -562,6 +563,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const env = yield* HostProcess.Environment; // wslhost loopback forwarding is unreliable on some Windows hosts; advertise the WSL IP instead. const wslBindHost = "0.0.0.0"; @@ -633,7 +635,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const forwardedEnv: Record = {}; const forwardedEnvNames: string[] = []; for (const name of WSL_FORWARDED_ENV_NAMES) { - const value = process.env[name]; + const value = env[name]; if (value !== undefined && value.length > 0) { forwardedEnv[name] = value; forwardedEnvNames.push(name); @@ -641,7 +643,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } const parentEnvWithoutT3Home: Record = {}; - for (const [key, value] of Object.entries(process.env)) { + for (const [key, value] of Object.entries(env)) { if (key === "T3CODE_HOME") continue; parentEnvWithoutT3Home[key] = value; } @@ -658,7 +660,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...forwardedEnv, ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), }, - // env already excludes T3CODE_HOME; merging process.env would restore it. + // env already excludes T3CODE_HOME; merging the host env would restore it. extendEnv: false, bootstrap, bootstrapDelivery: "stdin" as const, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index a3d4db0bb4dc2..42a78bd47fec0 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1619,9 +1619,10 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((state) => Effect.gen(function* () { + const env = yield* HostProcess.Environment; const linuxDesktop = state.mode === "portal" - ? process.env.XDG_CURRENT_DESKTOP?.toLowerCase() + ? env.XDG_CURRENT_DESKTOP?.toLowerCase() .split(":") .find( (name) => diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index f65fc11f59a75..dfa903ba2ddf2 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1,3 +1,4 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -1141,7 +1142,7 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; - const windir = process.env.WINDIR ?? "C:\\Windows"; + const windir = (yield* HostProcess.Environment).WINDIR ?? "C:\\Windows"; const provideSpawner = ( effect: Effect.Effect, diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 68a622c541fe9..ab87baa49daa8 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -26,6 +26,7 @@ import { type OrchestrationV2ThreadStreamItem, type OrchestrationV2ShellStreamItem, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -453,9 +454,10 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => } const fs = yield* FileSystem.FileSystem; const report = formatTransferBudgetReport(runs); + const env = yield* HostProcess.Environment; for (const [path, contents] of [ - [process.env.T3CODE_TRANSFER_BUDGET_REPORT_PATH, report], - [process.env.T3CODE_TRANSFER_BUDGET_RESULT_PATH, formatTransferBudgetResult(runs)], + [env.T3CODE_TRANSFER_BUDGET_REPORT_PATH, report], + [env.T3CODE_TRANSFER_BUDGET_RESULT_PATH, formatTransferBudgetResult(runs)], ]) if (path && contents) yield* fs.writeFileString(path, contents); yield* Effect.log(report); diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index de3fdb1a31404..fd04a2b642619 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -2,6 +2,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Deferred from "effect/Deferred"; @@ -467,6 +468,7 @@ function emitGrokKillTaskScenario( } const program = Effect.gen(function* () { + const hostEnv = yield* HostProcess.Environment; const agent = yield* EffectAcpAgent.AcpAgent; const nativeCancelRequested = yield* Deferred.make(); const nativeCancelRelease = yield* Deferred.make(); @@ -512,7 +514,7 @@ const program = Effect.gen(function* () { parameterizedModelPicker = request.capabilities?._meta?.parameterizedModelPicker === true; return { protocolVersion: 2, - info: { name: "t3-acp-mock-agent", version: process.env.T3_ACP_AGENT_VERSION ?? "0.0.0" }, + info: { name: "t3-acp-mock-agent", version: hostEnv.T3_ACP_AGENT_VERSION ?? "0.0.0" }, ...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }), capabilities: { session: { @@ -550,7 +552,7 @@ const program = Effect.gen(function* () { if (antigravityProfile) { if ( request.methodId !== "oauth-personal" && - !(request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY) + !(request.methodId === "gemini-api-key" && hostEnv.GEMINI_API_KEY) ) { return yield* AcpError.AcpRequestError.invalidParams( `Mock Antigravity rejected auth method ${request.methodId}.`, @@ -811,7 +813,7 @@ const program = Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); beginAcpMockPrompt(cancelledSessions, requestedSessionId); promptCount += 1; - const idleError = process.env.T3_ACP_IDLE_ERROR; + const idleError = hostEnv.T3_ACP_IDLE_ERROR; if (idleError === "grok-background" && promptCount === 1) { // The structured completion lands before the prompt settles, so it is buffered. const taskId = "call-idle-error-background"; diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index aa2c17af06c34..79401ac4eed92 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -1487,11 +1487,12 @@ const runScenario = Effect.fn("record-codex-app-server-replay-fixture.runScenari }); const program = Effect.gen(function* () { + const hostEnv = yield* HostProcess.Environment; const path = yield* Path.Path; const requestedScenarios = parseScenarios(); const allScenarios = scenarios(); - const outDir = readArgValue("--out-dir") ?? process.env.T3_CODEX_REPLAY_OUT_DIR; - const singleOutPath = readArgValue("--out") ?? process.env.T3_CODEX_REPLAY_OUT; + const outDir = readArgValue("--out-dir") ?? hostEnv.T3_CODEX_REPLAY_OUT_DIR; + const singleOutPath = readArgValue("--out") ?? hostEnv.T3_CODEX_REPLAY_OUT; const selected = allScenarios.filter((scenario) => requestedScenarios.includes(scenario.name)); if (selected.length === 0) { diff --git a/apps/server/scripts/record-fx-acp-replay-fixture.ts b/apps/server/scripts/record-fx-acp-replay-fixture.ts index 46af7ce49ee25..81287684620d2 100644 --- a/apps/server/scripts/record-fx-acp-replay-fixture.ts +++ b/apps/server/scripts/record-fx-acp-replay-fixture.ts @@ -323,6 +323,7 @@ function normalizeEntries(input: { const DEFAULT_FX_SETTINGS = Schema.decodeSync(FxSettings)({}); const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: string) { + const hostEnv = yield* HostProcess.Environment; const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName); const variant = fixture?.providers.find((provider) => provider.driver === FX_DRIVER_KIND); if (fixture === undefined || variant === undefined) { @@ -359,7 +360,7 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str }; const tee = makeWireTee(); - const settings = { ...DEFAULT_FX_SETTINGS, binaryPath: process.env.T3_FX_BIN ?? "fx" }; + const settings = { ...DEFAULT_FX_SETTINGS, binaryPath: hostEnv.T3_FX_BIN ?? "fx" }; const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { const environment = yield* HostProcess.Environment; @@ -455,7 +456,7 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str isRecord(initialize.frame.result.agentInfo) ? initialize.frame.result.agentInfo : {}; - const home = process.env.HOME ?? ""; + const home = hostEnv.HOME ?? ""; const transcript = { provider: FX_DRIVER_KIND, protocol: ACP_PROTOCOL, @@ -473,11 +474,11 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str entries, workspace: realWorkspace, home, - user: process.env.USER ?? "", + user: hostEnv.USER ?? "", skillNames: yield* readUserSkillNames(home), - ...(process.env.AI_GATEWAY_API_KEY === undefined + ...(hostEnv.AI_GATEWAY_API_KEY === undefined ? {} - : { gatewayApiKey: process.env.AI_GATEWAY_API_KEY }), + : { gatewayApiKey: hostEnv.AI_GATEWAY_API_KEY }), runtimeInstructions: buildRuntimeInstructions({ harness: "fx", model: variant.modelSelection.model, @@ -493,10 +494,7 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str Effect.option, ); if (liveFailure._tag === "Some") { - const dumpPath = path.join( - process.env.TMPDIR ?? "/tmp", - `fx-replay-${fixtureName}-failed.json`, - ); + const dumpPath = path.join(hostEnv.TMPDIR ?? "/tmp", `fx-replay-${fixtureName}-failed.json`); yield* fs.writeFileString(dumpPath, encodeJson(transcript)); yield* Console.log( `Live orchestration failed ${fixtureName} assertions; left ${outputPath} unchanged. Dump: ${dumpPath}`, diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index d9ce096a16504..da64a0b48cf03 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -371,6 +371,7 @@ function normalizeEntries(input: { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: string) { + const hostEnv = yield* HostProcess.Environment; const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName); const variant = fixture?.providers.find((provider) => provider.driver === GROK_PROVIDER); if (fixture === undefined || variant === undefined) { @@ -404,7 +405,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s }; const tee = makeWireTee(); - const settings = { ...DEFAULT_GROK_SETTINGS, binaryPath: process.env.T3_GROK_BIN ?? "grok" }; + const settings = { ...DEFAULT_GROK_SETTINGS, binaryPath: hostEnv.T3_GROK_BIN ?? "grok" }; const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { const environment = yield* HostProcess.Environment; @@ -522,8 +523,8 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s ...normalizeEntries({ entries, workspace: realWorkspace, - home: process.env.HOME ?? "", - user: process.env.USER ?? "", + home: hostEnv.HOME ?? "", + user: hostEnv.USER ?? "", runtimeInstructions: buildRuntimeInstructions({ harness: "Grok", model: variant.modelSelection.model, diff --git a/apps/server/scripts/record-kiro-acp-replay-fixture.ts b/apps/server/scripts/record-kiro-acp-replay-fixture.ts index 41262fc8dadd1..e14880f5a7944 100644 --- a/apps/server/scripts/record-kiro-acp-replay-fixture.ts +++ b/apps/server/scripts/record-kiro-acp-replay-fixture.ts @@ -408,6 +408,7 @@ const KIRO_RECORDING_SETTINGS = Schema.decodeSync(KiroSettings)({ }); const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: string) { + const hostEnv = yield* HostProcess.Environment; const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName); const variant = fixture?.providers.find((provider) => provider.driver === KIRO_PROVIDER); if (fixture === undefined || variant === undefined) { @@ -544,7 +545,7 @@ const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: s scenario: fixtureName, metadata: { generatedBy: "live-kiro-recorder", - kiroVersion: process.env.T3_KIRO_VERSION ?? "unknown", + kiroVersion: hostEnv.T3_KIRO_VERSION ?? "unknown", normalization: "Session ids are fixed sess_ UUIDs and other UUIDs (message and request ids) are numbered in first-seen order, the workspace is , HOME is /home/kiro-replay and the recording user is kiro-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . Kiro's log directory, user-scoped commands and agents, and the _kiro/* broadcasts T3 ignores (governance, steering documents, powers, tools, hooks, MCP status, session roster, memory sync) are removed. Timestamps are kept as recorded.", droppedFrames, @@ -553,8 +554,8 @@ const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: s ...normalizeEntries({ entries, workspace: realWorkspace, - home: process.env.HOME ?? "", - user: process.env.USER ?? "", + home: hostEnv.HOME ?? "", + user: hostEnv.USER ?? "", runtimeInstructions: buildRuntimeInstructions({ harness: "Kiro", model: variant.modelSelection.model, diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 2dba3b0eb061a..eecdd7fbe5018 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -41,6 +41,16 @@ const liveProcess = Effect.acquireRelease( const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState)); +// Node reads this variable itself, so the test must set the real process environment. +const setProcessEnv = (name: string, value: string) => { + const previous = process.env[name]; + process.env[name] = value; + return () => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }; +}; + const runCli = (args: ReadonlyArray, env: Record = {}) => Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( Effect.provide( @@ -229,16 +239,9 @@ describe("t3 app", () => { "t3-app-unsafe-", (root) => Effect.gen(function* () { - const previousTmpdir = process.env.TMPDIR; yield* Effect.acquireRelease( - Effect.sync(() => { - process.env.TMPDIR = root; - }), - () => - Effect.sync(() => { - if (previousTmpdir === undefined) delete process.env.TMPDIR; - else process.env.TMPDIR = previousTmpdir; - }), + Effect.sync(() => setProcessEnv("TMPDIR", root)), + (restore) => Effect.sync(restore), ); const baseDir = NodePath.join(root, ".t3"); const userId = yield* HostProcess.UserId; diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index d2ad673408d40..6b94c126f2967 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -47,7 +47,10 @@ it.live.runIf(HostProcess.Platform.defaultValue() === "win32")( yield* fs.makeDirectory(path.join(runtimeDir, "versions", "1.0.0"), { recursive: true }); yield* fs.writeFileString(path.join(runtimeDir, "versions", "1.0.0", "t3.exe"), ""); - const child = spawnWindowsRemoval(process.env.ComSpec ?? "cmd.exe", runtimeDir); + const child = spawnWindowsRemoval( + (yield* HostProcess.Environment).ComSpec ?? "cmd.exe", + runtimeDir, + ); yield* Effect.callback((resume) => { child.once("error", (error) => resume(Effect.fail(error))); child.once("exit", (code) => diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index ad7761f7c4549..a19f6bde0855f 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -80,6 +81,7 @@ const buildCloudManagedEndpointRuntime = ( relayClientLayer = layerRelayClientAvailable, httpClient = unusedHttpClient, secretStoreLayer = emptySecretStoreLayer, + env: NodeJS.ProcessEnv = {}, ) => Effect.gen(function* () { const context = yield* Layer.build( @@ -90,7 +92,7 @@ const buildCloudManagedEndpointRuntime = ( return yield* Effect.service(ManagedEndpointRuntime.CloudManagedEndpointRuntime).pipe( Effect.provide(context), ); - }); + }).pipe(Effect.provideService(HostProcess.Environment, env)); function makeHandle(input: { readonly pid: number; @@ -345,19 +347,6 @@ describe("CloudManagedEndpointRuntime", () => { isRunning?: Effect.Effect; } = {}, ) { - yield* Effect.acquireRelease( - Effect.sync(() => { - const previous = process.env.TUNNEL_TRANSPORT_PROTOCOL; - if (options.transport === undefined) delete process.env.TUNNEL_TRANSPORT_PROTOCOL; - else process.env.TUNNEL_TRANSPORT_PROTOCOL = options.transport; - return previous; - }), - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.TUNNEL_TRANSPORT_PROTOCOL; - else process.env.TUNNEL_TRANSPORT_PROTOCOL = previous; - }), - ); const spawned: Array = []; const killed: Array = []; const outputs: Array> = []; @@ -410,13 +399,21 @@ describe("CloudManagedEndpointRuntime", () => { index, lines + "2026-10-16T20:30:00Z INF Registered tunnel connection connIndex=0\n", ).pipe(Effect.andThen(Stream.runHead(runtime.tunnelConnected))); - return { spawner, spawned, killed, exits, started, emit, observe }; + const env: NodeJS.ProcessEnv = + options.transport === undefined ? {} : { TUNNEL_TRANSPORT_PROTOCOL: options.transport }; + return { spawner, env, spawned, killed, exits, started, emit, observe }; }); it.effect("preserves an explicit transport choice despite repeated QUIC stalls", () => Effect.gen(function* () { const fixture = yield* makeTransportSpawner({ transport: "quic" }); - const runtime = yield* buildCloudManagedEndpointRuntime(fixture.spawner); + const runtime = yield* buildCloudManagedEndpointRuntime( + fixture.spawner, + layerRelayClientAvailable, + unusedHttpClient, + emptySecretStoreLayer, + fixture.env, + ); yield* runtime.applyConfig(tunnelConfig); yield* fixture.observe(runtime, 0, quicDialWarning.repeat(30)); yield* fixture.observe(runtime, 0, quicDatagramWarning.repeat(30)); @@ -442,12 +439,13 @@ describe("CloudManagedEndpointRuntime", () => { context.pipe(Context.get(ServerSecretStore.ServerSecretStore)), ); const fixture = yield* makeTransportSpawner(); - const start = (spawner = fixture.spawner) => + const start = (spawner = fixture.spawner, env = fixture.env) => buildCloudManagedEndpointRuntime( spawner, layerRelayClientAvailable, unusedHttpClient, secretStoreLayer, + env, ); yield* Effect.gen(function* () { const runtime = yield* start(); @@ -464,7 +462,7 @@ describe("CloudManagedEndpointRuntime", () => { for (const transport of [undefined, "quic", "http2", "auto", undefined]) { yield* Effect.gen(function* () { const next = yield* makeTransportSpawner(transport ? { transport } : {}); - const restarted = yield* start(next.spawner); + const restarted = yield* start(next.spawner, next.env); yield* restarted.applyConfig(tunnelConfig); if (transport === undefined) { expect(next.spawned[0]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index ca0699787612d..f6bca362af6df 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -1,5 +1,6 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -216,7 +217,8 @@ export const make = Effect.gen(function* () { const retryLoopRunningRef = yield* Ref.make(false); const notReadyChecksBeforeRestartRef = yield* Ref.make(CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); // An explicit cloudflared transport in the environment is the user's choice. - const transportChosenByUser = process.env.TUNNEL_TRANSPORT_PROTOCOL !== undefined; + const env = yield* HostProcess.Environment; + const transportChosenByUser = env.TUNNEL_TRANSPORT_PROTOCOL !== undefined; const previousFallback = transportChosenByUser ? Option.none() : yield* secrets.get(HTTP2_FALLBACK_STATE).pipe( @@ -721,7 +723,7 @@ export const make = Effect.gen(function* () { { detached: false, env: { - ...process.env, + ...env, TUNNEL_TOKEN: config.connectorToken, }, shell: false, diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index d8c99eb3321f2..bf180c9004dfd 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -77,10 +77,11 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" "a!b!c", "", ]; + const hostPath = (yield* HostProcess.Environment).PATH ?? ""; yield* Effect.promise(() => expect( exec(path.join(shim, "agent-device"), args, { - env: { PATH: process.env.PATH ?? "", HOME: temp }, + env: { PATH: hostPath, HOME: temp }, }), ).rejects.toMatchObject({ code: 23, stdout: `${JSON.stringify(args)}\n`, stderr: "" }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 7ec4b4effbdaa..356f27f67a35f 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -53,6 +53,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { type ChangeRequestTerminology, @@ -1098,7 +1099,8 @@ export const make = Effect.gen(function* () { ), ), ); - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + const env = yield* HostProcess.Environment; + const tempDir = env.TMPDIR ?? env.TEMP ?? env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); const normalizeStatusCacheKey = canonicalizeExistingPath; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 0c36d4a5869b6..1ea0a62e168ce 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -84,7 +84,7 @@ it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", ( prefix: "t3-claude-fork-config-", }); const projectDir = yield* writeClaudeSession(configDir); - const hostConfigDir = process.env.CLAUDE_CONFIG_DIR; + const hostConfigDir = (yield* HostProcess.Environment).CLAUDE_CONFIG_DIR; const inProcessFork = vi.spyOn(ClaudeSdk, "forkSession"); yield* Effect.addFinalizer(() => Effect.sync(() => inProcessFork.mockRestore())); @@ -110,7 +110,7 @@ it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", ( ?.match(/"uuid"\s*:\s*"([^"]+)"/)?.[1]; assert.isString(inheritedHead); assert.equal(forked.headMessageId, inheritedHead); - assert.equal(process.env.CLAUDE_CONFIG_DIR, hostConfigDir); + assert.equal((yield* HostProcess.Environment).CLAUDE_CONFIG_DIR, hostConfigDir); assert.equal(inProcessFork.mock.calls.length, 0); }).pipe(Effect.provide(forkRunnerLayer)), ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 7990537355ed2..bf6f43a3fdb30 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -306,25 +307,15 @@ const withGitEnv = ( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const emptyConfig = yield* fileSystem.makeTempFileScoped({ prefix: "t3-gitconfig-" }); - return yield* Effect.acquireUseRelease( - Effect.sync(() => { - const saved = GIT_IDENTITY_KEYS.map((key) => [key, process.env[key]] as const); - for (const key of GIT_IDENTITY_KEYS) delete process.env[key]; - Object.assign(process.env, { - GIT_CONFIG_GLOBAL: emptyConfig, - GIT_CONFIG_NOSYSTEM: "1", - ...env, - }); - return saved; + const hostEnv: NodeJS.ProcessEnv = { ...(yield* HostProcess.Environment) }; + for (const key of GIT_IDENTITY_KEYS) delete hostEnv[key]; + return yield* effect.pipe( + Effect.provideService(HostProcess.Environment, { + ...hostEnv, + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + ...env, }), - () => effect, - (saved) => - Effect.sync(() => { - for (const [key, value] of saved) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }), ); }); @@ -354,7 +345,7 @@ it.effect("starts a named project as a committed repository, and suffixes a take yield* fileSystem.writeFileString(attributes, "* text=auto eol=crlf\n"); yield* git(baseDir, ["config", "--file", crlfConfig, "core.safecrlf", "true"]); yield* git(baseDir, ["config", "--file", crlfConfig, "core.attributesFile", attributes]); - process.env.GIT_CONFIG_GLOBAL = crlfConfig; + (yield* HostProcess.Environment).GIT_CONFIG_GLOBAL = crlfConfig; const first = yield* folders.createNamedProject({ name: "Pinball Stats" }); const second = yield* folders.createNamedProject({ name: "pinball stats" }); @@ -427,7 +418,10 @@ it.effect("keeps a named project and reports why when Git cannot commit", () => path.join(baseDir, "no-identity.gitconfig"), "[user]\n\tuseConfigOnly = true\n", ); - process.env.GIT_CONFIG_GLOBAL = path.join(baseDir, "no-identity.gitconfig"); + (yield* HostProcess.Environment).GIT_CONFIG_GLOBAL = path.join( + baseDir, + "no-identity.gitconfig", + ); const result = yield* folders.createNamedProject({ name: "No Identity" }); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index d71421debc727..bf3c9318877bb 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -4,6 +4,7 @@ import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; @@ -76,7 +77,7 @@ it.effect("keeps the probe subprocess alive while gateway model discovery can fi ); const capabilities = yield* probeClaudeCapabilities( yield* decodeClaudeSettings({ binaryPath: "claude" }), - { PATH: process.env.PATH, CLAUDE_CONFIG_DIR: configDir }, + { PATH: (yield* HostProcess.Environment).PATH, CLAUDE_CONFIG_DIR: configDir }, ); assert.deepEqual( capabilities.models?.map((model) => model.value), diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 754716ac53106..2c697f8fa8531 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -95,6 +95,7 @@ it.live.each( email: null, }); yield* store.set(new TextEncoder().encode(json)); + const hostEnv = yield* HostProcess.Environment; const ambient = { CODEX_HOME: "/user/.codex", ACCESS_TOKEN: "ambient-bearer", @@ -102,8 +103,8 @@ it.live.each( OPENAI_API_KEY: "dummy-global-key", OPENAI_BASE_URL: "https://user-proxy.test", T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", - PATH: process.env.PATH, - SystemRoot: process.env.SystemRoot, + PATH: hostEnv.PATH, + SystemRoot: hostEnv.SystemRoot, }; const runtime = yield* makeCodexManagedRuntime({ instanceId, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 8957c12747a89..f06c4f47570dd 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -34,6 +34,16 @@ const mockRuntimeOptions = { authMethodId: "test", } satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; +// The spawner extends the real process environment, so the test must set it there. +const setProcessEnv = (name: string, value: string) => { + const previous = process.env[name]; + process.env[name] = value; + return () => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }; +}; + describe("AcpSessionRuntime", () => { it.effect.each(["session/new", "session/resume"] as const)( "buffers root metadata while %s startup is still pending", @@ -422,16 +432,8 @@ describe("AcpSessionRuntime", () => { it.effect("does not restore ambient variables to a sanitized child environment", () => Effect.gen(function* () { yield* Effect.acquireRelease( - Effect.sync(() => { - const previous = process.env.T3_ACP_RUNTIME_AMBIENT; - process.env.T3_ACP_RUNTIME_AMBIENT = "sentinel"; - return previous; - }), - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.T3_ACP_RUNTIME_AMBIENT; - else process.env.T3_ACP_RUNTIME_AMBIENT = previous; - }), + Effect.sync(() => setProcessEnv("T3_ACP_RUNTIME_AMBIENT", "sentinel")), + (restore) => Effect.sync(restore), ); const runtime = yield* AcpSessionRuntime.make({ ...mockRuntimeOptions, diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 1da6c2e195140..274b86573ed3c 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -6,6 +6,7 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { SERVER_EXIT_CODE_STATE_DIR_OWNED } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -379,7 +380,7 @@ if (context.update?.status === "pending") { { token: "initial", update: "pending" }, { token: "initial", update: "rolled-back" }, ]); - assert.equal(process.env.T3CODE_TEST_SERVICE_TOKEN, "inherited"); + assert.equal((yield* HostProcess.Environment).T3CODE_TEST_SERVICE_TOKEN, "inherited"); assert.equal(yield* fs.readFileString(envPath), "T3CODE_TEST_SERVICE_TOKEN=changed\n"); }), ); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 197d412d93405..78b8724e1f93c 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -75,136 +75,34 @@ function withFakeClaudeEnv( const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort); const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":"; - const previousPath = process.env.PATH; - const previousRejectsNoSessionPersistence = - process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; - const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; - const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; - const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; - const previousCwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; - - yield* Effect.acquireRelease( - Effect.sync(() => { - process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`; - process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; - - if (input.rejectsNoSessionPersistence) { - process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = "1"; - } else { - delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; - } - - if (input.exitCode !== undefined) { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); - } else { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } - - if (input.stderr !== undefined) { - process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; - } else { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } - - if (input.argsMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } - - if (input.argsMustNotContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } - - if (input.stdinMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } - - if (input.cwdMustNotBe !== undefined) { - process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE = input.cwdMustNotBe; - } else { - delete process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; - } - - if (input.configDirMustBe !== undefined) { - process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe; - } else { - delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; - } - }), - () => - Effect.sync(() => { - process.env.PATH = previousPath; - - if (previousRejectsNoSessionPersistence === undefined) { - delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE; - } else { - process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = - previousRejectsNoSessionPersistence; - } - - if (previousOutput === undefined) { - delete process.env.T3_FAKE_CLAUDE_OUTPUT; - } else { - process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; - } - - if (previousExitCode === undefined) { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } else { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; - } - - if (previousStderr === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } else { - process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; - } - - if (previousArgsMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; - } - - if (previousArgsMustNotContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; - } - - if (previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; - } - - if (previousCwdMustNotBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; - } else { - process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE = previousCwdMustNotBe; - } - - if (previousConfigDirMustBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; - } else { - process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; - } - }), - ); + const hostEnv = yield* HostProcess.Environment; + const fakeClaudeEnv: Record = { + T3_FAKE_CLAUDE_OUTPUT: input.output, + T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE: input.rejectsNoSessionPersistence + ? "1" + : undefined, + T3_FAKE_CLAUDE_EXIT_CODE: input.exitCode === undefined ? undefined : String(input.exitCode), + T3_FAKE_CLAUDE_STDERR: input.stderr, + T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN: input.argsMustContain, + T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN: input.argsMustNotContain, + T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN: input.stdinMustContain, + T3_FAKE_CLAUDE_CWD_MUST_NOT_BE: input.cwdMustNotBe, + T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE: input.configDirMustBe, + }; + const environment: NodeJS.ProcessEnv = { + ...Object.fromEntries( + Object.entries(hostEnv).filter(([name]) => !name.startsWith("T3_FAKE_CLAUDE_")), + ), + ...Object.fromEntries( + Object.entries(fakeClaudeEnv).filter(([, value]) => value !== undefined), + ), + PATH: `${binDir}${pathDelimiter}${hostEnv.PATH ?? ""}`, + }; const config = decodeClaudeSettings(input.claudeConfig ?? {}); const textGeneration = yield* makeClaudeTextGeneration( config, - undefined, + environment, Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), ); return yield* effectFn(textGeneration); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index fed1c484d0d16..b79f93c192d4e 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -277,18 +277,11 @@ it.effect("checkpoint recovery discovers nested HEAD independently of inherited const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); yield* git(["init", "empty"]); const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - yield* Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.GIT_DIR; - process.env.GIT_DIR = path.join(cwd, ".git"); - return previous; + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }).pipe( + Effect.provideService(HostProcess.Environment, { + ...(yield* HostProcess.Environment), + GIT_DIR: path.join(cwd, ".git"), }), - () => driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.GIT_DIR; - else process.env.GIT_DIR = previous; - }), ); assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", "empty"])).stdout, ""); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 48f1f56b9b06e..a2cd548bcc0b9 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -913,8 +913,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd); const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); + const hostEnv = yield* HostProcess.Environment; const commitEnv: NodeJS.ProcessEnv = { - ...process.env, + ...hostEnv, GIT_INDEX_FILE: tempIndexPath, GIT_AUTHOR_NAME: "T3 Code", GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", @@ -1207,7 +1208,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ); if (candidates.length > CHECKPOINT_RECOVERY_MAX_CANDIDATES) return yield* error; const nestedRepoEnv: NodeJS.ProcessEnv = { - ...process.env, + ...hostEnv, GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_COMMON_DIR: undefined, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d00301e96e7d8..532a6db2b9e9f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -266,6 +266,14 @@ const git = ( return result.stdout.trim(); }); +// Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. +const withGitFileProtocol = (effect: Effect.Effect) => + Effect.flatMap(HostProcess.Environment, (env) => + effect.pipe( + Effect.provideService(HostProcess.Environment, { ...env, GIT_ALLOW_PROTOCOL: "file" }), + ), + ); + const initRepoWithCommit = ( cwd: string, ): Effect.Effect< @@ -4040,30 +4048,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const sshWrapperPath = yield* pathService.fromFileUrl( new URL("./testing/GitVcsDriverCore.fixture.sh", import.meta.url), ); - const envKeys = [ - "GCM_INTERACTIVE", - "GIT_ASKPASS", - "GIT_SSH", - "GIT_TERMINAL_PROMPT", - "SSH_ASKPASS", - "SSH_ASKPASS_REQUIRE", - "T3_TEST_SSH_ASKPASS_LOG", - ] as const; - const previousEnv = new Map(envKeys.map((key) => [key, process.env[key]])); yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]); yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]); yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]); yield* Effect.gen(function* () { - process.env.GIT_SSH = sshWrapperPath; - process.env.GCM_INTERACTIVE = "always"; - process.env.GIT_ASKPASS = "git-askpass"; - process.env.GIT_TERMINAL_PROMPT = "1"; - process.env.SSH_ASKPASS = "ssh-askpass"; - process.env.SSH_ASKPASS_REQUIRE = "force"; - process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath; - yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); assert.deepEqual((yield* fileSystem.readFileString(sshLogPath)).trim().split(/\r?\n/), [ @@ -4074,18 +4064,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "SSH_ASKPASS_REQUIRE=never", ]); }).pipe( - Effect.ensuring( - Effect.sync(() => { - for (const key of envKeys) { - const previous = previousEnv.get(key); - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } - } - }), - ), + Effect.provideService(HostProcess.Environment, { + ...(yield* HostProcess.Environment), + GIT_SSH: sshWrapperPath, + GCM_INTERACTIVE: "always", + GIT_ASKPASS: "git-askpass", + GIT_TERMINAL_PROMPT: "1", + SSH_ASKPASS: "ssh-askpass", + SSH_ASKPASS_REQUIRE: "force", + T3_TEST_SSH_ASKPASS_LOG: sshLogPath, + }), ); }), ); @@ -4488,7 +4476,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const driver = yield* GitVcsDriver.GitVcsDriver; - const serverLocale = process.env.LC_ALL ?? "unset"; + const serverLocale = (yield* HostProcess.Environment).LC_ALL ?? "unset"; const seenLocale = path.join(yield* makeTmpDir("git-hook-locale-"), "lc-all"); yield* writeTextFile( cwd, @@ -4767,19 +4755,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - // Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. - const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; - process.env.GIT_ALLOW_PROTOCOL = "file"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previousAllowedProtocol === undefined) { - delete process.env.GIT_ALLOW_PROTOCOL; - } else { - process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; - } - }), - ); - const submoduleRepo = yield* makeTmpDir("git-submodule-"); yield* initRepoWithCommit(submoduleRepo); yield* writeTextFile(submoduleRepo, ".gitattributes", "* text eol=lf\n"); @@ -4865,7 +4840,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "", ); if (progress) assert.isNotEmpty(yield* Ref.get(lines)); - }), + }).pipe(withGitFileProtocol), ); it.effect("still creates the worktree when submodule checkout fails", () => @@ -4994,18 +4969,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; - process.env.GIT_ALLOW_PROTOCOL = "file"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previousAllowedProtocol === undefined) { - delete process.env.GIT_ALLOW_PROTOCOL; - } else { - process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; - } - }), - ); - const nestedRepo = yield* makeTmpDir("git-nested-"); yield* initRepoWithCommit(nestedRepo); yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); @@ -5076,7 +5039,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { inner: false, nested: false, }); - }), + }).pipe(withGitFileProtocol), ); it.effect("still creates the worktree when the base-ref config stays locked", () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 25a757104db32..351daa0e697ff 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -977,7 +977,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); const baseEnv = { - ...process.env, + ...(yield* HostProcess.Environment), // Concurrent status polls must not take index.lock to save refreshed indexes. GIT_OPTIONAL_LOCKS: "0", ...input.env, diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 7ab7cd24a986c..c866b15f3f120 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -20,6 +20,7 @@ import { OrchestratorMcpTaskRole, ProviderInstanceId, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -2403,7 +2404,7 @@ const main = Effect.gen(function* () { } return; } - const apiKey = process.env.ANTHROPIC_API_KEY ?? ""; + const apiKey = (yield* HostProcess.Environment).ANTHROPIC_API_KEY ?? ""; const out = flags.out; if ((!apiKey && !codex) || out === undefined) { const message = diff --git a/packages/effect-acp/test/fixtures/acp-mock-peer.ts b/packages/effect-acp/test/fixtures/acp-mock-peer.ts index a75d6f5df1bc0..7509cd88457e1 100644 --- a/packages/effect-acp/test/fixtures/acp-mock-peer.ts +++ b/packages/effect-acp/test/fixtures/acp-mock-peer.ts @@ -23,6 +23,8 @@ const sessionId = "mock-session-1"; const v2Management = process.env.ACP_MOCK_V2_MANAGEMENT === "1"; const unknownVariants = process.env.ACP_MOCK_UNKNOWN_VARIANTS === "1"; const mcpOverAcp = process.env.ACP_MOCK_MCP_OVER_ACP === "1"; +const v2Diff = process.env.ACP_MOCK_V2_DIFF === "1"; +const badTypedRequest = process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1"; const program = Effect.gen(function* () { const agent = yield* AcpAgent.AcpAgent; @@ -178,7 +180,7 @@ const program = Effect.gen(function* () { }, }); - if (process.env.ACP_MOCK_V2_DIFF === "1") { + if (v2Diff) { yield* agent.client.sessionUpdate({ sessionId, update: { @@ -211,7 +213,7 @@ const program = Effect.gen(function* () { }); yield* agent.client.extRequest("x/typed_request", { - message: process.env.ACP_MOCK_BAD_TYPED_REQUEST === "1" ? 123 : "hello from typed request", + message: badTypedRequest ? 123 : "hello from typed request", }); yield* agent.client.extNotification("x/typed_notification", { diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 86c0dc7e662da..343738c800c83 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -203,12 +203,13 @@ const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { yield* fs.makeDirectory(generatedDir, { recursive: true }); }); +const githubToken = process.env.GITHUB_TOKEN; + const fetchText = Effect.fn("fetchText")(function* (url: string) { - const token = process.env.GITHUB_TOKEN; return yield* HttpClientRequest.get(url).pipe( HttpClientRequest.setHeader("user-agent", USER_AGENT), - token && url.startsWith("https://api.github.com/") - ? HttpClientRequest.bearerToken(token) + githubToken && url.startsWith("https://api.github.com/") + ? HttpClientRequest.bearerToken(githubToken) : (request) => request, HttpClient.execute, Effect.flatMap(HttpClientResponse.filterStatusOk), diff --git a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts index fed0e61440792..898c8cc094598 100644 --- a/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts +++ b/packages/effect-codex-app-server/test/examples/codex-app-server-probe.ts @@ -7,10 +7,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as CodexClient from "../../src/client.ts"; +const codexBin = process.env.CODEX_BIN ?? "codex"; + const program = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const handle = yield* spawner.spawn( - ChildProcess.make(process.env.CODEX_BIN ?? "codex", ["app-server"], { + ChildProcess.make(codexBin, ["app-server"], { cwd: process.cwd(), shell: false, }), diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index 904fbdfcb5c22..02a32ac40250d 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -91,10 +91,12 @@ const makeFakeNpmToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeNpmToolc npmPath, ); yield* fileSystem.chmod(npmPath, 0o755); + // The fake toolchain runs on the real host, not the simulated provider environment. + const hostEnv = HostProcess.Environment.defaultValue(); return { environment: { - ...process.env, - PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, + ...hostEnv, + PATH: `${toolchainBin}:${hostEnv.PATH ?? ""}`, FAKE_NPM_LOG: logPath, FAKE_NPM_PREFIX: path.join(rootDirectory, "external-npm-global"), FAKE_NPM_MANIFEST: encodeUnknownJson({ @@ -127,10 +129,12 @@ const makeFakeUvToolchain = Effect.fn("AcpRegistrySupport.test.makeFakeUvToolcha uvPath, ); yield* fileSystem.chmod(uvPath, 0o755); + // The fake toolchain runs on the real host, not the simulated provider environment. + const hostEnv = HostProcess.Environment.defaultValue(); return { environment: { - ...process.env, - PATH: `${toolchainBin}:${process.env.PATH ?? ""}`, + ...hostEnv, + PATH: `${toolchainBin}:${hostEnv.PATH ?? ""}`, FAKE_UV_LOG: logPath, FAKE_UV_BIN: path.join(rootDirectory, "external-uv-bin"), FAKE_UV_EXECUTABLE: executablePath, diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index a2cd718b9d497..5ce759ddd3c17 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -246,13 +246,12 @@ export const make = Effect.gen(function* () { ); const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { + const env = yield* HostProcess.Environment; for (const path of forgejoKeysPaths({ platform: yield* HostProcess.Platform, home: yield* HostProcess.HomeDirectory, - ...(globalThis.process.env.XDG_DATA_HOME - ? { dataHome: globalThis.process.env.XDG_DATA_HOME } - : {}), - ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}), + ...(env.XDG_DATA_HOME ? { dataHome: env.XDG_DATA_HOME } : {}), + ...(env.APPDATA ? { appData: env.APPDATA } : {}), })) { const exists = yield* fileSystem.exists(path).pipe( Effect.mapError( diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index fd241e7543bae..bb6a5c73bf5a5 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -68,7 +68,7 @@ exit 7 yield* fs.chmod(path.join(root, "release", stem, "t3"), 0o755); const tar = yield* run("tar", ["-czf", archiveName, stem], { cwd: path.join(root, "release"), - env: { PATH: process.env.PATH }, + env: { PATH: (yield* HostProcess.Environment).PATH }, }); assert.equal(tar.exitCode, 0, tar.stderr); const archive = yield* fs.readFile(path.join(root, "release", archiveName)); @@ -131,7 +131,7 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { yield* fs.makeDirectory(installed); const unpack = yield* run("tar", ["-xzf", output.tarball, "-C", installed], { cwd: root, - env: { PATH: process.env.PATH }, + env: { PATH: (yield* HostProcess.Environment).PATH }, }); assert.equal(unpack.exitCode, 0, unpack.stderr); const packageDir = path.join(installed, "package"); @@ -139,7 +139,7 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { const release = yield* serveFakeRelease(root); const home = path.join(root, "home"); const env = { - PATH: process.env.PATH, + PATH: (yield* HostProcess.Environment).PATH, T3CODE_HOME: home, T3CODE_TEST_CALLS: path.join(root, "calls"), T3CODE_RELEASE_BASE_URL: release.baseUrl, @@ -374,7 +374,7 @@ syncBuiltinESMExports(); `, ); const env = { - PATH: process.env.PATH, + PATH: (yield* HostProcess.Environment).PATH, T3CODE_HOME: home, T3CODE_RELEASE_BASE_URL: release.baseUrl, T3CODE_TEST_READY: path.join(root, "probe-ready"), diff --git a/tsconfig.effect.json b/tsconfig.effect.json index 685d7ebbb2a99..29240a31e71e8 100644 --- a/tsconfig.effect.json +++ b/tsconfig.effect.json @@ -96,6 +96,7 @@ "timeoutCatchTagToTimeoutOrElse": "error", "unnecessaryPipe": "error", "unnecessaryTypeofType": "error", + "processEnvInEffect": "error", "schemaNumber": "off" }, "overrides": [ From a597d20d9ec935f5314e39292946ccdc39db9517 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:43:33 -0800 Subject: [PATCH 2/3] refactor: read named env settings through Config Named settings that code interprets (XDG_*, WINDIR/SystemRoot, TMPDIR/TEMP/TMP, APPIMAGE, launcher path, tunnel transport, Forgejo dirs, recorder and eval settings) now come from Effect Config, and tests provide them with ConfigProvider. HostProcess.Environment stays only where the env is passed through to a child process. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopApp.ts | 12 ++++++-- apps/desktop/src/app/DesktopCliCommand.ts | 9 +++++- apps/desktop/src/app/DesktopConfig.ts | 2 +- .../src/app/DesktopPreReadyPlatform.test.ts | 19 ++++++++---- .../src/app/DesktopPreReadyPlatform.ts | 26 ++++++++++++---- .../src/snapShot/DesktopSnapShot.test.ts | 9 +++++- apps/desktop/src/snapShot/DesktopSnapShot.ts | 8 +++-- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 4 +-- .../transferBudgetV2.integration.test.ts | 11 ++++--- apps/server/scripts/acp-mock-agent.ts | 10 +++---- .../record-codex-app-server-replay-fixture.ts | 19 ++++++++++-- .../scripts/record-fx-acp-replay-fixture.ts | 30 ++++++++++++++----- .../scripts/record-grok-acp-replay-fixture.ts | 16 +++++++--- .../scripts/record-kiro-acp-replay-fixture.ts | 19 +++++++++--- apps/server/src/cli/uninstall.test.ts | 3 +- .../src/cloud/ManagedEndpointRuntime.test.ts | 10 +++++-- .../src/cloud/ManagedEndpointRuntime.ts | 5 +++- apps/server/src/git/GitManager.ts | 9 ++++-- evals/mcp-tool-use/run.ts | 4 +-- .../src/server/ForgejoCli.ts | 14 +++++++-- 20 files changed, 179 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 40202b76e2445..151c227e47ff4 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -42,6 +43,11 @@ const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; +const LinuxDesktopSessionConfig = Config.all({ + current: Config.String("XDG_CURRENT_DESKTOP").pipe(Config.option), + session: Config.String("XDG_SESSION_DESKTOP").pipe(Config.option), +}); + const makeDesktopRunId = SecureRandom.SecureRandom.pipe( Effect.flatMap((random) => random.uuidV4()), Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), @@ -307,13 +313,13 @@ const startup = Effect.gen(function* () { yield* desktopSettings.load; if (linuxElectronOptions !== null) { - const env = yield* HostProcess.Environment; + const xdgDesktop = yield* LinuxDesktopSessionConfig; yield* logStartupInfo("linux password store configured", { passwordStore: hasCommandLinePasswordStore ? "command-line" : (linuxElectronOptions.passwordStore ?? "electron-default"), - xdgCurrentDesktop: env.XDG_CURRENT_DESKTOP ?? null, - xdgSessionDesktop: env.XDG_SESSION_DESKTOP ?? null, + xdgCurrentDesktop: Option.getOrNull(xdgDesktop.current), + xdgSessionDesktop: Option.getOrNull(xdgDesktop.session), }); } diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index ea95c6a94cc27..67800d619b3a8 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises"; import type { DesktopCliCommandState } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -63,10 +64,16 @@ export class DesktopCliCommand extends Context.Service< >()("@t3tools/desktop/app/DesktopCliCommand") {} /** @public */ +const WindowsRootConfig = Config.String("SystemRoot").pipe( + Config.orElse(() => Config.String("WINDIR")), + Config.option, +); + export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // Shell discovery patches PATH into the live env after startup; a Config read would be stale. const env = yield* HostProcess.Environment; const path = environment.path; const windows = environment.platform === "win32"; @@ -110,7 +117,7 @@ export const make = Effect.gen(function* () { const powershell = (env: Record) => Effect.scoped( Effect.gen(function* () { - const root = env.SystemRoot ?? env.WINDIR; + const root = Option.getOrUndefined(yield* WindowsRootConfig); const handle = yield* spawner.spawn( ChildProcess.make( root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 11f975a6b86af..ef2909045bb4f 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -8,7 +8,7 @@ const trimNonEmptyOption = (value: string): Option.Option => { return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); }; -const trimmedString = (name: string) => +export const trimmedString = (name: string) => Config.String(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption))); const optionalBoolean = (name: string) => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index f2b176ee3761f..22bfc969c4088 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -57,6 +57,7 @@ vi.mock("node:child_process", () => ({ execFileSync: execFileSyncMock, })); +import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; describe("DesktopPreReadyPlatform", () => { @@ -124,8 +125,6 @@ describe("DesktopPreReadyPlatform", () => { { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, ])("prepares a $label Linux desktop entry before startup yields", ({ previousEntry }) => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); getSwitchValueMock.mockReturnValue(""); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; @@ -150,6 +149,12 @@ describe("DesktopPreReadyPlatform", () => { yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provide( + DesktopConfig.layerTest({ + XDG_DATA_HOME: "/xdg", + APPIMAGE: "/Applications/current.AppImage", + }), + ), ), ); const identity = yield* Effect.promise(() => portalIdentity); @@ -168,9 +173,6 @@ describe("DesktopPreReadyPlatform", () => { it.effect("points the early portal entry at a configured distribution launcher", () => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); - vi.stubEnv("T3CODE_DESKTOP_LAUNCHER_PATH", " /opt/T3 Code/t3code "); getSwitchValueMock.mockReturnValue(""); return Effect.scoped( @@ -178,6 +180,13 @@ describe("DesktopPreReadyPlatform", () => { yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provide( + DesktopConfig.layerTest({ + XDG_DATA_HOME: "/xdg", + APPIMAGE: "/Applications/current.AppImage", + T3CODE_DESKTOP_LAUNCHER_PATH: " /opt/T3 Code/t3code ", + }), + ), ), ); const entry = writeFileSyncMock.mock.calls.find( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index f49e2c47a7c4d..ff8c97b8f8260 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -2,14 +2,17 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Electron from "electron"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; +import { trimmedString } from "./DesktopConfig.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -49,11 +52,17 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} +const LinuxDesktopEntryConfig = Config.all({ + xdgDataHome: trimmedString("XDG_DATA_HOME"), + launcherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), + appImagePath: trimmedString("APPIMAGE"), +}); + /** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; - const env = yield* HostProcess.Environment; + const linuxDesktopEntryConfig = yield* LinuxDesktopEntryConfig; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { const linuxPasswordStoreCommandLine = platform === "linux" @@ -64,7 +73,7 @@ export const make = Effect.gen(function* () { if (linux !== null) { DesktopEarlyElectronStartup.restoreEarlyLinuxDeviceScaleFactor({ - env, + env: process.env, homeDirectory, joinPath: NodePath.posix.join, commandLine: Electron.app.commandLine, @@ -78,7 +87,9 @@ export const make = Effect.gen(function* () { // have removed the executable referenced by the previous launch's entry. try { const applicationsDir = NodePath.posix.join( - env.XDG_DATA_HOME?.trim() || NodePath.posix.join(homeDirectory, ".local", "share"), + Option.getOrElse(linuxDesktopEntryConfig.xdgDataHome, () => + NodePath.posix.join(homeDirectory, ".local", "share"), + ), "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); @@ -110,7 +121,12 @@ export const make = Effect.gen(function* () { appVersion: Electron.app.getVersion(), }).displayName, execTarget: - env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || env.APPIMAGE?.trim() || process.execPath, + Option.getOrUndefined( + Option.orElse( + linuxDesktopEntryConfig.launcherPath, + () => linuxDesktopEntryConfig.appImagePath, + ), + ) ?? process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), @@ -124,7 +140,7 @@ export const make = Effect.gen(function* () { } // Chromium keeps only the last --disable-features value, so pass the merged list. const disableFeatures = DesktopEarlyElectronStartup.resolveLinuxDisableFeaturesSwitch({ - env, + env: process.env, ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"), enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"), disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"), diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 721f130ef5e56..09fefe7bbf101 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -7,6 +7,7 @@ import { type DesktopSnapShotEvent, } from "@t3tools/contracts"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -1921,7 +1922,13 @@ it.effect( linuxBackendMock.mockResolvedValue(backend); yield* Effect.scoped( Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; + const service = yield* DesktopSnapShot.make.pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { XDG_CURRENT_DESKTOP: desktop } }), + ), + ), + ); yield* service.initialize; const state = yield* service.state; assert.equal(state.linuxDesktop, desktop.toLowerCase()); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 42a78bd47fec0..c447714ea5e79 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -30,6 +30,7 @@ import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Config from "effect/Config"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Ref from "effect/Ref"; @@ -768,6 +769,9 @@ export const make = Effect.gen(function* () { const captureDirectory = path.join(environment.stateDir, "snap-shots"); const linuxAppId = environment.linuxDesktopEntryName.replace(/\.desktop$/, ""); const home = yield* HostProcess.HomeDirectory; + const xdgCurrentDesktop = yield* Config.String("XDG_CURRENT_DESKTOP").pipe( + Config.withDefault(""), + ); let shortcutVerified = false; const gnomeSetupPaths = { bundle: environment.isPackaged @@ -1619,10 +1623,10 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((state) => Effect.gen(function* () { - const env = yield* HostProcess.Environment; const linuxDesktop = state.mode === "portal" - ? env.XDG_CURRENT_DESKTOP?.toLowerCase() + ? xdgCurrentDesktop + .toLowerCase() .split(":") .find( (name) => diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index dfa903ba2ddf2..307cee5ea1280 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1,4 +1,4 @@ -import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -1142,7 +1142,7 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; - const windir = (yield* HostProcess.Environment).WINDIR ?? "C:\\Windows"; + const windir = yield* Config.String("WINDIR").pipe(Config.withDefault("C:\\Windows")); const provideSpawner = ( effect: Effect.Effect, diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index ab87baa49daa8..3f1d50b510c68 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -26,8 +26,8 @@ import { type OrchestrationV2ThreadStreamItem, type OrchestrationV2ShellStreamItem, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -454,10 +454,13 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => } const fs = yield* FileSystem.FileSystem; const report = formatTransferBudgetReport(runs); - const env = yield* HostProcess.Environment; + const outputPaths = yield* Config.all({ + report: Config.String("T3CODE_TRANSFER_BUDGET_REPORT_PATH").pipe(Config.withDefault("")), + result: Config.String("T3CODE_TRANSFER_BUDGET_RESULT_PATH").pipe(Config.withDefault("")), + }); for (const [path, contents] of [ - [env.T3CODE_TRANSFER_BUDGET_REPORT_PATH, report], - [env.T3CODE_TRANSFER_BUDGET_RESULT_PATH, formatTransferBudgetResult(runs)], + [outputPaths.report, report], + [outputPaths.result, formatTransferBudgetResult(runs)], ]) if (path && contents) yield* fs.writeFileString(path, contents); yield* Effect.log(report); diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index fd04a2b642619..bbc3b2f2a8a55 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -2,7 +2,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Deferred from "effect/Deferred"; @@ -44,6 +43,9 @@ const omitInitializeModelState = process.env.T3_ACP_OMIT_INITIALIZE_MODEL_STATE const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; +const agentVersion = process.env.T3_ACP_AGENT_VERSION ?? "0.0.0"; +const geminiApiKey = process.env.GEMINI_API_KEY; +const idleError = process.env.T3_ACP_IDLE_ERROR; const emitTrackedToolCallKindThenHang = process.env.T3_ACP_EMIT_TRACKED_TOOL_CALL_KIND_THEN_HANG; const clientFsProbePath = process.env.T3_ACP_CLIENT_FS_PROBE_PATH; const clientFsProbeLogPath = process.env.T3_ACP_CLIENT_FS_PROBE_LOG_PATH; @@ -468,7 +470,6 @@ function emitGrokKillTaskScenario( } const program = Effect.gen(function* () { - const hostEnv = yield* HostProcess.Environment; const agent = yield* EffectAcpAgent.AcpAgent; const nativeCancelRequested = yield* Deferred.make(); const nativeCancelRelease = yield* Deferred.make(); @@ -514,7 +515,7 @@ const program = Effect.gen(function* () { parameterizedModelPicker = request.capabilities?._meta?.parameterizedModelPicker === true; return { protocolVersion: 2, - info: { name: "t3-acp-mock-agent", version: hostEnv.T3_ACP_AGENT_VERSION ?? "0.0.0" }, + info: { name: "t3-acp-mock-agent", version: agentVersion }, ...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }), capabilities: { session: { @@ -552,7 +553,7 @@ const program = Effect.gen(function* () { if (antigravityProfile) { if ( request.methodId !== "oauth-personal" && - !(request.methodId === "gemini-api-key" && hostEnv.GEMINI_API_KEY) + !(request.methodId === "gemini-api-key" && geminiApiKey) ) { return yield* AcpError.AcpRequestError.invalidParams( `Mock Antigravity rejected auth method ${request.methodId}.`, @@ -813,7 +814,6 @@ const program = Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); beginAcpMockPrompt(cancelledSessions, requestedSessionId); promptCount += 1; - const idleError = hostEnv.T3_ACP_IDLE_ERROR; if (idleError === "grok-background" && promptCount === 1) { // The structured completion lands before the prompt settles, so it is buffered. const taskId = "call-idle-error-background"; diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index 79401ac4eed92..202e72de32a43 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -13,6 +13,8 @@ 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 Config from "effect/Config"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -1486,13 +1488,24 @@ const runScenario = Effect.fn("record-codex-app-server-replay-fixture.runScenari yield* recorder.flush(); }); +const RecorderEnv = Config.all({ + T3_CODEX_REPLAY_OUT_DIR: Config.String("T3_CODEX_REPLAY_OUT_DIR").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + T3_CODEX_REPLAY_OUT: Config.String("T3_CODEX_REPLAY_OUT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + const program = Effect.gen(function* () { - const hostEnv = yield* HostProcess.Environment; + const recorderEnv = yield* RecorderEnv; const path = yield* Path.Path; const requestedScenarios = parseScenarios(); const allScenarios = scenarios(); - const outDir = readArgValue("--out-dir") ?? hostEnv.T3_CODEX_REPLAY_OUT_DIR; - const singleOutPath = readArgValue("--out") ?? hostEnv.T3_CODEX_REPLAY_OUT; + const outDir = readArgValue("--out-dir") ?? recorderEnv.T3_CODEX_REPLAY_OUT_DIR; + const singleOutPath = readArgValue("--out") ?? recorderEnv.T3_CODEX_REPLAY_OUT; const selected = allScenarios.filter((scenario) => requestedScenarios.includes(scenario.name)); if (selected.length === 0) { diff --git a/apps/server/scripts/record-fx-acp-replay-fixture.ts b/apps/server/scripts/record-fx-acp-replay-fixture.ts index 81287684620d2..ad24e8140ce96 100644 --- a/apps/server/scripts/record-fx-acp-replay-fixture.ts +++ b/apps/server/scripts/record-fx-acp-replay-fixture.ts @@ -13,6 +13,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; +import * as Config from "effect/Config"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -322,8 +324,19 @@ function normalizeEntries(input: { const DEFAULT_FX_SETTINGS = Schema.decodeSync(FxSettings)({}); +const RecorderEnv = Config.all({ + T3_FX_BIN: Config.String("T3_FX_BIN").pipe(Config.option, Config.map(Option.getOrUndefined)), + HOME: Config.String("HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + USER: Config.String("USER").pipe(Config.option, Config.map(Option.getOrUndefined)), + AI_GATEWAY_API_KEY: Config.String("AI_GATEWAY_API_KEY").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + TMPDIR: Config.String("TMPDIR").pipe(Config.option, Config.map(Option.getOrUndefined)), +}); + const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: string) { - const hostEnv = yield* HostProcess.Environment; + const recorderEnv = yield* RecorderEnv; const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName); const variant = fixture?.providers.find((provider) => provider.driver === FX_DRIVER_KIND); if (fixture === undefined || variant === undefined) { @@ -360,7 +373,7 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str }; const tee = makeWireTee(); - const settings = { ...DEFAULT_FX_SETTINGS, binaryPath: hostEnv.T3_FX_BIN ?? "fx" }; + const settings = { ...DEFAULT_FX_SETTINGS, binaryPath: recorderEnv.T3_FX_BIN ?? "fx" }; const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { const environment = yield* HostProcess.Environment; @@ -456,7 +469,7 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str isRecord(initialize.frame.result.agentInfo) ? initialize.frame.result.agentInfo : {}; - const home = hostEnv.HOME ?? ""; + const home = recorderEnv.HOME ?? ""; const transcript = { provider: FX_DRIVER_KIND, protocol: ACP_PROTOCOL, @@ -474,11 +487,11 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str entries, workspace: realWorkspace, home, - user: hostEnv.USER ?? "", + user: recorderEnv.USER ?? "", skillNames: yield* readUserSkillNames(home), - ...(hostEnv.AI_GATEWAY_API_KEY === undefined + ...(recorderEnv.AI_GATEWAY_API_KEY === undefined ? {} - : { gatewayApiKey: hostEnv.AI_GATEWAY_API_KEY }), + : { gatewayApiKey: recorderEnv.AI_GATEWAY_API_KEY }), runtimeInstructions: buildRuntimeInstructions({ harness: "fx", model: variant.modelSelection.model, @@ -494,7 +507,10 @@ const recordScenario = Effect.fn("recordFxScenario")(function* (fixtureName: str Effect.option, ); if (liveFailure._tag === "Some") { - const dumpPath = path.join(hostEnv.TMPDIR ?? "/tmp", `fx-replay-${fixtureName}-failed.json`); + const dumpPath = path.join( + recorderEnv.TMPDIR ?? "/tmp", + `fx-replay-${fixtureName}-failed.json`, + ); yield* fs.writeFileString(dumpPath, encodeJson(transcript)); yield* Console.log( `Live orchestration failed ${fixtureName} assertions; left ${outputPath} unchanged. Dump: ${dumpPath}`, diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index da64a0b48cf03..d9c5facfa71fc 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -6,6 +6,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; +import * as Config from "effect/Config"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -370,8 +372,14 @@ function normalizeEntries(input: { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); +const RecorderEnv = Config.all({ + T3_GROK_BIN: Config.String("T3_GROK_BIN").pipe(Config.option, Config.map(Option.getOrUndefined)), + HOME: Config.String("HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + USER: Config.String("USER").pipe(Config.option, Config.map(Option.getOrUndefined)), +}); + const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: string) { - const hostEnv = yield* HostProcess.Environment; + const recorderEnv = yield* RecorderEnv; const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName); const variant = fixture?.providers.find((provider) => provider.driver === GROK_PROVIDER); if (fixture === undefined || variant === undefined) { @@ -405,7 +413,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s }; const tee = makeWireTee(); - const settings = { ...DEFAULT_GROK_SETTINGS, binaryPath: hostEnv.T3_GROK_BIN ?? "grok" }; + const settings = { ...DEFAULT_GROK_SETTINGS, binaryPath: recorderEnv.T3_GROK_BIN ?? "grok" }; const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { const environment = yield* HostProcess.Environment; @@ -523,8 +531,8 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s ...normalizeEntries({ entries, workspace: realWorkspace, - home: hostEnv.HOME ?? "", - user: hostEnv.USER ?? "", + home: recorderEnv.HOME ?? "", + user: recorderEnv.USER ?? "", runtimeInstructions: buildRuntimeInstructions({ harness: "Grok", model: variant.modelSelection.model, diff --git a/apps/server/scripts/record-kiro-acp-replay-fixture.ts b/apps/server/scripts/record-kiro-acp-replay-fixture.ts index e14880f5a7944..28299b5ad7295 100644 --- a/apps/server/scripts/record-kiro-acp-replay-fixture.ts +++ b/apps/server/scripts/record-kiro-acp-replay-fixture.ts @@ -12,6 +12,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; +import * as Config from "effect/Config"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -407,8 +409,17 @@ const KIRO_RECORDING_SETTINGS = Schema.decodeSync(KiroSettings)({ binaryPath: process.env.T3_KIRO_BIN ?? "kiro-cli", }); +const RecorderEnv = Config.all({ + T3_KIRO_VERSION: Config.String("T3_KIRO_VERSION").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + HOME: Config.String("HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + USER: Config.String("USER").pipe(Config.option, Config.map(Option.getOrUndefined)), +}); + const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: string) { - const hostEnv = yield* HostProcess.Environment; + const recorderEnv = yield* RecorderEnv; const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName); const variant = fixture?.providers.find((provider) => provider.driver === KIRO_PROVIDER); if (fixture === undefined || variant === undefined) { @@ -545,7 +556,7 @@ const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: s scenario: fixtureName, metadata: { generatedBy: "live-kiro-recorder", - kiroVersion: hostEnv.T3_KIRO_VERSION ?? "unknown", + kiroVersion: recorderEnv.T3_KIRO_VERSION ?? "unknown", normalization: "Session ids are fixed sess_ UUIDs and other UUIDs (message and request ids) are numbered in first-seen order, the workspace is , HOME is /home/kiro-replay and the recording user is kiro-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . Kiro's log directory, user-scoped commands and agents, and the _kiro/* broadcasts T3 ignores (governance, steering documents, powers, tools, hooks, MCP status, session roster, memory sync) are removed. Timestamps are kept as recorded.", droppedFrames, @@ -554,8 +565,8 @@ const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: s ...normalizeEntries({ entries, workspace: realWorkspace, - home: hostEnv.HOME ?? "", - user: hostEnv.USER ?? "", + home: recorderEnv.HOME ?? "", + user: recorderEnv.USER ?? "", runtimeInstructions: buildRuntimeInstructions({ harness: "Kiro", model: variant.modelSelection.model, diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 6b94c126f2967..bf9fb85f89747 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -48,7 +49,7 @@ it.live.runIf(HostProcess.Platform.defaultValue() === "win32")( yield* fs.writeFileString(path.join(runtimeDir, "versions", "1.0.0", "t3.exe"), ""); const child = spawnWindowsRemoval( - (yield* HostProcess.Environment).ComSpec ?? "cmd.exe", + yield* Config.String("ComSpec").pipe(Config.withDefault("cmd.exe")), runtimeDir, ); yield* Effect.callback((resume) => { diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index a19f6bde0855f..36ebe60bf18c6 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -81,7 +82,7 @@ const buildCloudManagedEndpointRuntime = ( relayClientLayer = layerRelayClientAvailable, httpClient = unusedHttpClient, secretStoreLayer = emptySecretStoreLayer, - env: NodeJS.ProcessEnv = {}, + env: Record = {}, ) => Effect.gen(function* () { const context = yield* Layer.build( @@ -92,7 +93,10 @@ const buildCloudManagedEndpointRuntime = ( return yield* Effect.service(ManagedEndpointRuntime.CloudManagedEndpointRuntime).pipe( Effect.provide(context), ); - }).pipe(Effect.provideService(HostProcess.Environment, env)); + }).pipe( + Effect.provideService(HostProcess.Environment, env), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ); function makeHandle(input: { readonly pid: number; @@ -399,7 +403,7 @@ describe("CloudManagedEndpointRuntime", () => { index, lines + "2026-10-16T20:30:00Z INF Registered tunnel connection connIndex=0\n", ).pipe(Effect.andThen(Stream.runHead(runtime.tunnelConnected))); - const env: NodeJS.ProcessEnv = + const env: Record = options.transport === undefined ? {} : { TUNNEL_TRANSPORT_PROTOCOL: options.transport }; return { spawner, env, spawned, killed, exits, started, emit, observe }; }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index f6bca362af6df..2243d198e94a8 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -3,6 +3,7 @@ import { RotatingFileSink } from "@t3tools/shared/logging"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -218,7 +219,9 @@ export const make = Effect.gen(function* () { const notReadyChecksBeforeRestartRef = yield* Ref.make(CONNECTOR_NOT_READY_CHECKS_BEFORE_RESTART); // An explicit cloudflared transport in the environment is the user's choice. const env = yield* HostProcess.Environment; - const transportChosenByUser = env.TUNNEL_TRANSPORT_PROTOCOL !== undefined; + const transportChosenByUser = Option.isSome( + yield* Config.String("TUNNEL_TRANSPORT_PROTOCOL").pipe(Config.option), + ); const previousFallback = transportChosenByUser ? Option.none() : yield* secrets.get(HTTP2_FALLBACK_STATE).pipe( diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 356f27f67a35f..e8c2acbce4886 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,6 +1,7 @@ import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as ByteSize from "effect/ByteSize"; @@ -53,7 +54,6 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { type ChangeRequestTerminology, @@ -1099,8 +1099,11 @@ export const make = Effect.gen(function* () { ), ), ); - const env = yield* HostProcess.Environment; - const tempDir = env.TMPDIR ?? env.TEMP ?? env.TMP ?? "/tmp"; + const tempDir = yield* Config.String("TMPDIR").pipe( + Config.orElse(() => Config.String("TEMP")), + Config.orElse(() => Config.String("TMP")), + Config.withDefault("/tmp"), + ); const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); const normalizeStatusCacheKey = canonicalizeExistingPath; diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index c866b15f3f120..dbddce4c1c2fe 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -20,9 +20,9 @@ import { OrchestratorMcpTaskRole, ProviderInstanceId, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -2404,7 +2404,7 @@ const main = Effect.gen(function* () { } return; } - const apiKey = (yield* HostProcess.Environment).ANTHROPIC_API_KEY ?? ""; + const apiKey = yield* Config.String("ANTHROPIC_API_KEY").pipe(Config.withDefault("")); const out = flags.out; if ((!apiKey && !codex) || out === undefined) { const message = diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 5ce759ddd3c17..4ae39f3ae9626 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -1,6 +1,8 @@ +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; import * as Clock from "effect/Clock"; @@ -209,7 +211,14 @@ export function matchForgejoLogin( : undefined; } +const ForgejoKeysConfig = Config.all({ + dataHome: Config.String("XDG_DATA_HOME").pipe(Config.option), + appData: Config.String("APPDATA").pipe(Config.option), +}); + export const make = Effect.gen(function* () { + // Optional strings cannot fail to decode. + const { dataHome, appData } = yield* Effect.suspend(() => ForgejoKeysConfig).pipe(Effect.orDie); const { process } = yield* SourceControlHost.SourceControlHost; const fileSystem = yield* FileSystem.FileSystem; const httpClient = yield* HttpClient.HttpClient; @@ -246,12 +255,11 @@ export const make = Effect.gen(function* () { ); const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { - const env = yield* HostProcess.Environment; for (const path of forgejoKeysPaths({ platform: yield* HostProcess.Platform, home: yield* HostProcess.HomeDirectory, - ...(env.XDG_DATA_HOME ? { dataHome: env.XDG_DATA_HOME } : {}), - ...(env.APPDATA ? { appData: env.APPDATA } : {}), + ...(Option.isSome(dataHome) ? { dataHome: dataHome.value } : {}), + ...(Option.isSome(appData) ? { appData: appData.value } : {}), })) { const exists = yield* fileSystem.exists(path).pipe( Effect.mapError( From b8851c3311f30eb50a971b3958f350a3631f2879 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:18:02 -0800 Subject: [PATCH 3/3] fix(desktop): read Windows roots and the desktop name from the live env Windows variable names are case-insensitive only on process.env, and shell discovery can replace XDG_CURRENT_DESKTOP after startup. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliCommand.ts | 16 +++++----------- .../desktop/src/snapShot/DesktopSnapShot.test.ts | 9 +-------- apps/desktop/src/snapShot/DesktopSnapShot.ts | 8 +++----- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 5 +++-- 4 files changed, 12 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 67800d619b3a8..5dfb579cf2cc5 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -3,7 +3,6 @@ import * as NodeFSP from "node:fs/promises"; import type { DesktopCliCommandState } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -63,17 +62,12 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public */ -const WindowsRootConfig = Config.String("SystemRoot").pipe( - Config.orElse(() => Config.String("WINDIR")), - Config.option, -); - export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // Shell discovery patches PATH into the live env after startup; a Config read would be stale. + // Read the live env: shell discovery patches PATH after startup, and Windows variable + // names are case-insensitive only on process.env, not on Config's copy of it. const env = yield* HostProcess.Environment; const path = environment.path; const windows = environment.platform === "win32"; @@ -114,15 +108,15 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - const powershell = (env: Record) => + const powershell = (overrides: Record) => Effect.scoped( Effect.gen(function* () { - const root = Option.getOrUndefined(yield* WindowsRootConfig); + const root = env.SystemRoot ?? env.WINDIR; const handle = yield* spawner.spawn( ChildProcess.make( root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], - { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, + { stdin: "ignore", stderr: "ignore", env: overrides, extendEnv: true }, ), ); const [stdout, exitCode] = yield* Effect.all( diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 09fefe7bbf101..721f130ef5e56 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -7,7 +7,6 @@ import { type DesktopSnapShotEvent, } from "@t3tools/contracts"; import * as SecureRandom from "effect/crypto/SecureRandom"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -1922,13 +1921,7 @@ it.effect( linuxBackendMock.mockResolvedValue(backend); yield* Effect.scoped( Effect.gen(function* () { - const service = yield* DesktopSnapShot.make.pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ env: { XDG_CURRENT_DESKTOP: desktop } }), - ), - ), - ); + const service = yield* DesktopSnapShot.make; yield* service.initialize; const state = yield* service.state; assert.equal(state.linuxDesktop, desktop.toLowerCase()); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index c447714ea5e79..95b39d944e490 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -30,7 +30,6 @@ import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Config from "effect/Config"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Ref from "effect/Ref"; @@ -769,9 +768,8 @@ export const make = Effect.gen(function* () { const captureDirectory = path.join(environment.stateDir, "snap-shots"); const linuxAppId = environment.linuxDesktopEntryName.replace(/\.desktop$/, ""); const home = yield* HostProcess.HomeDirectory; - const xdgCurrentDesktop = yield* Config.String("XDG_CURRENT_DESKTOP").pipe( - Config.withDefault(""), - ); + // Shell discovery can replace XDG_CURRENT_DESKTOP after startup, so read the live env per state. + const hostEnv = yield* HostProcess.Environment; let shortcutVerified = false; const gnomeSetupPaths = { bundle: environment.isPackaged @@ -1625,7 +1623,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const linuxDesktop = state.mode === "portal" - ? xdgCurrentDesktop + ? (hostEnv.XDG_CURRENT_DESKTOP ?? "") .toLowerCase() .split(":") .find( diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 307cee5ea1280..57d6c61f7c43a 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1,4 +1,3 @@ -import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -10,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; @@ -1142,7 +1142,8 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; - const windir = yield* Config.String("WINDIR").pipe(Config.withDefault("C:\\Windows")); + // Not Config: Windows variable names are case-insensitive only on the live process.env. + const windir = (yield* HostProcess.Environment).WINDIR ?? "C:\\Windows"; const provideSpawner = ( effect: Effect.Effect,