diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 944077d0e396..b76f6b4ba197 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -35,6 +35,7 @@ import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -284,7 +285,9 @@ const startup = Effect.gen(function* () { preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; const linuxElectronOptions = environment.platform === "linux" && !hasCommandLinePasswordStore - ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess( + yield* HostProcessHomeDirectory, + ) : preReadyElectronOptions.linux; if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { if ( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 9b73a5d825bf..2de132887aa2 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,13 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available. import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; @@ -31,14 +30,15 @@ function readCommandLineSwitchValue( return value.length > 0 ? value : null; } -export const resolveEarlyLinuxElectronOptionsFromProcess = - (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => - DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ - env: process.env, - homeDirectory: NodeOS.homedir(), - joinPath: NodePath.posix.join, - readFileString: (path) => NodeFS.readFileSync(path, "utf8"), - }); +export const resolveEarlyLinuxElectronOptionsFromProcess = ( + homeDirectory: string, +): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + homeDirectory, + joinPath: NodePath.posix.join, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + }); export class DesktopPreReadyElectronOptions extends Context.Service< DesktopPreReadyElectronOptions, @@ -51,12 +51,14 @@ export class DesktopPreReadyElectronOptions extends Context.Service< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; + const homeDirectory = yield* HostProcessHomeDirectory; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { const linuxPasswordStoreCommandLine = platform === "linux" ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") : null; - const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; + const linux = + platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess(homeDirectory) : null; if (linux !== null) { // The portal also requires a valid desktop entry. An AppImage update may @@ -64,7 +66,7 @@ export const make = Effect.gen(function* () { try { const applicationsDir = NodePath.posix.join( process.env.XDG_DATA_HOME?.trim() || - NodePath.posix.join(NodeOS.homedir(), ".local", "share"), + NodePath.posix.join(homeDirectory, ".local", "share"), "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 64e0566d3e70..8e4ffebfefe7 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -16,7 +16,6 @@ import { import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/hostProcess"; -import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; @@ -46,6 +45,7 @@ import { resolveWslPickFolderDefaultPath, wslUncPathToLinuxPath, } from "../../wsl/wslPathParsing.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const ContextMenuPosition = Schema.Struct({ x: Schema.Number, @@ -398,7 +398,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ // The VS Code extensions directory is the same dotfolder on Windows, // macOS, and Linux; when it is missing the picker opens wherever the // platform would by default. - const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions"); + const extensionsDir = path.join(yield* HostProcessHomeDirectory, ".vscode", "extensions"); const defaultPath = yield* fileSystem .exists(extensionsDir) .pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts index 0b9346a9f8cf..248a6874e48f 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts @@ -29,7 +29,7 @@ beforeEach(async () => { tools.validateNiri.mockReset().mockResolvedValue(undefined); tools.hyprlandBindings.mockReset().mockResolvedValue([]); tools.reloadHyprland.mockReset().mockResolvedValue(undefined); - setup = new CaptureShortcutConfig(tools); + setup = new CaptureShortcutConfig(NodeOS.homedir(), tools); }); afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); @@ -219,15 +219,15 @@ it("does not edit Omarchy's shipped defaults", async () => { }); it("honors NIRI_CONFIG and XDG_CONFIG_HOME", () => { expect( - niriCaptureConfigPath( - { NIRI_CONFIG: "/custom/test.kdl", XDG_CONFIG_HOME: "/config" }, - "/home/test", - ), + niriCaptureConfigPath("/home/test", { + NIRI_CONFIG: "/custom/test.kdl", + XDG_CONFIG_HOME: "/config", + }), ).toBe("/custom/test.kdl"); - expect(niriCaptureConfigPath({ XDG_CONFIG_HOME: "/config" }, "/home/test")).toBe( + expect(niriCaptureConfigPath("/home/test", { XDG_CONFIG_HOME: "/config" })).toBe( "/config/niri/config.kdl", ); - expect(niriCaptureConfigPath({}, "/home/test")).toBe("/home/test/.config/niri/config.kdl"); + expect(niriCaptureConfigPath("/home/test", {})).toBe("/home/test/.config/niri/config.kdl"); }); it("preserves a UTF-8 BOM through preview and apply", async () => { await NodeFSP.writeFile(path, "\uFEFFbinds {}\r\n"); @@ -251,7 +251,7 @@ it("the installed Niri parser accepts the generated edit and its relative includ NodePath.join(directory, "keys.kdl"), "binds {\n Ctrl+Alt+Q { quit; }\n}\n", ); - const checked = new CaptureShortcutConfig({ + const checked = new CaptureShortcutConfig(NodeOS.homedir(), { ...tools, validateNiri: async (file) => { await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["validate", "--config", file]); diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.ts index edc4c300c101..8b86b416cdba 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.ts @@ -2,7 +2,6 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; -import * as NodeOS from "node:os"; import * as NodeCrypto from "node:crypto"; import * as NodeChildProcess from "node:child_process"; import * as NodeUtil from "node:util"; @@ -39,7 +38,7 @@ const decodeHyprlandBindings = Schema.decodeUnknownSync( ), ); -export function niriCaptureConfigPath(env = process.env, home = NodeOS.homedir()) { +export function niriCaptureConfigPath(home: string, env = process.env) { return ( env.NIRI_CONFIG || NodePath.join(env.XDG_CONFIG_HOME || NodePath.join(home, ".config"), "niri", "config.kdl") @@ -99,8 +98,10 @@ export class CaptureShortcutConfig { | { preview: DesktopCaptureConfigPreview; target: Target; files: Snapshot[]; missing: string[] } | undefined; private applying = false; + private readonly home: string; private readonly tools: typeof defaultTools; - constructor(tools = defaultTools) { + constructor(home: string, tools = defaultTools) { + this.home = home; this.tools = tools; } @@ -166,7 +167,7 @@ export class CaptureShortcutConfig { if (include.path.includes("$") || /[*?[\]]/.test(include.path)) throw new Error("This config uses a dynamic include. Use manual setup in Advanced."); const path = include.path.startsWith("~/") - ? NodePath.join(NodeOS.homedir(), include.path.slice(2)) + ? NodePath.join(this.home, include.path.slice(2)) : NodePath.resolve(NodePath.dirname(file.path), include.path); let child: Snapshot; try { diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index f1c1c2a4908f..5718676f3b7a 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -88,6 +88,7 @@ import { snapShotShortcutRegistrationFailureMessage, snapShotShortcutSystemConflict, } from "./snapShot.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; @@ -727,6 +728,7 @@ export const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(context); const captureDirectory = path.join(environment.stateDir, "snap-shots"); const linuxAppId = environment.linuxDesktopEntryName.replace(/\.desktop$/, ""); + const home = yield* HostProcessHomeDirectory; let shortcutVerified = false; const gnomeSetupPaths = { bundle: environment.isPackaged @@ -757,7 +759,7 @@ export const make = Effect.gen(function* () { dataHome: path.dirname(environment.linuxApplicationsDir), }; const shiftShortcutWorkerPath = path.join(__dirname, "snapShot", "GlobalShiftShortcutWorker.cjs"); - const shortcutConfig = new CaptureShortcutConfig(); + const shortcutConfig = new CaptureShortcutConfig(home); const accessibilityWorkerPath = path.join( __dirname, "snapShot", @@ -1184,7 +1186,7 @@ export const make = Effect.gen(function* () { shortcut, shortcutRegistered: false, shortcutBinding: niriCaptureBinding(linuxAppId), - shortcutConfigPath: niriCaptureConfigPath(), + shortcutConfigPath: niriCaptureConfigPath(home), shortcutActionRegistered: registered, shortcutMessage: registered ? "Set up the shortcut to add it to your Niri config." @@ -1474,8 +1476,8 @@ export const make = Effect.gen(function* () { const configPath = selectedPath ?? (desktop === "niri" - ? niriCaptureConfigPath() - : (await hyprlandCaptureShortcut(linuxAppId)).shortcutConfigPath); + ? niriCaptureConfigPath(home) + : (await hyprlandCaptureShortcut(linuxAppId, home)).shortcutConfigPath); return shortcutConfig.preview({ desktop, path: configPath, appId: linuxAppId }, request); }, catch: (cause) => @@ -1593,7 +1595,7 @@ export const make = Effect.gen(function* () { : undefined; const hyprlandShortcut = state.linuxBackend === "hyprland" - ? yield* Effect.promise(() => hyprlandCaptureShortcut(linuxAppId)) + ? yield* Effect.promise(() => hyprlandCaptureShortcut(linuxAppId, home)) : undefined; return { ...state, @@ -1609,7 +1611,7 @@ export const make = Effect.gen(function* () { : {}), ...hyprlandShortcut, ...(state.linuxBackend === "niri" - ? { shortcutConfigPath: niriCaptureConfigPath() } + ? { shortcutConfigPath: niriCaptureConfigPath(home) } : {}), ...(kdeHelper ? { diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts index 3129d5bab7fd..fe55319ecea4 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts @@ -121,7 +121,7 @@ it.each([true, false])("uses the active config syntax and user bindings (Lua: %s NodePath.join(config, lua ? "bindings.lua" : "bindings.conf"), "custom bindings", ); - const result = await hyprlandCaptureShortcut("com.t3tools.T3Code", directory); + const result = await hyprlandCaptureShortcut("com.t3tools.T3Code", "/home/test", directory); expect(result.shortcutConfigPath).toBe( NodePath.join(config, lua ? "bindings.lua" : "bindings.conf"), ); diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.ts index 2fc90a410a37..4dc57e44448d 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.ts @@ -29,7 +29,8 @@ function hyprlandCaptureBinding(appId: string, lua: boolean): string { /** Omarchy owns its defaults; instructions always point at a user-owned config. */ export async function hyprlandCaptureShortcut( appId: string, - configHome = process.env.XDG_CONFIG_HOME || NodePath.join(NodeOS.homedir(), ".config"), + home: string, + configHome = process.env.XDG_CONFIG_HOME || NodePath.join(home, ".config"), ) { const directory = NodePath.join(configHome, "hypr"); const exists = async (name: string) => diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 61197efb6a50..6b6d4b2b95da 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import { AssetAccessError, AssetPreviewTypeValidationError, @@ -37,17 +36,13 @@ import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import { githubMediaResponse } from "./GitHubMediaFetch.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; }); -vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, homedir: vi.fn(actual.homedir) }; -}); - const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); @@ -299,8 +294,7 @@ describe("AssetAccess", () => { yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, "recording bytes"); const canonicalFile = yield* fs.realPath(filePath); - const homeSpy = vi.mocked(NodeOS.homedir).mockReturnValue(home); - try { + yield* Effect.gen(function* () { for (const workspaceRoot of [ path.join(home, "project"), path.join(directory, "srv", "project"), @@ -332,9 +326,7 @@ describe("AssetAccess", () => { expect(yield* Effect.promise(() => response.text())).toBe("recording bytes"); } } - } finally { - homeSpy.mockRestore(); - } + }).pipe(Effect.provideService(HostProcessHomeDirectory, home)); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c9fbc064168c..d2de3e555736 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -58,6 +58,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -469,7 +470,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i switch (input.resource._tag) { case "media-file": { - let requestedPath = expandHomePath(input.resource.path); + let requestedPath = expandHomePath(input.resource.path, yield* HostProcessHomeDirectory); if (!path.isAbsolute(requestedPath)) { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 4bde751dcf41..0c35ab9f6941 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -9,6 +9,7 @@ import { it } from "@effect/vitest"; import type { DesktopAppActivationRequest } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; import { + HostProcessHomeDirectory, HostProcessPlatform, HostProcessUserId, HostProcessWorkingDirectory, @@ -19,18 +20,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { Command } from "effect/cli"; -import { afterEach, describe, expect, vi } from "vite-plus/test"; +import { describe, expect } from "vite-plus/test"; import { makeCli } from "../binCli.ts"; import { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; -vi.mock("node:os", async (importOriginal) => { - const os = await importOriginal(); - return { ...os, homedir: vi.fn(os.homedir) }; -}); - -afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); - const encodeRuntimeState = Schema.encodeEffect(Schema.fromJsonString(PersistedServerRuntimeState)); const runCli = (args: ReadonlyArray, env: Record = {}) => @@ -296,7 +290,6 @@ describe("t3 app", () => { it.effect("prefers the installed desktop app when a dev desktop is also running", () => withTempDirectory("t3-app-preferred-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const desktop = yield* fakeDesktop({ baseDir }); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -305,14 +298,13 @@ describe("t3 app", () => { expect(desktop.received).toHaveLength(1); expect(development.received).toHaveLength(0); - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcessHomeDirectory, root), Effect.scoped), ), ); it.effect("finds the dev desktop when the default desktop socket is absent", () => withTempDirectory("t3-app-dev-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -321,14 +313,13 @@ describe("t3 app", () => { expect(development.received).toHaveLength(2); expect(yield* pathExists(baseDir)).toBe(false); - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcessHomeDirectory, root), Effect.scoped), ), ); it.effect("never searches a dev state directory for an explicit T3 home", () => withTempDirectory("t3-app-explicit-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -338,7 +329,7 @@ describe("t3 app", () => { expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); expect(development.received).toHaveLength(0); - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcessHomeDirectory, root), Effect.scoped), ), ); @@ -347,7 +338,6 @@ describe("t3 app", () => { (responseKind) => withTempDirectory("t3-app-response-test-", (root) => Effect.gen(function* () { - vi.mocked(NodeOS.homedir).mockReturnValue(root); const baseDir = NodePath.join(root, ".t3"); const desktop = yield* fakeDesktop({ baseDir, @@ -387,7 +377,7 @@ describe("t3 app", () => { cause: { message: "The desktop app response is invalid." }, }); } - }).pipe(Effect.scoped), + }).pipe(Effect.provideService(HostProcessHomeDirectory, root), Effect.scoped), ), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c7b1fb0d5819..33db150ec689 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -31,7 +31,7 @@ import type { WebSearchOutput, } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, @@ -8181,12 +8181,12 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig.ServerConfig; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); + const baseEnvironment = yield* mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const binaryPath = yield* resolveClaudeSdkExecutablePath( - expandHomePath(config.binaryPath), + expandHomePath(config.binaryPath, yield* HostProcessHomeDirectory), claudeEnvironment, ); return yield* makeClaudeAdapterV2({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index dbdbab32a74d..69eb0aa7f38e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -30,7 +30,7 @@ import { type ProviderSetupError, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -1582,14 +1582,14 @@ export const createCodexAdapterV2 = ( const settings = { ...config, enabled, - binaryPath: expandHomePath(config.binaryPath), + binaryPath: expandHomePath(config.binaryPath, yield* HostProcessHomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; return yield* makeCodexAdapterV2({ instanceId, settings, - environment: mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), clientFactory, crypto, fileSystem, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index 732bc6904f40..bdfc883aeb93 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -94,9 +94,12 @@ describe("acpSubagentStatusBlocksTurnSettlement", () => { describe("GrokAdapterV2 capabilities", () => { it("preserves Grok's rate-limit stop and distinguishes other prompt failures", () => { - const flavor = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]); + const flavor = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ); const limit = flavor.promptFailure?.( new EffectAcpErrors.AcpRequestError({ code: xAiRateLimitedErrorCode, @@ -122,9 +125,12 @@ describe("GrokAdapterV2 capabilities", () => { }); it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => { - const flavor = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]); + const flavor = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ); assert.isFalse(flavor.interruptPromptOnCancel); // User Stop (requestRuntimeRestart) keeps the hard process-group kill and @@ -139,9 +145,12 @@ describe("GrokAdapterV2 capabilities", () => { }); it("terminalizes only foreground tools under the actual Grok flavor", () => { - const flavor = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]); + const flavor = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ); const foreground = { toolCallId: "foreground-1", title: "Terminal", @@ -284,9 +293,12 @@ describe("ACP permission policy", () => { }); describe("Grok permission prompts", () => { - const disposition = makeGrokAcpAdapterFlavor({ - makeRuntime: () => Effect.never, - } as unknown as Parameters[0]).permissionDisposition; + const disposition = makeGrokAcpAdapterFlavor( + { + makeRuntime: () => Effect.never, + } as unknown as Parameters[0], + "/home/ada", + ).permissionDisposition; // grok_auto_blocked_command replays Auto end to end. When an explicit policy // launches Grok asking instead, T3's policy still answers its prompts. diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.ts index 184abc6147ce..a4aa736c669b 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.ts @@ -14,16 +14,16 @@ * never trusted beyond these checks. */ import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const SCRIPT_BYTE_CAP = 256 * 1024; -function scriptsRoot(): string { - return NodePath.join(NodeOS.homedir(), ".claude", "projects"); +function scriptsRoot(home: string): string { + return NodePath.join(home, ".claude", "projects"); } export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { @@ -38,8 +38,9 @@ export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")( }); } + const home = yield* HostProcessHomeDirectory; const root = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(scriptsRoot()), + try: () => NodeFSP.realpath(scriptsRoot(home)), catch: (cause) => new OrchestrationGetWorkflowScriptError({ reason: "root-unavailable", diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..25c7dfdcb973 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,4 +1,8 @@ -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + HostProcessHomeDirectory, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { listLoginShellCandidates, mergePathEntries, @@ -93,11 +97,12 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< export const expandHomePath = Effect.fn(function* (input: string) { const { join } = yield* Path.Path; + const home = yield* HostProcessHomeDirectory; if (input === "~") { - return NodeOS.homedir(); + return home; } if (input.startsWith("~/") || input.startsWith("~\\")) { - return join(NodeOS.homedir(), input.slice(2)); + return join(home, input.slice(2)); } return input; }); @@ -105,7 +110,7 @@ export const expandHomePath = Effect.fn(function* (input: string) { export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return join(NodeOS.homedir(), ".t3"); + return join(yield* HostProcessHomeDirectory, ".t3"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 0fe74152f1c3..5177a948d945 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -44,7 +44,11 @@ import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl, parseOriginUrlFromGitConfig, } from "@t3tools/shared/git"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + HostProcessHomeDirectory, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as ServerConfig from "../config.ts"; @@ -631,7 +635,7 @@ export const make = Effect.gen(function* () { // must case fold. const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; const hostEnvironment = yield* HostProcessEnvironment; - const homeDir = NodeOS.homedir(); + const homeDir = yield* HostProcessHomeDirectory; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => @@ -912,13 +916,13 @@ export const make = Effect.gen(function* () { const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { const configured = homePath.trim(); if (configured.length > 0) { - return path.resolve(expandHomePath(configured)); + return path.resolve(expandHomePath(configured, homeDir)); } const fromEnvironment = environmentHome?.trim() ?? ""; if (fromEnvironment.length > 0) { - return path.resolve(expandHomePath(fromEnvironment)); + return path.resolve(expandHomePath(fromEnvironment, homeDir)); } - return path.join(NodeOS.homedir(), ".claude"); + return path.join(homeDir, ".claude"); }; const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( @@ -1217,7 +1221,7 @@ export const make = Effect.gen(function* () { const gitIdentities = new Map(); for (const candidate of raw) { - const expanded = expandHomePath(candidate.cwd.trim()); + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); if (isExcludedProjectPath(resolved)) continue; @@ -1281,7 +1285,7 @@ export const make = Effect.gen(function* () { ); const importedProjectsByRoot = new Map(); for (const project of importedProjects) { - const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot, homeDir)); importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); } @@ -1328,7 +1332,7 @@ export const make = Effect.gen(function* () { workspaceRoot: string, completedSources: ReadonlyArray, ) { - const root = path.resolve(expandHomePath(workspaceRoot)); + const root = path.resolve(expandHomePath(workspaceRoot, homeDir)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; const rootIdentity = yield* directoryIdentity(root); @@ -1343,7 +1347,7 @@ export const make = Effect.gen(function* () { readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; }> = []; for (const candidate of candidates) { - const expanded = expandHomePath(candidate.cwd.trim()); + const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; const resolved = path.resolve(expanded); if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; @@ -1439,7 +1443,7 @@ export const make = Effect.gen(function* () { if (snapshotCwd === null) { return Option.some({ _tag: "Skipped" }); } - const expandedCwd = expandHomePath(snapshotCwd.trim()); + const expandedCwd = expandHomePath(snapshotCwd.trim(), homeDir); if ( !path.isAbsolute(expandedCwd) || (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 913a1983a107..a8895e21ae32 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -47,6 +47,7 @@ import { type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; @@ -374,7 +375,9 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`. - const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const resolvedHomePath = input.homePath + ? expandHomePath(input.homePath, yield* HostProcessHomeDirectory) + : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { ...input.environment, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index c0812af2a6df..cdf938dbb4d0 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,7 +1,7 @@ import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import { NodeRuntimeUnavailableError, @@ -113,8 +113,12 @@ export const AntigravityDriver: ProviderDriver< gcpLocation: settings.gcpLocation, }; const authConfigIssue = antigravityAuthConfigIssue(auth); - const processEnvironment = mergeProviderInstanceEnvironment(environment); - const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment); + const processEnvironment = yield* mergeProviderInstanceEnvironment(environment); + const userHome = resolveAntigravityUserHome( + yield* HostProcessPlatform, + processEnvironment, + yield* HostProcessHomeDirectory, + ); const directories = yield* resolveAntigravityInstanceDirectories( host.paths.stateDir, instanceId, diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index 180ef9968fac..f5bc8ec3f4d3 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -344,17 +344,35 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { it("resolves the home the agent expands ~ against", () => { assert.equal( - resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + resolveAntigravityUserHome( + "linux", + { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, + "/home/fallback", + ), "/home/user", ); assert.equal( - resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + resolveAntigravityUserHome( + "win32", + { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, + "/home/fallback", + ), "C:\\Users\\user", ); assert.equal( - resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), + resolveAntigravityUserHome( + "win32", + { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }, + "/home/fallback", + ), "D:\\Users\\alice", ); - assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); - assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); + assert.equal( + resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }, "/home/fallback"), + "/Users/a b ", + ); + assert.equal( + resolveAntigravityUserHome("darwin", { HOME: "" }, "/home/fallback"), + "/home/fallback", + ); }); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index b11396467582..1bc4e370013f 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import type { ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -18,15 +16,16 @@ import { parse as parseYamlDocument } from "yaml"; export function resolveAntigravityUserHome( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, + home: string, ): string { if (platform === "win32") { if (environment.USERPROFILE) return environment.USERPROFILE; if (environment.HOMEDRIVE && environment.HOMEPATH) { return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; } - return NodeOS.homedir(); + return home; } - return environment.HOME || NodeOS.homedir(); + return environment.HOME || home; } /** diff --git a/apps/server/src/provider/Drivers/AntigravityUsage.ts b/apps/server/src/provider/Drivers/AntigravityUsage.ts index 7b629c857c9a..d99539b555c1 100644 --- a/apps/server/src/provider/Drivers/AntigravityUsage.ts +++ b/apps/server/src/provider/Drivers/AntigravityUsage.ts @@ -8,7 +8,6 @@ * * @module provider/Drivers/AntigravityUsage */ -import * as NodeOS from "node:os"; import { type AntigravitySettings, UsageReadError } from "@t3tools/contracts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -18,7 +17,7 @@ import type { ProviderUsageReader, ProviderUsageScan, } from "@t3tools/provider-core/server/usage"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -50,7 +49,7 @@ const make = Effect.gen(function* () { /** `ANTIGRAVITY_DATA_DIR` (comma-separated) or the defaults, canonicalized. */ const dataRoots = Effect.gen(function* () { - const home = NodeOS.homedir(); + const home = yield* HostProcessHomeDirectory; const configured = hostEnvironment["ANTIGRAVITY_DATA_DIR"] ?.split(",") .map((value) => value.trim()) @@ -63,7 +62,7 @@ const make = Effect.gen(function* () { ]; const canonical = new Set(); for (const root of configured?.length ? configured : defaults) { - const resolved = path.resolve(expandHomePath(root)); + const resolved = path.resolve(expandHomePath(root, home)); canonical.add( yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), ); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e9f37580215b..9a43be65aac4 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -71,6 +71,7 @@ import { makeClaudeContinuationGroupKey, resolveClaudeHomePath, } from "./ClaudeHome.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); @@ -130,7 +131,7 @@ export const ClaudeDriver: ProviderDriver { const path = yield* Path.Path; + const home = yield* HostProcessHomeDirectory; const homePath = config.homePath.trim(); if (homePath.length > 0) { - return path.resolve(expandHomePath(homePath)); + return path.resolve(expandHomePath(homePath, home)); } // Inherited env vars are not shell-expanded, so a literal `~` stays literal. const inherited = environment?.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (inherited.length > 0) { return path.resolve(inherited); } - return path.resolve(path.join(NodeOS.homedir(), ".claude")); + return path.resolve(path.join(home, ".claude")); }); export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 3ef7d925e031..3082f53b0280 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -13,14 +13,13 @@ * * @module provider/Drivers/ClaudeSkills */ -import * as NodeOS from "node:os"; import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; @@ -296,9 +295,10 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct cwd?: string, ): Effect.fn.Return { const path = yield* Path.Path; + const home = yield* HostProcessHomeDirectory; const homePath = config.homePath.trim(); if (homePath.length > 0) { - return path.resolve(expandHomePath(homePath)); + return path.resolve(expandHomePath(homePath, home)); } // No tilde expansion here: the spawned CLI receives this env var verbatim // (env vars are never shell-expanded), so a literal `~` must stay literal @@ -309,7 +309,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); } - return path.join(NodeOS.homedir(), ".claude"); + return path.join(home, ".claude"); }); /** diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 32ffed8905b4..e1fadbcd175e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -77,6 +77,7 @@ import { codexUsageReader } from "./codexUsage.ts"; import * as CodexInstallation from "../CodexInstallation.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -154,7 +155,7 @@ export const CodexDriver: ProviderDriver 0 - ? expandHomePath(value) - : path.join(NodeOS.homedir(), ".codex"); + value && value.trim().length > 0 ? expandHomePath(value, home) : path.join(home, ".codex"); return path.resolve(expanded); } @@ -45,7 +42,8 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi config: CodexSettings, ): Effect.fn.Return { const path = yield* Path.Path; - const sharedHomePath = resolveHomePath(path, config.homePath); + const home = yield* HostProcessHomeDirectory; + const sharedHomePath = resolveHomePath(path, home, config.homePath); const shadowHomePath = config.shadowHomePath.trim(); if (shadowHomePath.length === 0) { return { @@ -56,7 +54,7 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi }; } - const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath)); + const effectiveHomePath = path.resolve(expandHomePath(shadowHomePath, home)); return { mode: "authOverlay", sharedHomePath, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index a7181352b288..92c461390aea 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -36,7 +36,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu instanceId, enabled, config, - environment: mergeProviderInstanceEnvironment(input.environment), + environment: yield* mergeProviderInstanceEnvironment(input.environment), }); const continuationIdentity = codexContinuationIdentity(runtime.homeLayout); const stamp = withInstanceIdentity({ diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index 155ebc6a33ec..38df3146e097 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -4,7 +4,6 @@ * * @module provider/Drivers/claudeUsage */ -import * as NodeOS from "node:os"; import type { ClaudeSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -18,6 +17,7 @@ import { type TranscriptUsageFormat, type UsageRecord, } from "@t3tools/provider-core/server/usage"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; /** * Parses one line of a Claude Code transcript. @@ -111,10 +111,11 @@ export const claudeUsageReader: ProviderUsageReader = // An undecodable config has no trustworthy home to read. if (config === undefined) return []; const path = yield* Path.Path; + const homeDirectory = yield* HostProcessHomeDirectory; const configured = config.homePath.trim(); const home = configured - ? expandHomePath(configured) - : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude"); + ? expandHomePath(configured, homeDirectory) + : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(homeDirectory, ".claude"); return [{ dir: path.resolve(home, "projects") }]; }), }; diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 36db18edb3b9..7c6b191a8b84 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -39,7 +39,11 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import type { GrokSettings } from "@t3tools/provider-grok/settings"; import type { CursorSettings } from "@t3tools/provider-cursor/settings"; -import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; +import { + HostProcessHomeDirectory, + HostProcessPlatform, + isHostWindows, +} from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -51,7 +55,6 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ServerConfig from "../config.ts"; -import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; @@ -153,7 +156,7 @@ const makeTildeProviderFixtures = Effect.fn( )(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const homePath = expandHomePath("~"); + const homePath = yield* HostProcessHomeDirectory; const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ directory: homePath, prefix: ".t3-provider-path-test-", diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index c4816709a2f6..4b0e769cde1d 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -2,7 +2,7 @@ import * as NodeFSP from "node:fs/promises"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -317,7 +317,12 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = input.platform ?? (yield* HostProcessPlatform); const userHome = - input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); + input.userHome ?? + resolveAntigravityUserHome( + platform, + input.baseEnv ?? process.env, + yield* HostProcessHomeDirectory, + ); const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe( diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 5f8379e1c037..c37a0dfb2d8d 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -7,12 +7,11 @@ * * @module provider/claudeResetCredits */ -import * as NodeOS from "node:os"; import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCredits, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -190,10 +189,14 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ -export const claudeAccountConfigPath = (configDir: string | undefined) => - Effect.map(Path.Path, (path) => - configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), - ); +export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( + configDir: string | undefined, +) { + const path = yield* Path.Path; + return configDir + ? path.join(configDir, ".claude.json") + : path.join(yield* HostProcessHomeDirectory, ".claude.json"); +}); const CLAIM_OUTCOMES = { reset: "reset", diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index d888725b3ba9..ab7257553339 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -134,18 +134,21 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu true, ); const entries = yield* readEntries(input.instanceId, "remove-install"); - const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => { - if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { - return Effect.succeed([]); - } - const binaryPath = entry.config.binaryPath.trim(); - return resolveCommandPath(binaryPath, { - env: mergeProviderInstanceEnvironment(entry.environment), - }).pipe( - Effect.map((resolved) => [binaryPath, resolved]), - Effect.orElseSucceed(() => [binaryPath]), - ); - }); + const protectedPaths = yield* Effect.forEach( + Object.values(entries), + Effect.fnUntraced(function* (entry) { + if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { + return []; + } + const binaryPath = entry.config.binaryPath.trim(); + return yield* resolveCommandPath(binaryPath, { + env: yield* mergeProviderInstanceEnvironment(entry.environment), + }).pipe( + Effect.map((resolved) => [binaryPath, resolved]), + Effect.orElseSucceed(() => [binaryPath]), + ); + }), + ); yield* installation .remove(protectedPaths.flat()) .pipe(Effect.mapError(failure(input.instanceId))); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 7aee5847001f..ef7a0e31590f 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -20,6 +20,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; export class ReviewService extends Context.Service< ReviewService, @@ -73,13 +74,14 @@ export const make = Effect.gen(function* () { const worktreesDirectories = yield* settings.getSettings.pipe( Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })), ); + const home = yield* HostProcessHomeDirectory; const [candidate, workspaceRoot, worktreesRoots] = yield* Effect.all([ canonicalizePath(cwd), canonicalizePath(config.cwd), // A managed root that cannot be resolved, or resolves to a filesystem // root through a symlink, is skipped rather than failing every review. Effect.forEach( - managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path), + managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path, home), (directory) => canonicalizePath(directory).pipe(Effect.orElseSucceed(() => null)), ).pipe( Effect.map((roots) => diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1bbca1e2b3d5..e595b4028cee 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -29,6 +29,7 @@ import { import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); @@ -213,7 +214,7 @@ export const make = Effect.gen(function* () { }); } - return path.resolve(expandHomePath(trimmed)); + return path.resolve(expandHomePath(trimmed, yield* HostProcessHomeDirectory)); }, ); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d823e84e45dd..c0ad95a63c61 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -38,6 +38,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), @@ -213,6 +214,7 @@ export const make = Effect.gen(function* () { serverSettings, config.worktreesDir, path, + yield* HostProcessHomeDirectory, )) { // An unmounted drive only skips its own worktrees. const root = yield* fs.exists(directory).pipe( diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 4009625d5752..a0e6c1c7ecfd 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,4 +1,3 @@ -import * as NodeOS from "node:os"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -10,6 +9,7 @@ import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerConfig from "../config.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; /** * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key, @@ -311,6 +311,7 @@ export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierFo Effect.orElseSucceed(() => null), ); -export const getTelemetryIdentifier = Effect.suspend(() => - getTelemetryIdentifierForHome(NodeOS.homedir()), +export const getTelemetryIdentifier = Effect.flatMap( + HostProcessHomeDirectory, + getTelemetryIdentifierForHome, ); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 8aa64408993e..2a16b9d44f75 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -42,7 +42,11 @@ import { } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessHomeDirectory, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { mergePathEntries } from "@t3tools/shared/shell"; import { acpRegistryManagedBinaryDirectories } from "@t3tools/provider-acp-registry/server"; @@ -1317,6 +1321,7 @@ function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv: Record | null | undefined, platform: NodeJS.Platform, + home: string, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { @@ -1331,7 +1336,7 @@ function createTerminalSpawnEnv( ? Object.keys(spawnEnv).find((candidate) => candidate.toLowerCase() === key.toLowerCase()) : undefined; spawnEnv[existingKey ?? key] = - key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value, home) : value; } } // An explicit empty override opts out for terminals started without a client. @@ -1408,7 +1413,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); } - let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + let resolved = yield* mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); if (instance.driver === "codex") { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isSome(config)) { @@ -2244,7 +2249,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv, platform); + const terminalEnv = createTerminalSpawnEnv( + baseEnv, + session.runtimeEnv, + platform, + yield* HostProcessHomeDirectory, + ); // Append (never prepend) managed ACP agent install directories so // `kimi login` and friends resolve by name without shadowing any // system or user tool of the same name. diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 0477c7fa163b..c11fed75814d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -26,6 +26,7 @@ import { } from "@t3tools/provider-core/server/textGenerationUtils"; import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const CODEX_TIMEOUT_MS = 180_000; const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -214,7 +215,12 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func env: { ...effectiveEnvironment, ...(effectiveConfig.homePath - ? { CODEX_HOME: expandHomePath(effectiveConfig.homePath) } + ? { + CODEX_HOME: expandHomePath( + effectiveConfig.homePath, + yield* HostProcessHomeDirectory, + ), + } : {}), }, cwd, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index ba65ba2887bb..2ceeb8750001 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -339,10 +339,10 @@ export const make = Effect.gen(function* () { * not decode. Disabled accounts still have history. An unconfigured default * slot runs with default config, just as it does in the provider registry. */ - const usageInstances = ( + const usageInstances = Effect.fn("UsageService.usageInstances")(function* ( driver: ProviderDriver, settings: ServerSettingsValue, - ): Array> => { + ) { const entries: Array< readonly [ProviderInstanceId, Pick, boolean] > = Object.entries(settings.providerInstances) @@ -352,13 +352,22 @@ export const make = Effect.gen(function* () { entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); } const decodeConfig = Schema.decodeUnknownOption(driver.configSchema); - return entries.map(([instanceId, instance, configured]) => ({ - instanceId, - config: Option.getOrUndefined(decodeConfig(instance.config ?? {})), - environment: mergeProviderInstanceEnvironment(instance.environment, hostEnvironment), - configured, - })); - }; + return yield* Effect.forEach( + entries, + Effect.fnUntraced(function* ([instanceId, instance, configured]) { + const instanceConfig: ProviderUsageInstance = { + instanceId, + config: Option.getOrUndefined(decodeConfig(instance.config ?? {})), + environment: yield* mergeProviderInstanceEnvironment( + instance.environment, + hostEnvironment, + ), + configured, + }; + return instanceConfig; + }), + ); + }); /** Resolves every transcript directory the usage readers point at. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( @@ -369,8 +378,9 @@ export const make = Effect.gen(function* () { const seen = new Set(); for (const { driver, reader } of transcriptReaders) { const { provider, format } = reader; - const directories = yield* Effect.forEach(usageInstances(driver, settings), (instance) => - reader.directories(instance), + const directories = yield* Effect.forEach( + yield* usageInstances(driver, settings), + (instance) => reader.directories(instance), ); for (const { dir: directory, fileName } of directories.flat()) { const sourceKey = provider + "\0" + directory; @@ -635,14 +645,12 @@ export const make = Effect.gen(function* () { const scans = Effect.forEach( scanReaders, ({ driver, reader }) => - reader - .scan({ - instances: usageInstances(driver, settings), - settings, - windowStartMs, - retentionCutoffMs, - awaitRefresh, - }) + usageInstances(driver, settings) + .pipe( + Effect.flatMap((instances) => + reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }), + ), + ) .pipe( Effect.flatMap((sources) => Effect.forEach(sources, ({ volumeId, ...source }) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 002311fda2cf..b273e9eff52f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -29,7 +29,7 @@ import { type VcsRef, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; @@ -3403,6 +3403,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options?.worktreesDirectory ?? "", worktreesDir, path, + yield* HostProcessHomeDirectory, ); if (parentDir === null) { return yield* new GitCommandError({ diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index beac8d18a094..153360443338 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -19,7 +19,7 @@ import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; @@ -120,7 +120,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu } if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath)); + return path.resolve(expandHomePath(input.partialPath, yield* HostProcessHomeDirectory)); } if (!input.cwd) { @@ -128,7 +128,10 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu partialPath: input.partialPath, }); } - return path.resolve(expandHomePath(input.cwd), input.partialPath); + return path.resolve( + expandHomePath(input.cwd, yield* HostProcessHomeDirectory), + input.partialPath, + ); }); /** @public Service construction is part of the canonical Effect module API. */ diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 000a422bcdc8..748689330072 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; export class WorkspaceRootNotExistsError extends Schema.TaggedError()( "WorkspaceRootNotExistsError", @@ -152,7 +153,9 @@ export const make = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim())); + const normalizedWorkspaceRoot = path.resolve( + expandHomePath(workspaceRoot.trim(), yield* HostProcessHomeDirectory), + ); let workspaceStat = yield* statWorkspaceRoot( workspaceRoot, normalizedWorkspaceRoot, diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index ee7154577458..65542b2c03bc 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -4,7 +4,8 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; /** * Directory new worktrees are created under: the `worktreesDirectory` - * setting, or `defaultDir` (`/worktrees`) when it is empty. Null when + * setting (`~` expands to `home`), or `defaultDir` (`/worktrees`) + * when it is empty. Null when * the setting is not an absolute path on this machine, such as `D:\worktrees` * configured for a Windows server and synced to a Linux one, or when it is a * filesystem root, which would make every path on that drive look managed. @@ -13,9 +14,10 @@ export function resolveWorktreesDirectory( setting: string, defaultDir: string, path: Path.Path, + home: string, ): string | null { if (setting === "") return defaultDir; - const expanded = expandHomePath(setting); + const expanded = expandHomePath(setting, home); if (!path.isAbsolute(expanded)) return null; const resolved = path.resolve(expanded); return isFilesystemRoot(resolved, path) ? null : resolved; @@ -34,10 +36,11 @@ export function managedWorktreesDirectories( }, defaultDir: string, path: Path.Path, + home: string, ): ReadonlyArray { const directories = new Set([defaultDir]); for (const setting of [settings.worktreesDirectory, ...settings.previousWorktreesDirectories]) { - const directory = resolveWorktreesDirectory(setting, defaultDir, path); + const directory = resolveWorktreesDirectory(setting, defaultDir, path, home); if (directory !== null) directories.add(directory); } return [...directories]; diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 872eb0999802..4827ca4e4c56 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -278,7 +278,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< return yield* makeAcpRegistryAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, - environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), selfInvocation, nativeLogging: (threadId) => makeNativeLogger({ diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index bcfd83af7a39..4468aa70af39 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -497,7 +497,10 @@ export const AcpRegistryDriver: ProviderDriver(); const stderrTailRef = yield* Ref.make(""); + const homeDirectory = yield* HostProcessHomeDirectory; const stderrDrained = yield* Deferred.make(); const runtimeClosed = yield* Deferred.make(); const promptSerializationSemaphore = yield* Semaphore.make(1); @@ -1440,7 +1441,7 @@ export const make = ( Effect.ignore, Effect.andThen(Ref.get(stderrTailRef)), Effect.map((tail) => { - const stderr = sanitizeAcpStderrExcerpt(tail); + const stderr = sanitizeAcpStderrExcerpt(tail, homeDirectory); return stderr.length === 0 ? error : new EffectAcpErrors.AcpProcessExitedError({ diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts index f5f704f04996..5e5c42b63cd7 100644 --- a/packages/provider-acp/src/server/stderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -20,6 +20,7 @@ describe("AcpStderr", () => { "Visit http://localhost:5733/pair#token=ABCDEF for pairing", "key=sk-abcdefghijklmnopqrstuv", ].join("\n"), + "/home/server", { HOME: "/Users/ada" }, ); @@ -41,6 +42,8 @@ describe("AcpStderr", () => { "Authorization: Basic dXNlcjpwYXNz", "x-api-key: ant-api-key-value", ].join("\n"), + "/Users/ada", + {}, ); expect(excerpt).toContain("[redacted]"); diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index 52f68bb4c19a..01ed491d8732 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -- The excerpt sanitizer masks the home directory, which only the Node os module can resolve. -import * as NodeOS from "node:os"; - /** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; @@ -19,10 +16,11 @@ export function appendAcpStderrTail(current: string, chunk: string): string { /** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ export function sanitizeAcpStderrExcerpt( text: string, + homeDirectory: string, environment: NodeJS.ProcessEnv = process.env, ): string { let result = text.replaceAll("\0", ""); - const homes = [environment.HOME, environment.USERPROFILE, NodeOS.homedir()].filter( + const homes = [environment.HOME, environment.USERPROFILE, homeDirectory].filter( (value): value is string => typeof value === "string" && value.length > 1, ); for (const home of new Set(homes)) { diff --git a/packages/provider-core/src/server/instanceEnvironment.test.ts b/packages/provider-core/src/server/instanceEnvironment.test.ts index 73d2ae3c313c..e64cd31650f5 100644 --- a/packages/provider-core/src/server/instanceEnvironment.test.ts +++ b/packages/provider-core/src/server/instanceEnvironment.test.ts @@ -1,9 +1,8 @@ -import * as NodeOS from "node:os"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import { mergeProviderInstanceEnvironment } from "./instanceEnvironment.ts"; @@ -18,7 +17,7 @@ describe("mergeProviderInstanceEnvironment", () => { CODEX_HOME: "~/.inherited-codex", CLAUDE_CONFIG_DIR: "~/.inherited-claude", }; - const environment = mergeProviderInstanceEnvironment( + const environment = yield* mergeProviderInstanceEnvironment( [ { name: "CODEX_HOME", value, sensitive: false }, { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, @@ -28,41 +27,48 @@ describe("mergeProviderInstanceEnvironment", () => { ); expect(environment).toEqual({ - CODEX_HOME: path.join(NodeOS.homedir(), tail), - CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CODEX_HOME: path.join("/home/ada", tail), + CLAUDE_CONFIG_DIR: path.join("/home/ada", tail), CUSTOM_VALUE: value, }); expect(baseEnv).toEqual({ CODEX_HOME: "~/.inherited-codex", CLAUDE_CONFIG_DIR: "~/.inherited-claude", }); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe( + Effect.provideService(HostProcessHomeDirectory, "/home/ada"), + Effect.provide(NodeServices.layer), + ), ); - it("leaves inherited provider homes unchanged", () => { - const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + it.effect("leaves inherited provider homes unchanged", () => + Effect.gen(function* () { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; - expect( - mergeProviderInstanceEnvironment( - [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], - baseEnv, - ), - ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); - }); + expect( + yield* mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }), + ); - it("overrides inherited environment values and preserves empty strings", () => { - expect( - mergeProviderInstanceEnvironment( - [ - { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, - { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, - ], - { ANTHROPIC_API_KEY: "inherited", PATH: "/bin" }, - ), - ).toMatchObject({ - OPENROUTER_API_KEY: "sk-or-test", - ANTHROPIC_API_KEY: "", - PATH: "/bin", - }); - }); + it.effect("overrides inherited environment values and preserves empty strings", () => + Effect.gen(function* () { + expect( + yield* mergeProviderInstanceEnvironment( + [ + { name: "OPENROUTER_API_KEY", value: "sk-or-test", sensitive: true }, + { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, + ], + { ANTHROPIC_API_KEY: "inherited", PATH: "/bin" }, + ), + ).toMatchObject({ + OPENROUTER_API_KEY: "sk-or-test", + ANTHROPIC_API_KEY: "", + PATH: "/bin", + }); + }), + ); }); diff --git a/packages/provider-core/src/server/instanceEnvironment.ts b/packages/provider-core/src/server/instanceEnvironment.ts index ff8c9815731a..0ddb61af4b5c 100644 --- a/packages/provider-core/src/server/instanceEnvironment.ts +++ b/packages/provider-core/src/server/instanceEnvironment.ts @@ -1,22 +1,25 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; import { expandHomePath } from "./pathExpansion.ts"; -export function mergeProviderInstanceEnvironment( +export const mergeProviderInstanceEnvironment = Effect.fn(function* ( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, -): NodeJS.ProcessEnv { +) { if (!environment || environment.length === 0) { return baseEnv; } + const home = yield* HostProcessHomeDirectory; const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { // Child processes do not apply shell expansion to environment values. next[variable.name] = variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" - ? expandHomePath(variable.value) + ? expandHomePath(variable.value, home) : variable.value; } return next; -} +}); diff --git a/packages/provider-core/src/server/pathExpansion.test.ts b/packages/provider-core/src/server/pathExpansion.test.ts index cc7c85786da6..46a9faf87e8a 100644 --- a/packages/provider-core/src/server/pathExpansion.test.ts +++ b/packages/provider-core/src/server/pathExpansion.test.ts @@ -1,34 +1,35 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { expandHomePath } from "./pathExpansion.ts"; +const home = "/home/ada"; + describe("expandHomePath", () => { it("returns an empty string unchanged", () => { - expect(expandHomePath("")).toBe(""); + expect(expandHomePath("", home)).toBe(""); }); it("returns paths without a leading tilde unchanged", () => { - expect(expandHomePath("/absolute/path")).toBe("/absolute/path"); - expect(expandHomePath("relative/path")).toBe("relative/path"); - expect(expandHomePath("some~weird~path")).toBe("some~weird~path"); + expect(expandHomePath("/absolute/path", home)).toBe("/absolute/path"); + expect(expandHomePath("relative/path", home)).toBe("relative/path"); + expect(expandHomePath("some~weird~path", home)).toBe("some~weird~path"); }); it("expands a lone tilde to the home directory", () => { - expect(expandHomePath("~")).toBe(NodeOS.homedir()); + expect(expandHomePath("~", home)).toBe(home); }); it("expands ~/ to a subpath of the home directory", () => { - expect(expandHomePath("~/.codex-work")).toBe(NodePath.join(NodeOS.homedir(), ".codex-work")); + expect(expandHomePath("~/.codex-work", home)).toBe(NodePath.join(home, ".codex-work")); }); it("expands a Windows-style ~\\ prefix", () => { - expect(expandHomePath("~\\.codex")).toBe(NodePath.join(NodeOS.homedir(), ".codex")); + expect(expandHomePath("~\\.codex", home)).toBe(NodePath.join(home, ".codex")); }); it("does not expand ~user paths", () => { - expect(expandHomePath("~alice/foo")).toBe("~alice/foo"); + expect(expandHomePath("~alice/foo", home)).toBe("~alice/foo"); }); }); diff --git a/packages/provider-core/src/server/pathExpansion.ts b/packages/provider-core/src/server/pathExpansion.ts index bacdaece0b1c..d486b2988d47 100644 --- a/packages/provider-core/src/server/pathExpansion.ts +++ b/packages/provider-core/src/server/pathExpansion.ts @@ -1,12 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; /** - * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the - * current user's home directory. Spawned processes don't get shell - * expansion, so env vars like `CODEX_HOME=~/.codex-work` would be passed - * verbatim and treated as relative paths by the receiver. + * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to `home`, + * the current user's home directory (`HostProcessHomeDirectory`). Spawned + * processes don't get shell expansion, so env vars like + * `CODEX_HOME=~/.codex-work` would be passed verbatim and treated as relative + * paths by the receiver. * * Matches the behavior of the other `expandHomePath` helpers in the * workspace layers and CLI bootstrap: `~` alone and both `~/` and `~\` @@ -14,11 +14,11 @@ import * as NodePath from "node:path"; * start with `~` or is empty. Does not handle `~user` (other-user) * expansion. */ -export function expandHomePath(value: string): string { +export function expandHomePath(value: string, home: string): string { if (!value) return value; - if (value === "~") return NodeOS.homedir(); + if (value === "~") return home; if (value.startsWith("~/") || value.startsWith("~\\")) { - return NodePath.join(NodeOS.homedir(), value.slice(2)); + return NodePath.join(home, value.slice(2)); } return value; } diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 852d746aed00..56f64104a449 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -10,12 +10,15 @@ * * @module provider-cursor/server/CursorUsageAccounts */ -import * as NodeOS from "node:os"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + HostProcessHomeDirectory, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; @@ -283,7 +286,7 @@ const make = Effect.gen(function* () { const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( function* ({ keychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh }) { - const home = NodeOS.homedir(); + const home = yield* HostProcessHomeDirectory; const userHome = (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index ecca0e80ac7a..fa557d6d8bc1 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2627,7 +2627,7 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< ...input.config, enabled: input.enabled, }, - environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), }); }, (effect, input) => diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index a9fa4b31acb4..2c748bb70d5e 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -99,7 +99,7 @@ export const CursorDriver: ProviderDriver< const httpClient = yield* HttpClient.HttpClient; const keychain = yield* CursorKeychain.CursorKeychain; const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts index c8cd561dfea0..4c7b0bbea985 100644 --- a/packages/provider-cursor/src/server/skills.ts +++ b/packages/provider-cursor/src/server/skills.ts @@ -8,7 +8,6 @@ * * @module provider/Drivers/CursorSkills */ -import * as NodeOS from "node:os"; import type { ServerProviderSkill } from "@t3tools/contracts"; import * as ByteSize from "effect/ByteSize"; @@ -18,6 +17,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { parse as parseYamlDocument } from "yaml"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const SKILL_MENTION_PATTERN = @@ -221,7 +221,10 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( environment: NodeJS.ProcessEnv = process.env, ) { const path = yield* Path.Path; - const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + const userHome = + environment.HOME?.trim() || + environment.USERPROFILE?.trim() || + (yield* HostProcessHomeDirectory); const rootsBelow = (base: string, scope: "user" | "project") => [ { directory: path.join(base, ".cursor", "skills"), scope }, { directory: path.join(base, ".agents", "skills"), scope }, diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 59fa3dfc452d..6a6b8ae989cf 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -1,4 +1,3 @@ -import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import type { AgentOptions, RunResult } from "@cursor/sdk"; @@ -13,6 +12,7 @@ import * as TextGenerationOperations from "@t3tools/provider-core/server/textGen import { cursorSdkModelSelection } from "./sdkModel.ts"; import type { CursorAuth } from "./auth.ts"; import * as CursorSdk from "./CursorSdk.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const CURSOR_TIMEOUT_MS = 180_000; @@ -90,7 +90,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const apiKey = yield* resolveCursorApiKey(operation); // The SDK loads sandbox.json independently of settingSources and lets it // expand the writable paths. Its public API cannot override that policy. - if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) { + if (yield* fs.exists(`${yield* HostProcessHomeDirectory}/.cursor/sandbox.json`)) { return yield* new TextGenerationError({ operation, detail: diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 57f74bc54488..dd29a443cd3a 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -1,7 +1,6 @@ -import * as NodeOS from "node:os"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -109,7 +108,8 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function token = (yield* keychain.accessToken)?.trim(); } else if (!token) { const home = - (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); + (platform === "win32" ? environment.USERPROFILE : environment.HOME) || + (yield* HostProcessHomeDirectory); const directory = platform === "win32" ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 8100246ace18..cca040e81698 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -4,7 +4,11 @@ import { isXAiTaskCompletedWakeNotification, xAiRateLimitedErrorCode, } from "./xaiAcpExtension.ts"; -import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + HostProcessHomeDirectory, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime"; import { defaultInstanceIdForDriver, @@ -227,7 +231,10 @@ export function grokLaunchRuntimeMode( } /** The flavor runs Grok's launcher through the adapter's spawner. */ -export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor { +export function makeGrokAcpAdapterFlavor( + options: GrokAdapterV2Options, + homeDirectory: string, +): AcpAdapterV2Flavor { return { driver: GROK_PROVIDER, runtimeHarness: "Grok", @@ -311,6 +318,7 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap extractGrokPlanMarkdownFromToolCallData(toolCall.data, { platform: options.hostPlatform, environment: options.environment, + homeDirectory, }), extractBackgroundTaskId: extractXAiMonitorTaskId, extractBackgroundToolMutation: extractXAiAcpBackgroundToolMutation, @@ -331,7 +339,7 @@ export const makeGrokAdapterV2 = Effect.fn("makeGrokAdapterV2")(function* ( ) { return yield* makeAcpAdapterV2({ instanceId: options.instanceId, - flavor: makeGrokAcpAdapterFlavor(options), + flavor: makeGrokAcpAdapterFlavor(options, yield* HostProcessHomeDirectory), selfInvocation: options.selfInvocation, ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), ...(options.continuationRequests === undefined @@ -366,7 +374,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver const path = yield* Path.Path; const host = yield* ProviderHost.ProviderHost; const { cwd } = host.paths; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/packages/provider-grok/src/server/usage.ts b/packages/provider-grok/src/server/usage.ts index 9f2be3c04299..8b5c0db45ea6 100644 --- a/packages/provider-grok/src/server/usage.ts +++ b/packages/provider-grok/src/server/usage.ts @@ -4,7 +4,6 @@ * * @module provider-grok/server/usage */ -import * as NodeOS from "node:os"; import type { UsageTokenTotals } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -20,6 +19,7 @@ import { } from "@t3tools/provider-core/server/usage"; import type { GrokSettings } from "../settings.ts"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; /** * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok @@ -229,8 +229,10 @@ export const grokUsageReader: ProviderUsageReader = { format: grokUsageFormat, directories: Effect.fn("grokUsageReader.directories")(function* ({ environment }) { const path = yield* Path.Path; + const homeDirectory = yield* HostProcessHomeDirectory; const home = expandHomePath( - environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"), + environment.GROK_HOME?.trim() || path.join(homeDirectory, ".grok"), + homeDirectory, ); // Sessions also ship multi-megabyte `chat_history` and `events` logs that // never carry usage; only `updates.jsonl` does. diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 83369c062c16..b7db7f6fd79a 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -1,4 +1,3 @@ -import * as NodeOS from "node:os"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -14,6 +13,7 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const GrokCredentials = Schema.Record( Schema.String, @@ -100,7 +100,10 @@ const readGrokCredential = Effect.fn("readGrokCredential")(function* ( const path = yield* Path.Path; const home = environment.GROK_HOME?.trim() || - path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); + path.join( + environment.HOME || environment.USERPROFILE || (yield* HostProcessHomeDirectory), + ".grok", + ); for (const configPath of [ path.join(home, "config.toml"), path.join(home, "managed_config.toml"), diff --git a/packages/provider-grok/src/server/xaiAcpExtension.test.ts b/packages/provider-grok/src/server/xaiAcpExtension.test.ts index 5dce41edead9..18803e0f39e4 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.test.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.test.ts @@ -1,5 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -- plan-path tests resolve the OS home dir. -import * as NodeOS from "node:os"; import { it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -1902,13 +1900,14 @@ describe("Grok exit_plan_mode capture (#8358)", () => { expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN); }); it("identifies Grok plan.md paths and extracts markdown from tool call data", () => { - const linuxHost = { platform: "linux" as const, environment: {} }; - const windowsHost = { platform: "win32" as const, environment: {} }; + const home = "/home/ada"; + const linuxHost = { platform: "linux" as const, environment: {}, homeDirectory: home }; + const windowsHost = { platform: "win32" as const, environment: {}, homeDirectory: home }; const grokHomeHost = { platform: "linux" as const, environment: { GROK_HOME: "/opt/grok-data" }, + homeDirectory: home, }; - const home = NodeOS.homedir().replace(/\\/g, "/"); const sessionPlan = `${home}/.grok/sessions/abc/plan.md`; const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`; expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true); @@ -1935,6 +1934,7 @@ describe("Grok exit_plan_mode capture (#8358)", () => { isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", { platform: "win32", environment: { GROK_HOME: "/opt/grok-data" }, + homeDirectory: home, }), ).toBe(true); expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe( diff --git a/packages/provider-grok/src/server/xaiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts index 4c424a664d90..3fe2c2886dea 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Grok's plan file lives under the OS home dir. -import * as NodeOS from "node:os"; - import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -1088,11 +1085,13 @@ function addGrokSessionPrefix( export interface GrokPlanPathHost { readonly platform: NodeJS.Platform; readonly environment: NodeJS.ProcessEnv; + readonly homeDirectory: string; } -function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet { +function grokPlanSessionPrefixes(host: GrokPlanPathHost): ReadonlySet { + const { environment } = host; const prefixes = new Set(); - addGrokSessionPrefix(prefixes, NodeOS.homedir(), true); + addGrokSessionPrefix(prefixes, host.homeDirectory, true); addGrokSessionPrefix(prefixes, "~", true); addGrokSessionPrefix(prefixes, environment.HOME ?? "", true); addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true); @@ -1134,7 +1133,7 @@ export function isGrokPlanMarkdownPath( ) { return false; } - for (const prefix of grokPlanSessionPrefixes(host.environment)) { + for (const prefix of grokPlanSessionPrefixes(host)) { const needle = win32 ? prefix.toLowerCase() : prefix; if (!haystack.startsWith(needle)) { continue; diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts index 1d2922e54270..3b9df42100a6 100644 --- a/packages/provider-muse/src/server/driver.ts +++ b/packages/provider-muse/src/server/driver.ts @@ -1,6 +1,6 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { MuseSettings } from "../settings.ts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -71,14 +71,14 @@ export const MuseDriver: ProviderDriver = { const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; const hostEnvironment = yield* HostProcessEnvironment; // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins. - const processEnvironment = mergeProviderInstanceEnvironment( + const processEnvironment = yield* mergeProviderInstanceEnvironment( environment, makeMuseEnvironment(hostEnvironment), ); const effectiveConfig = { ...config, enabled, - binaryPath: expandHomePath(config.binaryPath), + binaryPath: expandHomePath(config.binaryPath, yield* HostProcessHomeDirectory), } satisfies MuseSettings; const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 96d535070294..c7020b8ff45a 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -3744,7 +3744,7 @@ export const OpenCodeAdapterV2Driver: ProviderAdapterDriver< return yield* makeOpenCodeAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, - environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), ...(providerEventLoggers.native === undefined ? {} : { nativeEventLogger: providerEventLoggers.native }), diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 0573db664969..f3340a62e9e0 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -206,7 +206,7 @@ export const OpenCodeDriver: ProviderDriver< const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const crypto = yield* Crypto.Crypto; const host = yield* ProviderHost.ProviderHost; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index f0005ad61608..65897f85d98e 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -4,7 +4,6 @@ * * @module provider-opencode/server/usage */ -import * as NodeOS from "node:os"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { @@ -12,7 +11,7 @@ import { type ProviderUsageReader, type UsageRecord, } from "@t3tools/provider-core/server/usage"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -259,6 +258,7 @@ const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* ( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const environment = yield* HostProcessEnvironment; + const homeDirectory = yield* HostProcessHomeDirectory; const roots = environment["OPENCODE_DATA_DIR"] ?.split(",") .map((value) => value.trim()) @@ -268,13 +268,13 @@ const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* ( path.join( dataHome && path.isAbsolute(dataHome) ? dataHome - : path.join(NodeOS.homedir(), ".local", "share"), + : path.join(homeDirectory, ".local", "share"), "opencode", ), ]; const canonical = new Set(); for (const root of roots?.length ? roots : defaults) { - const resolved = path.resolve(expandHomePath(root)); + const resolved = path.resolve(expandHomePath(root, homeDirectory)); canonical.add(yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved))); } return [...canonical]; diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 3c21536b8798..05c3b79d411a 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -18,6 +16,7 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; +import { HostProcessHomeDirectory } from "@t3tools/shared/hostProcess"; const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); @@ -47,7 +46,11 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( const env = input.environment; const dataHome = env.XDG_DATA_HOME || - path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); + path.join( + env.HOME || env.USERPROFILE || (yield* HostProcessHomeDirectory), + ".local", + "share", + ); const authPath = path.join(dataHome, "opencode", "auth.json"); const contents = env.OPENCODE_AUTH_CONTENT || diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index eecba9c93aad..c6bdc4338ab3 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3276,7 +3276,7 @@ export const PiAdapterV2Driver: ProviderAdapterDriver = { const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const host = yield* ProviderHost.ProviderHost; const { cwd } = host.paths; - const processEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 9bd41a825044..d729624b7630 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -25,6 +25,13 @@ export const HostProcessHostname = Context.Reference( }, ); +export const HostProcessHomeDirectory = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessHomeDirectory", + { + defaultValue: () => NodeOS.homedir(), + }, +); + export const HostProcessEnvironment = Context.Reference( "@t3tools/shared/hostProcess/HostProcessEnvironment", { diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 73b0098aa403..46afc4522b45 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -6,13 +6,12 @@ import * as Result from "effect/Result"; import * as Clock from "effect/Clock"; import * as FileSystem from "effect/FileSystem"; import * as Semaphore from "effect/Semaphore"; -import * as NodeOS from "node:os"; // @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform. import * as NodePath from "node:path"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessHomeDirectory, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; @@ -250,7 +249,7 @@ export const make = Effect.gen(function* () { const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { for (const path of forgejoKeysPaths({ platform: yield* HostProcessPlatform, - home: NodeOS.homedir(), + home: yield* HostProcessHomeDirectory, ...(globalThis.process.env.XDG_DATA_HOME ? { dataHome: globalThis.process.env.XDG_DATA_HOME } : {}),