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 8bdd1957cbfb..40202b76e244 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 8566613dd606..ebb32bda43ce 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 f53895a60a57..ea95c6a94cc2 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 d77544f54b69..f49e2c47a7c4 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 b0c042c05f5c..e71d83818870 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 1789c31eb407..abadf58f3f02 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 a3d4db0bb4dc..42a78bd47fec 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 f65fc11f59a7..dfa903ba2ddf 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 68a622c541fe..ab87baa49daa 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 de3fdb1a3140..fd04a2b64261 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 aa2c17af06c3..79401ac4eed9 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 46af7ce49ee2..81287684620d 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 d9ce096a1650..da64a0b48cf0 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 41262fc8dadd..e14880f5a794 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 2dba3b0eb061..eecdd7fbe501 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 d2ad673408d4..6b94c126f296 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 ad7761f7c454..a19f6bde0855 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 ca0699787612..f6bca362af6d 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 d8c99eb3321f..bf180c9004df 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 7ec4b4effbda..356f27f67a35 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 0c36d4a5869b..1ea0a62e168c 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 7990537355ed..bf6f43a3fdb3 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 d71421debc72..bf3c9318877b 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 754716ac5310..2c697f8fa853 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 8957c12747a8..f06c4f47570d 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 1da6c2e19514..274b86573ed3 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 197d412d9340..78b8724e1f93 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 fed1c484d0d1..b79f93c192d4 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 48f1f56b9b06..a2cd548bcc0b 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 d00301e96e7d..532a6db2b9e9 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 25a757104db3..351daa0e697f 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 7ab7cd24a986..c866b15f3f12 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 a75d6f5df1bc..7509cd88457e 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 86c0dc7e662d..343738c800c8 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 fed0e6144079..898c8cc09459 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 904fbdfcb5c2..02a32ac40250 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 a2cd718b9d49..5ce759ddd3c1 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 fd241e7543ba..bb6a5c73bf5a 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 685d7ebbb2a9..29240a31e71e 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 40202b76e244..151c227e47ff 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 ea95c6a94cc2..67800d619b3a 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 11f975a6b86a..ef2909045bb4 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 f2b176ee3761..22bfc969c408 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 f49e2c47a7c4..ff8c97b8f826 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 721f130ef5e5..09fefe7bbf10 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 42a78bd47fec..c447714ea5e7 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 dfa903ba2ddf..307cee5ea128 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 ab87baa49daa..3f1d50b510c6 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 fd04a2b64261..bbc3b2f2a8a5 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 79401ac4eed9..202e72de32a4 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 81287684620d..ad24e8140ce9 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 da64a0b48cf0..d9c5facfa71f 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 e14880f5a794..28299b5ad729 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 6b94c126f296..bf9fb85f8974 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 a19f6bde0855..36ebe60bf18c 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 f6bca362af6d..2243d198e94a 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 356f27f67a35..e8c2acbce488 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 c866b15f3f12..dbddce4c1c2f 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 5ce759ddd3c1..4ae39f3ae962 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 67800d619b3a..5dfb579cf2cc 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 09fefe7bbf10..721f130ef5e5 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 c447714ea5e7..95b39d944e49 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 307cee5ea128..57d6c61f7c43 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,