fix(agent-tools): expand Windows home paths (#122071)

This commit is contained in:
Peter Steinberger
2026-08-11 08:28:35 -07:00
committed by GitHub
parent db73b59c04
commit f798f9999b
9 changed files with 138 additions and 18 deletions
+1 -1
View File
@@ -1966,7 +1966,7 @@
"test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts",
"test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts",
"test:watch": "node --import tsx scripts/test-projects.mts --watch",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
+4
View File
@@ -75,6 +75,8 @@ const WINDOWS_HOME_DISPLAY_SCOPE_RE =
/^(?:src\/(?:utils(?:\.test)?|infra\/(?:home-display|path-guards)|commands\/agents\.commands\.list(?:\.test)?|cli\/daemon-cli\/status\.print(?:\.test)?|agents\/(?:sandbox\/fs-paths|sessions\/tools\/render-utils)(?:\.test)?)|packages\/terminal-core\/src\/display-string(?:\.test)?)\.ts$/;
const WINDOWS_CHILD_ENV_SCOPE_RE =
/^src\/(?:agents\/provider-local-service(?:\.env-case\.test)?|cli\/mcp-cli(?:\.path-case\.windows)?\.test|cli\/mcp-cli|infra\/process-env(?:\.test)?)\.ts$/;
const WINDOWS_AGENT_HOME_PATH_SCOPE_RE =
/^src\/(?:infra\/home-dir(?:\.test)?|agents\/(?:agent-tools\.read(?:\.host-operations|\.windows)?\.test|agent-tools\.read|sessions\/tools\/path-utils(?:\.test)?))\.ts$/;
const WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE =
/^(?:packages\/memory-host-sdk\/src\/host\/(?:(?:internal|read-file)(?:\.test)?|explicit-extra-markdown)|extensions\/memory-core\/src\/(?:cli-runtime-common|memory-extra-file-path\.windows\.test))\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
@@ -188,6 +190,7 @@ export function detectChangedScope(changedPaths) {
WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) ||
WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) ||
WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) ||
WINDOWS_AGENT_HOME_PATH_SCOPE_RE.test(path) ||
WINDOWS_CHILD_ENV_SCOPE_RE.test(path) ||
WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE.test(path)) &&
(!facts.isTestOnly ||
@@ -198,6 +201,7 @@ export function detectChangedScope(changedPaths) {
WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) ||
WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) ||
WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) ||
WINDOWS_AGENT_HOME_PATH_SCOPE_RE.test(path) ||
WINDOWS_CHILD_ENV_SCOPE_RE.test(path) ||
WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE.test(path))
) {
@@ -145,6 +145,24 @@ describe("host tool tilde expansion (non-workspace mode)", () => {
expect((await fs.stat(newDir)).isDirectory()).toBe(true);
});
it.runIf(process.platform === "win32")(
"keeps host write and edit operations on the same Windows-style home path",
async () => {
const dir = tempDirs.make("openclaw-tilde-test-win32-", osHome());
const testFile = path.join(dir, "same-path.txt");
const modelPath = toTildePath(testFile);
createHostWorkspaceWriteTool(dir, { workspaceOnly: false });
await readWriteOps().writeFile(modelPath, "before");
createHostWorkspaceEditTool(dir, { workspaceOnly: false });
expect((await readEditOps().readFile(modelPath)).toString("utf8")).toBe("before");
await readEditOps().writeFile(modelPath, "after");
expect(await fs.readFile(testFile, "utf8")).toBe("after");
},
);
it("ignores OPENCLAW_HOME for write operations", async () => {
const openclawHome = tempDirs.make("openclaw-home-override-", os.tmpdir());
const dir = tempDirs.make("openclaw-tilde-test-write-", osHome());
+4 -9
View File
@@ -14,7 +14,6 @@ import {
root as fsRoot,
FsSafeError,
} from "../infra/fs-safe.js";
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js";
import { decodeWindowsTextFileBuffer } from "../infra/windows-encoding.js";
import {
@@ -49,6 +48,7 @@ import {
type ReadToolDetails,
type ReadToolTruncationDetails,
} from "./sessions/tools/index.js";
import { expandOsHomePrefix } from "./sessions/tools/path-utils.js";
import { sanitizeToolResultImages } from "./tool-images.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
@@ -1094,13 +1094,8 @@ async function assertSandboxFileExists(params: SandboxToolParams, absolutePath:
}
}
function expandTildeToOsHome(filePath: string): string {
const home = resolveOsHomeDir();
return home ? expandHomePrefix(filePath, { home }) : filePath;
}
function resolveHostPath(filePath: string): string {
return path.resolve(expandTildeToOsHome(filePath));
return path.resolve(expandOsHomePrefix(filePath));
}
async function writeHostFile(absolutePath: string, content: string) {
@@ -1170,9 +1165,9 @@ function createHostWriteOperations(
},
writeFile: writeHostFile,
readFile: async (absolutePath: string) =>
fs.readFile(path.resolve(expandTildeToOsHome(absolutePath))),
fs.readFile(path.resolve(expandOsHomePrefix(absolutePath))),
statFile: (absolutePath: string) =>
statHostFile(path.resolve(expandTildeToOsHome(absolutePath))),
statHostFile(path.resolve(expandOsHomePrefix(absolutePath))),
} as const,
options?.memoryWriteProvenance,
);
@@ -0,0 +1,33 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import "./test-helpers/fast-openclaw-tools.js";
import { createOpenClawCodingTools } from "./agent-tools.js";
import { expectReadWriteEditTools } from "./test-helpers/agent-tools-fs-helpers.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("registered core read OS-home paths", () => {
it.runIf(process.platform === "win32")("reads a Windows-style home path", async () => {
const homeDir = process.env.HOME ?? os.homedir();
const homeTestDir = tempDirs.make("openclaw-core-read-home-", homeDir);
const workspaceDir = tempDirs.make("openclaw-core-read-workspace-");
const targetPath = path.join(homeTestDir, "same-path.txt");
const modelPath = `~\\${path.relative(homeDir, targetPath)}`;
await fs.writeFile(targetPath, "home read", "utf8");
const tools = createOpenClawCodingTools({ workspaceDir });
const { readTool } = expectReadWriteEditTools(tools);
const result = await readTool?.execute("tool-home-read", { path: modelPath });
const text = result?.content
?.filter((block) => block.type === "text")
.map((block) => block.text ?? "")
.join("\n");
expect(text).toContain("home read");
});
});
@@ -0,0 +1,38 @@
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";
describe("resolveReadPath", () => {
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"));
});
it("resolves valid file URLs to their filesystem path", () => {
const target = path.resolve(cwd, "notes.txt");
expect(resolveReadPath(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));
});
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"));
},
);
it.runIf(process.platform !== "win32")(
"keeps a backslash-prefixed tilde literal on POSIX",
() => {
expect(resolveReadPath("~\\notes.txt", cwd)).toBe(path.resolve(cwd, "~\\notes.txt"));
},
);
});
+15 -8
View File
@@ -3,9 +3,9 @@
*
* Expands user/file URL inputs and resolves read/write paths against the active cwd with macOS filename variants.
*/
import * as os from "node:os";
import { isAbsolute, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
import { expandHomePrefix, resolveOsHomeDir } from "../../../infra/home-dir.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const NARROW_NO_BREAK_SPACE = "\u202F";
@@ -21,6 +21,19 @@ function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
/** Expand OS-home syntax without treating a POSIX backslash as a separator. */
export function expandOsHomePrefix(filePath: string): string {
const isHomePath =
filePath === "~" ||
filePath.startsWith("~/") ||
(process.platform === "win32" && filePath.startsWith("~\\"));
if (!isHomePath) {
return filePath;
}
const home = resolveOsHomeDir();
return home ? expandHomePrefix(filePath, { home }) : filePath;
}
function expandPath(filePath: string, normalizeSpaces = true): string {
const withoutAtPrefix = normalizeAtPrefix(filePath);
const normalized = normalizeSpaces ? normalizeUnicodeSpaces(withoutAtPrefix) : withoutAtPrefix;
@@ -31,13 +44,7 @@ function expandPath(filePath: string, normalizeSpaces = true): string {
return normalized;
}
}
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
return expandOsHomePrefix(normalized);
}
/**
@@ -201,6 +201,23 @@ describe("detectChangedScope Windows routing", () => {
}
});
it("routes OS-home path owners and native tool coverage to Windows", () => {
for (const homePath of [
"src/infra/home-dir.ts",
"src/infra/home-dir.test.ts",
"src/agents/agent-tools.read.ts",
"src/agents/agent-tools.read.host-operations.test.ts",
"src/agents/agent-tools.read.windows.test.ts",
"src/agents/sessions/tools/path-utils.ts",
"src/agents/sessions/tools/path-utils.test.ts",
]) {
expect(detectChangedScope([homePath]), homePath).toMatchObject({
runNode: true,
runWindows: true,
});
}
});
it("routes child environment resolution and native doctor coverage to Windows", () => {
for (const envPath of [
"src/agents/provider-local-service.ts",
+8
View File
@@ -350,6 +350,14 @@ describe("package scripts", () => {
expect(script).toContain("src/agents/sessions/tools/render-utils.test.ts");
});
it("runs native OS-home path tool coverage in Windows CI", () => {
const script = readPackageJson().scripts["test:windows:ci"];
expect(script).toContain("src/agents/agent-tools.read.windows.test.ts");
expect(script).toContain("src/agents/agent-tools.read.host-operations.test.ts");
expect(script).toContain("src/agents/sessions/tools/path-utils.test.ts");
});
it("runs child environment and native doctor coverage in Windows CI", () => {
const script = readPackageJson().scripts["test:windows:ci"];