fix(cli): tolerate deleted startup directories (#93636)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ml12580
2026-07-06 03:10:24 +08:00
committed by GitHub
parent b6a7898c7c
commit fb22fd9e0e
14 changed files with 163 additions and 23 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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 }) => {

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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. */

View File

@@ -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");

View File

@@ -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<string>,
): { 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);

9
src/infra/safe-cwd.ts Normal file
View File

@@ -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;
}
}

View File

@@ -27,6 +27,7 @@ function createOverlayHandle(): OverlayHandle {
function createShellHarness(params?: {
spawnCommand?: typeof import("node:child_process").spawn;
getCwd?: () => string | undefined;
env?: Record<string, string>;
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",
);
});
});

View File

@@ -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" },
});

View File

@@ -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", () => {

View File

@@ -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<TuiResult> {
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<TuiResult> {
cfg: config,
fallbackAgentId: agentDefaultId,
initialSessionInput,
cwd: process.cwd(),
});
let agents: AgentSummary[] = [];
const agentNames = new Map<string, string>();
@@ -821,7 +822,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
thinkingLevels: sessionInfo.thinkingLevels,
dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [],
}),
process.cwd(),
resolveUsableCwd(),
),
);
};
@@ -1215,7 +1216,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
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,