mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 06:26:09 +00:00
fix(cli): tolerate deleted startup directories (#93636)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
9
src/infra/safe-cwd.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user