From e93bc057331f954388e79cdca8c2b93156b29558 Mon Sep 17 00:00:00 2001 From: boeingchoco Date: Thu, 1 Oct 2026 21:27:53 +0900 Subject: [PATCH] fix(cli): tool summaries show wrong paths when HOME=/ or the home is a path prefix (#162483) Fixes #162438. `shortenHomeInString` now replaces the home directory only at a path boundary. With HOME=/home/al, `/home/alice/...` is left alone; with HOME=/, slashes are no longer turned into `~`. `/home/al/x` still becomes `~/x` and the exact home path still becomes `~`. Proof: real CLI in secretless Docker (`openclaw reset --scope config --dry-run`). main printed `~ice/p162438.json` and `~p162438~root.json`; this PR prints the correct paths. Regression test fails on main and passes here. Co-authored-by: Ayaan Zaidi --- src/utils.test.ts | 23 +++++++++++++++++++++++ src/utils.ts | 28 ++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/utils.test.ts b/src/utils.test.ts index 93ac767fc8c7..785616e3df71 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -227,6 +227,29 @@ describe("shortenHomeInString", () => { }, ); + it.skipIf(process.platform === "win32")("replaces only whole home path components", () => { + withEnv({ OPENCLAW_HOME: undefined, HOME: "/home/al" }, () => { + expect(shortenHomeInString("/home/al")).toBe("~"); + expect(shortenHomeInString("/home/al/x")).toBe("~/x"); + expect( + shortenHomeInString("open /home/al/notes, /home/al, /home/alice/x and /mnt/home/al/x"), + ).toBe("open ~/notes, ~, /home/alice/x and /mnt/home/al/x"); + expect(shortenHomeInString("Stored in /home/al. Backup: /home/al.bak/x")).toBe( + "Stored in ~. Backup: /home/al.bak/x", + ); + const siblingPaths = + "/home/al+old, /home/al./notes, /home/al,old/x, /home/al:old/x, /home/al\\ice/x, /mnt+/home/al/x"; + expect(shortenHomeInString(siblingPaths)).toBe(siblingPaths); + expect( + shortenHomeInString('PATH=/home/al:/usr/bin:/home/al/bin; dirs=/tmp;/home/al ("/home/al")'), + ).toBe('PATH=~:/usr/bin:~/bin; dirs=/tmp;~ ("~")'); + expect(shortenHomeInString("at file:///home/al/app.js:5:3")).toBe("at file://~/app.js:5:3"); + }); + withEnv({ OPENCLAW_HOME: undefined, HOME: "/" }, () => { + expect(shortenHomeInString("see /var/log/openclaw.log")).toBe("see /var/log/openclaw.log"); + }); + }); + it.skipIf(process.platform !== "win32")( "shortens real Windows home casing aliases inside diagnostic text", async () => { diff --git a/src/utils.ts b/src/utils.ts index 2fe196ee9ad5..25fe150b3f70 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import { normalizeHomeDirValue } from "@openclaw/normalization-core/home-dir"; import { resolveConfigDir } from "./infra/config-dir.js"; import { resolveEffectiveHomeDir, resolveUserPath } from "./infra/home-dir.js"; @@ -60,6 +61,13 @@ export function resolveHomeDir(): string | undefined { return resolveEffectiveHomeDir(process.env, os.homedir); } +// Stack traces print ESM paths as file:// URLs, so the URL scheme also starts a path. +const HOME_TEXT_START = String.raw`(?<=^|[\s"'\x60(\[{<=:;]|file://)`; +const HOME_TEXT_DELIMITER = String.raw`[\s"'\x60)\]}>]`; + +// A PATH-style list continues with another absolute path, home prefix, or drive letter. +const HOME_TEXT_LIST_NEXT = String.raw`[:;](?=[/\\~$]|[A-Za-z]:)`; + function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefined { const home = resolveHomeDir(); if (!home) { @@ -81,19 +89,27 @@ export function shortenHomePath(input: string): string { return shortenPathWithHome(input, display); } -/** Replaces all effective-home occurrences inside a diagnostic string. */ +/** Replaces effective-home path occurrences inside a diagnostic string. */ export function shortenHomeInString(input: string): string { if (!input) { return input; } const display = resolveHomeDisplayPrefix(); - if (!display) { + // A filesystem-root home such as "/" would turn every path separator into the prefix. + if (!display || path.parse(display.home).root === display.home) { return input; } - if (process.platform === "win32") { - return input.replace(new RegExp(escapeRegExpValue(display.home), "giu"), display.prefix); - } - return input.split(display.home).join(display.prefix); + // Diagnostics delimit paths with whitespace, quotes, brackets, `=`, and PATH list separators. + // Replace the home only between those delimiters so /home/al+old, /mnt/home/al, and + // /home/al.bak stay exact. Trailing `.`, `,`, `;`, or `:` ends the home only when a + // delimiter, the end of the text, or the next PATH entry follows it. + // POSIX file names may contain backslashes, so only Windows treats them as separators. + const pathSeparator = process.platform === "win32" ? String.raw`[\\/]` : "/"; + const homePattern = new RegExp( + `${HOME_TEXT_START}${escapeRegExpValue(display.home)}(?=$|${pathSeparator}|${HOME_TEXT_DELIMITER}|[.,;:](?:$|${HOME_TEXT_DELIMITER})|${HOME_TEXT_LIST_NEXT})`, + process.platform === "win32" ? "giu" : "gu", + ); + return input.replace(homePattern, display.prefix); } /** Shortens a path for display without changing non-home paths. */