mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(agents): preserve literal Unicode-space paths (#126797)
Preserve model-supplied filename identity for mutations while keeping existence-checked Unicode-equivalent fallback for reads. Co-authored-by: yetval <yetvald@gmail.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -50,7 +50,7 @@ import {
|
||||
type ReadToolDetails,
|
||||
type ReadToolTruncationDetails,
|
||||
} from "./sessions/tools/index.js";
|
||||
import { expandOsHomePrefix, resolveReadPath } from "./sessions/tools/path-utils.js";
|
||||
import { expandOsHomePrefix, resolveToCwd } from "./sessions/tools/path-utils.js";
|
||||
import { createBoundedReadTextPage, formatReadContinuationNotice } from "./sessions/tools/read.js";
|
||||
import {
|
||||
ReadToolContinuationSchema,
|
||||
@@ -1080,7 +1080,7 @@ export function wrapReadToolWithSkillContent(
|
||||
root: cwd,
|
||||
containerWorkdir: options?.containerWorkdir,
|
||||
});
|
||||
return resolveReadPath(mapped, cwd);
|
||||
return resolveToCwd(mapped, cwd);
|
||||
};
|
||||
const instructionContent = new Map<string, string | undefined>(
|
||||
(options?.instructionPaths ?? []).map((filePath) => [
|
||||
|
||||
@@ -2,37 +2,44 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReadPath } from "./path-utils.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
||||
describe("resolveReadPath", () => {
|
||||
describe("resolveToCwd", () => {
|
||||
const cwd = path.resolve("workspace");
|
||||
|
||||
it("resolves ordinary relative paths against cwd", () => {
|
||||
expect(resolveReadPath("notes/today.md", cwd)).toBe(path.resolve(cwd, "notes/today.md"));
|
||||
expect(resolveToCwd("notes/today.md", cwd)).toBe(path.resolve(cwd, "notes/today.md"));
|
||||
});
|
||||
|
||||
it("keeps Unicode spaces in the destination path", () => {
|
||||
const nnbsp = "Screenshot 9.30\u202FAM.png";
|
||||
const ascii = "Screenshot 9.30 AM.png";
|
||||
expect(resolveToCwd(nnbsp, cwd)).toBe(path.resolve(cwd, nnbsp));
|
||||
expect(resolveToCwd(nnbsp, cwd)).not.toBe(path.resolve(cwd, ascii));
|
||||
});
|
||||
|
||||
it("resolves valid file URLs to their filesystem path", () => {
|
||||
const target = path.resolve(cwd, "notes.txt");
|
||||
expect(resolveReadPath(pathToFileURL(target).href, cwd)).toBe(target);
|
||||
expect(resolveToCwd(pathToFileURL(target).href, cwd)).toBe(target);
|
||||
});
|
||||
|
||||
it("keeps malformed file URLs on the ordinary relative-path path", () => {
|
||||
const malformed = "file://%";
|
||||
expect(resolveReadPath(malformed, cwd)).toBe(path.resolve(cwd, malformed));
|
||||
expect(resolveToCwd(malformed, cwd)).toBe(path.resolve(cwd, malformed));
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "win32")(
|
||||
"expands a Windows-style home prefix against the OS home",
|
||||
() => {
|
||||
const homeDir = process.env.HOME ?? os.homedir();
|
||||
expect(resolveReadPath("~\\notes.txt", cwd)).toBe(path.resolve(homeDir, "notes.txt"));
|
||||
expect(resolveToCwd("~\\notes.txt", cwd)).toBe(path.resolve(homeDir, "notes.txt"));
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"keeps a backslash-prefixed tilde literal on POSIX",
|
||||
() => {
|
||||
expect(resolveReadPath("~\\notes.txt", cwd)).toBe(path.resolve(cwd, "~\\notes.txt"));
|
||||
expect(resolveToCwd("~\\notes.txt", cwd)).toBe(path.resolve(cwd, "~\\notes.txt"));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,9 +34,8 @@ export function expandOsHomePrefix(filePath: string): string {
|
||||
return home ? expandHomePrefix(filePath, { home }) : filePath;
|
||||
}
|
||||
|
||||
function expandPath(filePath: string, normalizeSpaces = true): string {
|
||||
const withoutAtPrefix = normalizeAtPrefix(filePath);
|
||||
const normalized = normalizeSpaces ? normalizeUnicodeSpaces(withoutAtPrefix) : withoutAtPrefix;
|
||||
function expandPath(filePath: string): string {
|
||||
const normalized = normalizeAtPrefix(filePath);
|
||||
if (normalized.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(normalized);
|
||||
@@ -53,14 +52,6 @@ function expandPath(filePath: string, normalizeSpaces = true): string {
|
||||
*/
|
||||
export function resolveToCwd(filePath: string, cwd: string): string {
|
||||
const expanded = expandPath(filePath);
|
||||
if (isAbsolute(expanded)) {
|
||||
return expanded;
|
||||
}
|
||||
return resolvePath(cwd, expanded);
|
||||
}
|
||||
|
||||
export function resolveReadPath(filePath: string, cwd: string): string {
|
||||
const expanded = expandPath(filePath, false);
|
||||
return isAbsolute(expanded) ? expanded : resolvePath(cwd, expanded);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { detectSupportedImageMimeType } from "../../utils/mime.js";
|
||||
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.js";
|
||||
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
|
||||
import { normalizePositiveLimit } from "./limits.js";
|
||||
import { getReadPathVariants, resolveReadPath } from "./path-utils.js";
|
||||
import { getReadPathVariants, resolveToCwd } from "./path-utils.js";
|
||||
import {
|
||||
createReadToolDetails,
|
||||
readToolInputSchema,
|
||||
@@ -216,7 +216,7 @@ function getCompactReadClassification(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const absolutePath = resolveReadPath(rawPath, cwd);
|
||||
const absolutePath = resolveToCwd(rawPath, cwd);
|
||||
const fileName = basename(absolutePath);
|
||||
if (fileName === "SKILL.md") {
|
||||
return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
|
||||
@@ -239,7 +239,7 @@ async function resolveLocalReadPath(filePath: string, cwd: string): Promise<stri
|
||||
if (classifyMediaReferenceSource(normalizedMediaSource).isMediaStoreUrl) {
|
||||
return await resolveMediaReferenceLocalPath(normalizedMediaSource);
|
||||
}
|
||||
return resolveReadPath(filePath, cwd);
|
||||
return resolveToCwd(filePath, cwd);
|
||||
}
|
||||
|
||||
async function resolveReadToolPath(
|
||||
@@ -247,7 +247,7 @@ async function resolveReadToolPath(
|
||||
filePath: string,
|
||||
cwd: string,
|
||||
): Promise<{ absolutePath: string; note?: string }> {
|
||||
const absolutePath = await (ops.resolvePath?.(filePath, cwd) ?? resolveReadPath(filePath, cwd));
|
||||
const absolutePath = await (ops.resolvePath?.(filePath, cwd) ?? resolveToCwd(filePath, cwd));
|
||||
try {
|
||||
await ops.access(absolutePath);
|
||||
return { absolutePath };
|
||||
|
||||
@@ -178,6 +178,18 @@ describe("write tool", () => {
|
||||
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("finished\n");
|
||||
});
|
||||
|
||||
it("writes the literal Unicode-space path instead of an ASCII-space sibling", async () => {
|
||||
const nnbspPath = await createTempPath("report 2026.md");
|
||||
const asciiPath = path.join(tmpDir, "report 2026.md");
|
||||
await fs.writeFile(asciiPath, "ascii\n", "utf-8");
|
||||
const tool = createWriteTool(tmpDir);
|
||||
|
||||
await tool.execute("call-1", { path: nnbspPath, content: "nnbsp\n" }, undefined);
|
||||
|
||||
await expect(fs.readFile(nnbspPath, "utf-8")).resolves.toBe("nnbsp\n");
|
||||
await expect(fs.readFile(asciiPath, "utf-8")).resolves.toBe("ascii\n");
|
||||
});
|
||||
|
||||
it("returns terminal no-op when writing identical content to existing file", async () => {
|
||||
const filePath = await createTempPath("identical.txt");
|
||||
await fs.writeFile(filePath, "hello\n", "utf-8");
|
||||
|
||||
Reference in New Issue
Block a user