Skip to content

Commit 5a21c8c

Browse files
committed
fix(agents): expand leading tilde in exec workdir
Add `expandTilde()` helper to handle `~` and `~/` prefixes in the `workdir` parameter of exec tool calls. The function expands leading tildes to the user's home directory before validating the path. Fixes issue #94434 where models emitting `workdir: "~/path"` would fail because the tilde was not expanded, causing the path validation to fail and fall back to a different directory while still executing the command. Changes: - Add `expandTilde()` function that handles `~`, `~/path`, and leaves other paths unchanged (absolute, relative, `~user` unsupported) - Modify `resolveWorkdir()` to call `expandTilde()` before validation - Add comprehensive unit tests for both functions covering all edge cases The fix is minimal and focused: only paths starting with `~` or `~/` are affected; all other path types continue to work as before. Fixes #94434
1 parent 4106794 commit 5a21c8c

2 files changed

Lines changed: 147 additions & 4 deletions

File tree

src/agents/bash-tools.shared.test.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import { existsSync, statSync as statSyncCb } from "node:fs";
12
/**
23
* Shared bash-tool helper tests.
34
* Covers strict env parsing and sandbox workdir mapping between container and
45
* host workspace paths.
56
*/
6-
import { mkdir, mkdtemp, rm } from "node:fs/promises";
7+
import { mkdir, mkdtemp, rm, statSync } from "node:fs/promises";
78
import os from "node:os";
89
import path from "node:path";
910
import { afterEach, describe, expect, it, vi } from "vitest";
10-
import { deriveSessionName, readEnvInt, resolveSandboxWorkdir } from "./bash-tools.shared.js";
11+
import {
12+
deriveSessionName,
13+
expandTilde,
14+
readEnvInt,
15+
resolveSandboxWorkdir,
16+
resolveWorkdir,
17+
} from "./bash-tools.shared.js";
1118

1219
async function withTempDir(run: (dir: string) => Promise<void>) {
1320
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-bash-workdir-"));
@@ -144,3 +151,121 @@ describe("deriveSessionName", () => {
144151
}
145152
});
146153
});
154+
155+
describe("expandTilde", () => {
156+
const homeDir = os.homedir();
157+
158+
it("expands ~ to home directory", () => {
159+
expect(expandTilde("~")).toBe(homeDir);
160+
});
161+
162+
it("expands ~/path to homeDir/path", () => {
163+
expect(expandTilde("~/test/path")).toBe(`${homeDir}/test/path`);
164+
expect(expandTilde("~/Documents/file.txt")).toBe(`${homeDir}/Documents/file.txt`);
165+
});
166+
167+
it("leaves absolute paths unchanged", () => {
168+
expect(expandTilde("/usr/local/bin")).toBe("/usr/local/bin");
169+
expect(expandTilde("/home/user/test")).toBe("/home/user/test");
170+
});
171+
172+
it("leaves relative paths unchanged", () => {
173+
expect(expandTilde("src/index.ts")).toBe("src/index.ts");
174+
expect(expandTilde("./local/path")).toBe("./local/path");
175+
expect(expandTilde("../parent/path")).toBe("../parent/path");
176+
});
177+
178+
it("handles empty string", () => {
179+
expect(expandTilde("")).toBe("");
180+
});
181+
182+
it("does not expand ~user syntax (not supported)", () => {
183+
expect(expandTilde("~otheruser")).toBe("~otheruser");
184+
expect(expandTilde("~otheruser/path")).toBe("~otheruser/path");
185+
});
186+
187+
it("handles tilde in the middle or end of path", () => {
188+
// These should not be expanded as they're not leading tildes
189+
expect(expandTilde("/path/to/~cache")).toBe("/path/to/~cache");
190+
expect(expandTilde("file~backup.txt")).toBe("file~backup.txt");
191+
});
192+
});
193+
194+
describe("resolveWorkdir", () => {
195+
afterEach(() => {
196+
vi.unstubAllEnvs();
197+
});
198+
199+
it("returns existing absolute path if valid directory", () => {
200+
const tmpDir = path.join(os.tmpdir(), `openclaw-test-${Date.now()}`);
201+
try {
202+
require("node:fs").mkdirSync(tmpDir, { recursive: true });
203+
const warnings: string[] = [];
204+
const result = resolveWorkdir(tmpDir, warnings);
205+
expect(result).toBe(tmpDir);
206+
expect(warnings).toEqual([]);
207+
} finally {
208+
require("node:fs").rmSync(tmpDir, { recursive: true, force: true });
209+
}
210+
});
211+
212+
it("falls back for invalid relative path", async () => {
213+
const tmpDir = path.join(os.tmpdir(), `openclaw-test-${Date.now()}`);
214+
try {
215+
await mkdir(tmpDir, { recursive: true });
216+
const warnings: string[] = [];
217+
// Test with a relative path that doesn't exist from any reasonable cwd
218+
const result = resolveWorkdir("nonexistent_subdir_12345", warnings);
219+
// Should fall back to cwd or homedir since the path doesn't exist
220+
expect([process.cwd(), os.homedir()].includes(result)).toBe(true);
221+
expect(warnings.length).toBe(1);
222+
expect(warnings[0]).toMatch(/Warning: workdir "nonexistent_subdir_\d+" is unavailable/);
223+
} finally {
224+
await rm(tmpDir, { recursive: true, force: true });
225+
}
226+
});
227+
228+
it("expands ~ to home directory and returns it if valid", () => {
229+
const warnings: string[] = [];
230+
const result = resolveWorkdir("~", warnings);
231+
expect(result).toBe(os.homedir());
232+
expect(warnings).toEqual([]);
233+
});
234+
235+
it("expands ~/path and returns it if valid directory", async () => {
236+
const testSubdir = `openclaw-test-${Date.now()}`;
237+
const testPath = path.join(os.homedir(), testSubdir);
238+
try {
239+
await mkdir(testPath, { recursive: true });
240+
const warnings: string[] = [];
241+
const result = resolveWorkdir(`~/${testSubdir}`, warnings);
242+
expect(result).toBe(testPath);
243+
expect(warnings).toEqual([]);
244+
} finally {
245+
await rm(testPath, { recursive: true, force: true });
246+
}
247+
});
248+
249+
it("falls back with warning when expanded ~ path does not exist", () => {
250+
const warnings: string[] = [];
251+
const result = resolveWorkdir("~/nonexistent_dir_12345", warnings);
252+
// Should fall back to cwd or homedir
253+
expect(result).toBe(process.cwd() ?? os.homedir());
254+
expect(warnings.length).toBe(1);
255+
expect(warnings[0]).toMatch(/Warning: workdir "~\/nonexistent_dir_\d+" is unavailable/);
256+
});
257+
258+
it("falls back with warning for invalid absolute path", () => {
259+
const warnings: string[] = [];
260+
const result = resolveWorkdir("/nonexistent/path/12345", warnings);
261+
expect(result).toBe(process.cwd() ?? os.homedir());
262+
expect(warnings.length).toBe(1);
263+
});
264+
265+
it("falls back with warning for invalid relative path", () => {
266+
const warnings: string[] = [];
267+
const result = resolveWorkdir("nonexistent/relative/path", warnings);
268+
expect(result).toBe(process.cwd() ?? os.homedir());
269+
expect(warnings.length).toBe(1);
270+
});
271+
});

src/agents/bash-tools.shared.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,32 @@ function normalizeContainerPath(input: string): string {
179179
return path.posix.normalize(normalized);
180180
}
181181

182+
/** Expands a leading ~ or ~/ to the user's home directory. */
183+
export function expandTilde(input: string): string {
184+
if (!input) {
185+
return input;
186+
}
187+
if (input === "~") {
188+
return homedir();
189+
}
190+
if (input.startsWith("~/")) {
191+
return homedir() + input.slice(1);
192+
}
193+
return input;
194+
}
195+
182196
/** Resolves a host workdir, falling back to a safe cwd/home path with a warning. */
183197
export function resolveWorkdir(workdir: string, warnings: string[]) {
184198
const current = safeCwd();
185199
const fallback = current ?? homedir();
200+
201+
// Expand leading tilde before validating the path
202+
const expandedWorkdir = expandTilde(workdir);
203+
186204
try {
187-
const stats = statSync(workdir);
205+
const stats = statSync(expandedWorkdir);
188206
if (stats.isDirectory()) {
189-
return workdir;
207+
return expandedWorkdir;
190208
}
191209
} catch {
192210
// ignore, fallback below

0 commit comments

Comments
 (0)