From 645d963954938df8a8bf4a40d9f80ce894cddfbf Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Sun, 1 Mar 2026 09:00:52 -0400 Subject: [PATCH] feat: expand ~ (tilde) to home directory in file tools (read/write/edit) (openclaw#29779) thanks @Glucksberg Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/infra/fs-safe.test.ts | 64 +++++++++++++++++++++++++++++++++++++++ src/infra/fs-safe.ts | 18 +++++++++-- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6346e61..64ff7151e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks . - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks . - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks . +- File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg. - Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007. - Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70. - Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz. diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index c8302e5e0..03a2888aa 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -173,3 +173,67 @@ describe("fs-safe", () => { }); }); }); + +describe("tilde expansion in file tools", () => { + it("expandHomePrefix respects process.env.HOME changes", async () => { + const { expandHomePrefix } = await import("./home-dir.js"); + const originalHome = process.env.HOME; + const fakeHome = "/tmp/fake-home-test"; + process.env.HOME = fakeHome; + try { + const result = expandHomePrefix("~/file.txt"); + expect(result).toBe(`${fakeHome}/file.txt`); + } finally { + process.env.HOME = originalHome; + } + }); + + it("reads a file via ~/path after HOME override", async () => { + const root = await tempDirs.make("openclaw-tilde-test-"); + const originalHome = process.env.HOME; + process.env.HOME = root; + try { + await fs.writeFile(path.join(root, "hello.txt"), "tilde-works"); + const result = await openFileWithinRoot({ + rootDir: root, + relativePath: "~/hello.txt", + }); + const buf = Buffer.alloc(result.stat.size); + await result.handle.read(buf, 0, buf.length, 0); + await result.handle.close(); + expect(buf.toString("utf8")).toBe("tilde-works"); + } finally { + process.env.HOME = originalHome; + } + }); + + it("writes a file via ~/path after HOME override", async () => { + const root = await tempDirs.make("openclaw-tilde-test-"); + const originalHome = process.env.HOME; + process.env.HOME = root; + try { + await writeFileWithinRoot({ + rootDir: root, + relativePath: "~/output.txt", + data: "tilde-write-works", + }); + const content = await fs.readFile(path.join(root, "output.txt"), "utf8"); + expect(content).toBe("tilde-write-works"); + } finally { + process.env.HOME = originalHome; + } + }); + + it("rejects ~/path that resolves outside root", async () => { + const root = await tempDirs.make("openclaw-tilde-outside-"); + // HOME points to real home, ~/file goes to /home/dev/file which is outside root + await expect( + openFileWithinRoot({ + rootDir: root, + relativePath: "~/escape.txt", + }), + ).rejects.toMatchObject({ + code: expect.stringMatching(/outside-workspace|not-found|invalid-path/), + }); + }); +}); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index e986980f8..f7d1f97d6 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -2,8 +2,10 @@ import type { Stats } from "node:fs"; import { constants as fsConstants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { sameFileIdentity } from "./file-identity.js"; +import { expandHomePrefix } from "./home-dir.js"; import { assertNoPathAliasEscape } from "./path-alias-guards.js"; import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; @@ -48,6 +50,16 @@ const OPEN_WRITE_FLAGS = const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); +async function expandRelativePathWithHome(relativePath: string): Promise { + let home = process.env.HOME || process.env.USERPROFILE || os.homedir(); + try { + home = await fs.realpath(home); + } catch { + // If the home dir cannot be canonicalized, keep lexical expansion behavior. + } + return expandHomePrefix(relativePath, { home }); +} + async function openVerifiedLocalFile( filePath: string, options?: { @@ -119,7 +131,8 @@ export async function openFileWithinRoot(params: { throw err; } const rootWithSep = ensureTrailingSep(rootReal); - const resolved = path.resolve(rootWithSep, params.relativePath); + const expanded = await expandRelativePathWithHome(params.relativePath); + const resolved = path.resolve(rootWithSep, expanded); if (!isPathInside(rootWithSep, resolved)) { throw new SafeOpenError("outside-workspace", "file is outside workspace root"); } @@ -188,7 +201,8 @@ export async function writeFileWithinRoot(params: { throw err; } const rootWithSep = ensureTrailingSep(rootReal); - const resolved = path.resolve(rootWithSep, params.relativePath); + const expanded = await expandRelativePathWithHome(params.relativePath); + const resolved = path.resolve(rootWithSep, expanded); if (!isPathInside(rootWithSep, resolved)) { throw new SafeOpenError("outside-workspace", "file is outside workspace root"); }