diff --git a/src/cli/dotenv.ts b/src/cli/dotenv.ts index 5439f7877ec4..dcf877d6f00f 100644 --- a/src/cli/dotenv.ts +++ b/src/cli/dotenv.ts @@ -2,12 +2,15 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadGlobalRuntimeDotEnvFiles, loadWorkspaceDotEnvFile } from "../infra/dotenv.js"; +import { tryProcessCwd } from "../infra/safe-cwd.js"; /** Load `.env` files for normal CLI commands without overriding existing process env. */ export function loadCliDotEnv(opts?: { loadGlobalEnv?: boolean; quiet?: boolean }) { const quiet = opts?.quiet ?? true; - const cwdEnvPath = path.join(process.cwd(), ".env"); - loadWorkspaceDotEnvFile(cwdEnvPath, { quiet }); + const cwd = tryProcessCwd(); + if (cwd) { + loadWorkspaceDotEnvFile(path.join(cwd, ".env"), { quiet }); + } if (opts?.loadGlobalEnv === false) { return; diff --git a/src/cli/gateway-dispatch-dotenv.ts b/src/cli/gateway-dispatch-dotenv.ts index 4060f1eae890..ed6cd011d535 100644 --- a/src/cli/gateway-dispatch-dotenv.ts +++ b/src/cli/gateway-dispatch-dotenv.ts @@ -3,12 +3,13 @@ import fs from "node:fs"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { loadGlobalRuntimeDotEnvFiles } from "../infra/dotenv-global.js"; +import { tryProcessCwd } from "../infra/safe-cwd.js"; /** Load only the env files needed before dispatching a command through the gateway. */ export async function loadGatewayDispatchCliDotEnv(opts?: { quiet?: boolean }) { const quiet = opts?.quiet ?? true; - const cwdEnvPath = path.join(process.cwd(), ".env"); - if (fs.existsSync(cwdEnvPath)) { + const cwd = tryProcessCwd(); + if (cwd && fs.existsSync(path.join(cwd, ".env"))) { const { loadCliDotEnv } = await import("./dotenv.js"); loadCliDotEnv({ quiet }); return; diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index d598e48836f0..fe14039fbba0 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -12,6 +12,7 @@ import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js"; import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; +import { tryProcessCwd } from "../infra/safe-cwd.js"; import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { @@ -542,7 +543,8 @@ export function resolveMissingPluginCommandMessage( } function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean { - if (existsSync(path.join(process.cwd(), ".env"))) { + const cwd = tryProcessCwd(); + if (cwd && existsSync(path.join(cwd, ".env"))) { return true; } return existsSync(path.join(resolveStateDir(env), ".env")); diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 03f85bdb3b0f..ef45d981db89 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -215,6 +215,22 @@ describe("loadDotEnv", () => { }); }); + it("loads global env when the working directory was deleted", async () => { + await withIsolatedEnvAndCwd(async () => { + await withDotEnvFixture(async ({ stateDir }) => { + await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n"); + vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + delete process.env.FOO; + + loadDotEnv({ quiet: true }); + + expect(process.env.FOO).toBe("from-global"); + }); + }); + }); + it("loads the Ubuntu gateway.env compatibility fallback after ~/.openclaw/.env", async () => { await withIsolatedEnvAndCwd(async () => { await withDotEnvFixture(async ({ base, cwdDir }) => { @@ -719,6 +735,22 @@ describe("loadCliDotEnv", () => { }); }); + it("loads global CLI env when the working directory was deleted", async () => { + await withIsolatedEnvAndCwd(async () => { + await withDotEnvFixture(async ({ stateDir }) => { + await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n"); + vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + delete process.env.FOO; + + loadCliDotEnv({ quiet: true }); + + expect(process.env.FOO).toBe("from-global"); + }); + }); + }); + it("does not load gateway.env when OPENCLAW_STATE_DIR is explicitly set", async () => { await withIsolatedEnvAndCwd(async () => { await withDotEnvFixture(async ({ base, cwdDir }) => { diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index 7fa484b4358d..86b8b202874a 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -7,6 +7,7 @@ import { isDangerousHostEnvVarName, normalizeEnvVarKey, } from "./host-env-security.js"; +import { tryProcessCwd } from "./safe-cwd.js"; const BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS = [ "AI_GATEWAY_API_KEY", @@ -245,8 +246,10 @@ export { loadGlobalRuntimeDotEnvFiles }; export function loadDotEnv(opts?: { quiet?: boolean }) { const quiet = opts?.quiet ?? true; - const cwdEnvPath = path.join(process.cwd(), ".env"); - loadWorkspaceDotEnvFile(cwdEnvPath, { quiet }); + const cwd = tryProcessCwd(); + if (cwd) { + loadWorkspaceDotEnvFile(path.join(cwd, ".env"), { quiet }); + } // Then load global fallback: ~/.openclaw/.env (or OPENCLAW_STATE_DIR/.env), // without overriding any env vars already present. diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index 541d65a0d08d..5d34b2135d16 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -1,6 +1,6 @@ // Tests OpenClaw home directory resolution. import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { expandHomePrefix, resolveEffectiveHomeDir, @@ -8,6 +8,7 @@ import { resolveOsHomeDir, resolveOsHomeRelativePath, resolveRequiredHomeDir, + resolveRequiredOsHomeDir, } from "./home-dir.js"; describe("resolveEffectiveHomeDir", () => { @@ -166,6 +167,22 @@ describe("resolveRequiredHomeDir", () => { ])("$name", ({ env, homedir, expected }) => { expect(resolveRequiredHomeDir(env, homedir)).toBe(expected); }); + + it("fails clearly when both home and cwd are unavailable", () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + const noHome = () => { + throw new Error("no home"); + }; + + try { + expect(() => resolveRequiredHomeDir({}, noHome)).toThrow(/set OPENCLAW_HOME/i); + expect(() => resolveRequiredOsHomeDir({}, noHome)).toThrow(/set HOME/i); + } finally { + cwdSpy.mockRestore(); + } + }); }); describe("resolveOsHomeDir", () => { diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 83c2600a176e..18c75e87960a 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -1,6 +1,7 @@ // Resolves OpenClaw home and platform-specific config directories. import os from "node:os"; import path from "node:path"; +import { tryProcessCwd } from "./safe-cwd.js"; function normalize(value: string | undefined): string | undefined { const trimmed = value?.trim(); @@ -75,7 +76,13 @@ export function resolveRequiredHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { - return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd()); + const resolved = resolveEffectiveHomeDir(env, homedir) ?? tryProcessCwd(); + if (resolved) { + return path.resolve(resolved); + } + throw new Error( + "Unable to resolve an OpenClaw home: set OPENCLAW_HOME, HOME, or USERPROFILE, or run from an existing directory.", + ); } /** Resolves the OS home or falls back to cwd when no OS home source exists. */ @@ -83,7 +90,13 @@ export function resolveRequiredOsHomeDir( env: NodeJS.ProcessEnv = process.env, homedir: () => string = os.homedir, ): string { - return resolveOsHomeDir(env, homedir) ?? path.resolve(process.cwd()); + const resolved = resolveOsHomeDir(env, homedir) ?? tryProcessCwd(); + if (resolved) { + return path.resolve(resolved); + } + throw new Error( + "Unable to resolve an OS home: set HOME or USERPROFILE, or run from an existing directory.", + ); } /** Expands leading `~`, `~/`, or `~\` with the effective home when one is known. */ diff --git a/src/infra/path-env.test.ts b/src/infra/path-env.test.ts index 83f1574b15bd..4e1cb6763cdb 100644 --- a/src/infra/path-env.test.ts +++ b/src/infra/path-env.test.ts @@ -232,6 +232,26 @@ describe("ensureOpenClawCliOnPath", () => { }, ); + it("skips project-local bins when the working directory was deleted", () => { + const { tmp, appCli } = setupAppCliRoot("case-deleted-cwd"); + const localBinDir = path.join(tmp, "node_modules", ".bin"); + setDir(localBinDir); + setExe(path.join(localBinDir, "openclaw")); + resetBootstrapEnv(); + process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN = "1"; + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + + try { + ensureOpenClawCliOnPath({ execPath: appCli, homeDir: tmp, platform: "darwin" }); + } finally { + cwdSpy.mockRestore(); + } + + expect((process.env.PATH ?? "").split(path.delimiter)).not.toContain(localBinDir); + }); + it("prepends XDG_BIN_HOME ahead of other user bin fallbacks", () => { const { tmp, appCli } = setupAppCliRoot("case-xdg-bin-home"); const xdgBinHome = path.join(tmp, "xdg-bin"); diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index d6b56a5ebae1..71e0f593c766 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -8,6 +8,7 @@ import { } from "@openclaw/normalization-core/string-normalization"; import { resolveBrewPathDirs } from "./brew.js"; import { isTruthyEnvValue } from "./env.js"; +import { tryProcessCwd } from "./safe-cwd.js"; type EnsureOpenClawPathOpts = { /** Executable whose directory should stay first for shebang-compatible child processes. */ @@ -80,7 +81,7 @@ function candidateBinDirs( existingPathParts: ReadonlySet, ): { prepend: string[]; append: string[] } { const execPath = opts.execPath ?? process.execPath; - const cwd = opts.cwd ?? process.cwd(); + const cwd = opts.cwd ?? tryProcessCwd(); const homeDir = opts.homeDir ?? os.homedir(); const platform = opts.platform ?? process.platform; @@ -114,7 +115,7 @@ function candidateBinDirs( const allowProjectLocalBin = opts.allowProjectLocalBin === true || isTruthyEnvValue(process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN); - if (allowProjectLocalBin) { + if (allowProjectLocalBin && cwd) { const localBinDir = path.join(cwd, "node_modules", ".bin"); if (isExecutable(path.join(localBinDir, "openclaw"))) { append.push(localBinDir); diff --git a/src/infra/safe-cwd.ts b/src/infra/safe-cwd.ts new file mode 100644 index 000000000000..63916236cbc2 --- /dev/null +++ b/src/infra/safe-cwd.ts @@ -0,0 +1,9 @@ +// A removed launch directory makes Node's process.cwd() throw before callers can recover. +// Keep the absence explicit so each trust boundary chooses whether to skip, fail, or fall back. +export function tryProcessCwd(): string | undefined { + try { + return process.cwd(); + } catch { + return undefined; + } +} diff --git a/src/tui/tui-local-shell.test.ts b/src/tui/tui-local-shell.test.ts index a524dca7fe70..8338a6bf66f1 100644 --- a/src/tui/tui-local-shell.test.ts +++ b/src/tui/tui-local-shell.test.ts @@ -27,6 +27,7 @@ function createOverlayHandle(): OverlayHandle { function createShellHarness(params?: { spawnCommand?: typeof import("node:child_process").spawn; + getCwd?: () => string | undefined; env?: Record; maxOutputChars?: number; }) { @@ -53,6 +54,7 @@ function createShellHarness(params?: { closeOverlay, createSelector: createSelectorSpy, spawnCommand, + ...(params?.getCwd ? { getCwd: params.getCwd } : {}), ...(params?.env ? { env: params.env } : {}), ...(params?.maxOutputChars !== undefined ? { maxOutputChars: params.maxOutputChars } : {}), }); @@ -162,4 +164,17 @@ describe("createLocalShellRunner", () => { // the previous head-cut kept all stdout and dropped stderr entirely. expect(harness.messages.some((m) => m.includes("FATAL"))).toBe(true); }); + + it("refuses to retarget local commands after the working directory is deleted", async () => { + const harness = createShellHarness({ getCwd: () => undefined }); + + const run = harness.runLocalShellLine("!pwd"); + harness.getLastSelector()?.onSelect?.({ value: "yes", label: "Yes" }); + await run; + + expect(harness.spawnCommand).not.toHaveBeenCalled(); + expect(harness.messages).toContain( + "local shell: working directory was deleted; cd to an existing directory first", + ); + }); }); diff --git a/src/tui/tui-local-shell.ts b/src/tui/tui-local-shell.ts index 94d69d221dec..5f7fcf0cf4df 100644 --- a/src/tui/tui-local-shell.ts +++ b/src/tui/tui-local-shell.ts @@ -1,6 +1,7 @@ // Launches and manages the local shell process used by TUI local mode. import { spawn } from "node:child_process"; import type { Component, OverlayHandle, SelectItem } from "@earendil-works/pi-tui"; +import { tryProcessCwd } from "../infra/safe-cwd.js"; import { createSearchableSelectList } from "./components/selectors.js"; type LocalShellDeps = { @@ -20,7 +21,7 @@ type LocalShellDeps = { onCancel?: () => void; }; spawnCommand?: typeof spawn; - getCwd?: () => string; + getCwd?: () => string | undefined; env?: NodeJS.ProcessEnv; maxOutputChars?: number; }; @@ -30,7 +31,7 @@ export function createLocalShellRunner(deps: LocalShellDeps) { let localExecAllowed = false; const createSelector = deps.createSelector ?? createSearchableSelectList; const spawnCommand = deps.spawnCommand ?? spawn; - const getCwd = deps.getCwd ?? (() => process.cwd()); + const getCwd = deps.getCwd ?? tryProcessCwd; const env = deps.env ?? process.env; const maxChars = deps.maxOutputChars ?? 40_000; @@ -98,6 +99,16 @@ export function createLocalShellRunner(deps: LocalShellDeps) { return; } + // A shell command's meaning depends on its directory; never retarget it implicitly. + const cwd = getCwd(); + if (!cwd) { + deps.chatLog.addSystem( + "local shell: working directory was deleted; cd to an existing directory first", + ); + deps.tui.requestRender(); + return; + } + deps.chatLog.addSystem(`[local] $ ${cmd}`); deps.tui.requestRender(); @@ -111,7 +122,7 @@ export function createLocalShellRunner(deps: LocalShellDeps) { // Intentionally a shell: this is an operator-only local TUI feature (prefixed with `!`) // and is gated behind an explicit in-session approval prompt. shell: true, - cwd: getCwd(), + cwd, env: { ...env, OPENCLAW_SHELL: "tui-local" }, }); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index e67114d3e47d..1c747cc39c4e 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -338,6 +338,18 @@ describe("resolveInitialTuiAgentId", () => { }), ).toBe("main"); }); + + it("falls back when the working directory was deleted", () => { + const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("ENOENT: uv_cwd"); + }); + + try { + expect(resolveInitialTuiAgentId({ cfg, fallbackAgentId: "main" })).toBe("main"); + } finally { + cwdSpy.mockRestore(); + } + }); }); describe("resolveGatewayDisconnectState", () => { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 6642d9c004dd..16a23b4e1edd 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -19,6 +19,7 @@ import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/ import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { isChatStopCommandText } from "../gateway/chat-abort.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { tryProcessCwd } from "../infra/safe-cwd.js"; import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js"; import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; import { setConsoleSubsystemFilter } from "../logging/console.js"; @@ -157,7 +158,8 @@ export function resolveLocalAuthSpawnInvocation(params: { } export function resolveLocalAuthSpawnCwd(params: { args: string[]; defaultCwd?: string }): string { - const defaultCwd = params.defaultCwd ?? process.cwd(); + const defaultCwd = + params.defaultCwd ?? tryProcessCwd() ?? path.dirname(OPENCLAW_CLI_WRAPPER_PATH); const entryArg = params.args[0]?.trim(); if (!entryArg) { return defaultCwd; @@ -218,10 +220,8 @@ export function resolveInitialTuiAgentId(params: { return normalizeAgentId(parsed.agentId); } - const inferredFromWorkspace = resolveAgentIdByWorkspacePath( - params.cfg, - params.cwd ?? process.cwd(), - ); + const cwd = params.cwd ?? tryProcessCwd(); + const inferredFromWorkspace = cwd ? resolveAgentIdByWorkspacePath(params.cfg, cwd) : null; if (inferredFromWorkspace) { return inferredFromWorkspace; } @@ -530,6 +530,8 @@ function resolveEmptySessionInfoDefaults(config: OpenClawConfig): SessionInfo { export async function runTui(opts: RunTuiOptions): Promise { const isLocalMode = opts.local === true || opts.backend !== undefined; const config = opts.config ?? getRuntimeConfig({ skipPluginValidation: !isLocalMode }); + const fallbackCwd = path.dirname(OPENCLAW_CLI_WRAPPER_PATH); + const resolveUsableCwd = () => tryProcessCwd() ?? fallbackCwd; const emptySessionInfoDefaults = resolveEmptySessionInfoDefaults(config); const initialSessionInput = (opts.session ?? "").trim(); let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; @@ -539,7 +541,6 @@ export async function runTui(opts: RunTuiOptions): Promise { cfg: config, fallbackAgentId: agentDefaultId, initialSessionInput, - cwd: process.cwd(), }); let agents: AgentSummary[] = []; const agentNames = new Map(); @@ -821,7 +822,7 @@ export async function runTui(opts: RunTuiOptions): Promise { thinkingLevels: sessionInfo.thinkingLevels, dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [], }), - process.cwd(), + resolveUsableCwd(), ), ); }; @@ -1215,7 +1216,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const invocation = resolveLocalAuthSpawnInvocation({ command, args }); const child = spawn(invocation.command, invocation.args, { - cwd: resolveLocalAuthSpawnCwd({ args, defaultCwd: process.cwd() }), + cwd: resolveLocalAuthSpawnCwd({ args, defaultCwd: resolveUsableCwd() }), env: process.env, stdio: "inherit", ...invocation.options,