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:
Yuval Dinodia
2026-08-21 02:48:11 -04:00
committed by GitHub
parent 73ff2d2f45
commit 8ec5ae0693
5 changed files with 34 additions and 24 deletions
+2 -2
View File
@@ -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) => [
+14 -7
View File
@@ -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"));
},
);
});
+2 -11
View File
@@ -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);
}
+4 -4
View File
@@ -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 };
+12
View File
@@ -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("report2026.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");