Skip to content

Commit ede4604

Browse files
steipeteml12580
andcommitted
fix(cli): tolerate deleted startup directories
Co-authored-by: ml12580 <long.xinyuan3@xydigit.com>
1 parent f04d18a commit ede4604

14 files changed

Lines changed: 163 additions & 23 deletions

src/cli/dotenv.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
import path from "node:path";
33
import { resolveStateDir } from "../config/paths.js";
44
import { loadGlobalRuntimeDotEnvFiles, loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
5+
import { tryProcessCwd } from "../infra/safe-cwd.js";
56

67
/** Load `.env` files for normal CLI commands without overriding existing process env. */
78
export function loadCliDotEnv(opts?: { loadGlobalEnv?: boolean; quiet?: boolean }) {
89
const quiet = opts?.quiet ?? true;
9-
const cwdEnvPath = path.join(process.cwd(), ".env");
10-
loadWorkspaceDotEnvFile(cwdEnvPath, { quiet });
10+
const cwd = tryProcessCwd();
11+
if (cwd) {
12+
loadWorkspaceDotEnvFile(path.join(cwd, ".env"), { quiet });
13+
}
1114

1215
if (opts?.loadGlobalEnv === false) {
1316
return;

src/cli/gateway-dispatch-dotenv.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import fs from "node:fs";
33
import path from "node:path";
44
import { resolveStateDir } from "../config/paths.js";
55
import { loadGlobalRuntimeDotEnvFiles } from "../infra/dotenv-global.js";
6+
import { tryProcessCwd } from "../infra/safe-cwd.js";
67

78
/** Load only the env files needed before dispatching a command through the gateway. */
89
export async function loadGatewayDispatchCliDotEnv(opts?: { quiet?: boolean }) {
910
const quiet = opts?.quiet ?? true;
10-
const cwdEnvPath = path.join(process.cwd(), ".env");
11-
if (fs.existsSync(cwdEnvPath)) {
11+
const cwd = tryProcessCwd();
12+
if (cwd && fs.existsSync(path.join(cwd, ".env"))) {
1213
const { loadCliDotEnv } = await import("./dotenv.js");
1314
loadCliDotEnv({ quiet });
1415
return;

src/cli/run-main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
1212
import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js";
1313
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
1414
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
15+
import { tryProcessCwd } from "../infra/safe-cwd.js";
1516
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
1617
import { resolveCliArgvInvocation } from "./argv-invocation.js";
1718
import {
@@ -542,7 +543,8 @@ export function resolveMissingPluginCommandMessage(
542543
}
543544

544545
function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
545-
if (existsSync(path.join(process.cwd(), ".env"))) {
546+
const cwd = tryProcessCwd();
547+
if (cwd && existsSync(path.join(cwd, ".env"))) {
546548
return true;
547549
}
548550
return existsSync(path.join(resolveStateDir(env), ".env"));

src/infra/dotenv.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,22 @@ describe("loadDotEnv", () => {
215215
});
216216
});
217217

218+
it("loads global env when the working directory was deleted", async () => {
219+
await withIsolatedEnvAndCwd(async () => {
220+
await withDotEnvFixture(async ({ stateDir }) => {
221+
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
222+
vi.spyOn(process, "cwd").mockImplementation(() => {
223+
throw new Error("ENOENT: uv_cwd");
224+
});
225+
delete process.env.FOO;
226+
227+
loadDotEnv({ quiet: true });
228+
229+
expect(process.env.FOO).toBe("from-global");
230+
});
231+
});
232+
});
233+
218234
it("loads the Ubuntu gateway.env compatibility fallback after ~/.openclaw/.env", async () => {
219235
await withIsolatedEnvAndCwd(async () => {
220236
await withDotEnvFixture(async ({ base, cwdDir }) => {
@@ -719,6 +735,22 @@ describe("loadCliDotEnv", () => {
719735
});
720736
});
721737

738+
it("loads global CLI env when the working directory was deleted", async () => {
739+
await withIsolatedEnvAndCwd(async () => {
740+
await withDotEnvFixture(async ({ stateDir }) => {
741+
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
742+
vi.spyOn(process, "cwd").mockImplementation(() => {
743+
throw new Error("ENOENT: uv_cwd");
744+
});
745+
delete process.env.FOO;
746+
747+
loadCliDotEnv({ quiet: true });
748+
749+
expect(process.env.FOO).toBe("from-global");
750+
});
751+
});
752+
});
753+
722754
it("does not load gateway.env when OPENCLAW_STATE_DIR is explicitly set", async () => {
723755
await withIsolatedEnvAndCwd(async () => {
724756
await withDotEnvFixture(async ({ base, cwdDir }) => {

src/infra/dotenv.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isDangerousHostEnvVarName,
88
normalizeEnvVarKey,
99
} from "./host-env-security.js";
10+
import { tryProcessCwd } from "./safe-cwd.js";
1011

1112
const BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS = [
1213
"AI_GATEWAY_API_KEY",
@@ -245,8 +246,10 @@ export { loadGlobalRuntimeDotEnvFiles };
245246

246247
export function loadDotEnv(opts?: { quiet?: boolean }) {
247248
const quiet = opts?.quiet ?? true;
248-
const cwdEnvPath = path.join(process.cwd(), ".env");
249-
loadWorkspaceDotEnvFile(cwdEnvPath, { quiet });
249+
const cwd = tryProcessCwd();
250+
if (cwd) {
251+
loadWorkspaceDotEnvFile(path.join(cwd, ".env"), { quiet });
252+
}
250253

251254
// Then load global fallback: ~/.openclaw/.env (or OPENCLAW_STATE_DIR/.env),
252255
// without overriding any env vars already present.

src/infra/home-dir.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// Tests OpenClaw home directory resolution.
22
import path from "node:path";
3-
import { describe, expect, it } from "vitest";
3+
import { describe, expect, it, vi } from "vitest";
44
import {
55
expandHomePrefix,
66
resolveEffectiveHomeDir,
77
resolveHomeRelativePath,
88
resolveOsHomeDir,
99
resolveOsHomeRelativePath,
1010
resolveRequiredHomeDir,
11+
resolveRequiredOsHomeDir,
1112
} from "./home-dir.js";
1213

1314
describe("resolveEffectiveHomeDir", () => {
@@ -166,6 +167,22 @@ describe("resolveRequiredHomeDir", () => {
166167
])("$name", ({ env, homedir, expected }) => {
167168
expect(resolveRequiredHomeDir(env, homedir)).toBe(expected);
168169
});
170+
171+
it("fails clearly when both home and cwd are unavailable", () => {
172+
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
173+
throw new Error("ENOENT: uv_cwd");
174+
});
175+
const noHome = () => {
176+
throw new Error("no home");
177+
};
178+
179+
try {
180+
expect(() => resolveRequiredHomeDir({}, noHome)).toThrow(/set OPENCLAW_HOME/i);
181+
expect(() => resolveRequiredOsHomeDir({}, noHome)).toThrow(/set HOME/i);
182+
} finally {
183+
cwdSpy.mockRestore();
184+
}
185+
});
169186
});
170187

171188
describe("resolveOsHomeDir", () => {

src/infra/home-dir.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Resolves OpenClaw home and platform-specific config directories.
22
import os from "node:os";
33
import path from "node:path";
4+
import { tryProcessCwd } from "./safe-cwd.js";
45

56
function normalize(value: string | undefined): string | undefined {
67
const trimmed = value?.trim();
@@ -75,15 +76,27 @@ export function resolveRequiredHomeDir(
7576
env: NodeJS.ProcessEnv = process.env,
7677
homedir: () => string = os.homedir,
7778
): string {
78-
return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd());
79+
const resolved = resolveEffectiveHomeDir(env, homedir) ?? tryProcessCwd();
80+
if (resolved) {
81+
return path.resolve(resolved);
82+
}
83+
throw new Error(
84+
"Unable to resolve an OpenClaw home: set OPENCLAW_HOME, HOME, or USERPROFILE, or run from an existing directory.",
85+
);
7986
}
8087

8188
/** Resolves the OS home or falls back to cwd when no OS home source exists. */
8289
export function resolveRequiredOsHomeDir(
8390
env: NodeJS.ProcessEnv = process.env,
8491
homedir: () => string = os.homedir,
8592
): string {
86-
return resolveOsHomeDir(env, homedir) ?? path.resolve(process.cwd());
93+
const resolved = resolveOsHomeDir(env, homedir) ?? tryProcessCwd();
94+
if (resolved) {
95+
return path.resolve(resolved);
96+
}
97+
throw new Error(
98+
"Unable to resolve an OS home: set HOME or USERPROFILE, or run from an existing directory.",
99+
);
87100
}
88101

89102
/** Expands leading `~`, `~/`, or `~\` with the effective home when one is known. */

src/infra/path-env.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,26 @@ describe("ensureOpenClawCliOnPath", () => {
232232
},
233233
);
234234

235+
it("skips project-local bins when the working directory was deleted", () => {
236+
const { tmp, appCli } = setupAppCliRoot("case-deleted-cwd");
237+
const localBinDir = path.join(tmp, "node_modules", ".bin");
238+
setDir(localBinDir);
239+
setExe(path.join(localBinDir, "openclaw"));
240+
resetBootstrapEnv();
241+
process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN = "1";
242+
const cwdSpy = vi.spyOn(process, "cwd").mockImplementation(() => {
243+
throw new Error("ENOENT: uv_cwd");
244+
});
245+
246+
try {
247+
ensureOpenClawCliOnPath({ execPath: appCli, homeDir: tmp, platform: "darwin" });
248+
} finally {
249+
cwdSpy.mockRestore();
250+
}
251+
252+
expect((process.env.PATH ?? "").split(path.delimiter)).not.toContain(localBinDir);
253+
});
254+
235255
it("prepends XDG_BIN_HOME ahead of other user bin fallbacks", () => {
236256
const { tmp, appCli } = setupAppCliRoot("case-xdg-bin-home");
237257
const xdgBinHome = path.join(tmp, "xdg-bin");

src/infra/path-env.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "@openclaw/normalization-core/string-normalization";
99
import { resolveBrewPathDirs } from "./brew.js";
1010
import { isTruthyEnvValue } from "./env.js";
11+
import { tryProcessCwd } from "./safe-cwd.js";
1112

1213
type EnsureOpenClawPathOpts = {
1314
/** Executable whose directory should stay first for shebang-compatible child processes. */
@@ -80,7 +81,7 @@ function candidateBinDirs(
8081
existingPathParts: ReadonlySet<string>,
8182
): { prepend: string[]; append: string[] } {
8283
const execPath = opts.execPath ?? process.execPath;
83-
const cwd = opts.cwd ?? process.cwd();
84+
const cwd = opts.cwd ?? tryProcessCwd();
8485
const homeDir = opts.homeDir ?? os.homedir();
8586
const platform = opts.platform ?? process.platform;
8687

@@ -114,7 +115,7 @@ function candidateBinDirs(
114115
const allowProjectLocalBin =
115116
opts.allowProjectLocalBin === true ||
116117
isTruthyEnvValue(process.env.OPENCLAW_ALLOW_PROJECT_LOCAL_BIN);
117-
if (allowProjectLocalBin) {
118+
if (allowProjectLocalBin && cwd) {
118119
const localBinDir = path.join(cwd, "node_modules", ".bin");
119120
if (isExecutable(path.join(localBinDir, "openclaw"))) {
120121
append.push(localBinDir);

src/infra/safe-cwd.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// A removed launch directory makes Node's process.cwd() throw before callers can recover.
2+
// Keep the absence explicit so each trust boundary chooses whether to skip, fail, or fall back.
3+
export function tryProcessCwd(): string | undefined {
4+
try {
5+
return process.cwd();
6+
} catch {
7+
return undefined;
8+
}
9+
}

0 commit comments

Comments
 (0)