diff --git a/src/agents/sandbox/host-paths.ts b/src/agents/sandbox/host-paths.ts index f80ba2c8ee5..f07f44d2ff4 100644 --- a/src/agents/sandbox/host-paths.ts +++ b/src/agents/sandbox/host-paths.ts @@ -1,12 +1,34 @@ import { posix } from "node:path"; import { resolvePathViaExistingAncestorSync } from "../../infra/boundary-path.js"; +function stripWindowsNamespacePrefix(input: string): string { + if (input.startsWith("\\\\?\\")) { + const withoutPrefix = input.slice(4); + if (withoutPrefix.toUpperCase().startsWith("UNC\\")) { + return `\\\\${withoutPrefix.slice(4)}`; + } + return withoutPrefix; + } + if (input.startsWith("//?/")) { + const withoutPrefix = input.slice(4); + if (withoutPrefix.toUpperCase().startsWith("UNC/")) { + return `//${withoutPrefix.slice(4)}`; + } + return withoutPrefix; + } + return input; +} + /** * Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`. */ export function normalizeSandboxHostPath(raw: string): string { - const trimmed = raw.trim(); - return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; + const trimmed = stripWindowsNamespacePrefix(raw.trim()); + if (!trimmed) { + return "/"; + } + const normalized = posix.normalize(trimmed.replaceAll("\\", "/")); + return normalized.replace(/\/+$/, "") || "/"; } /** diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index a0f8d205c70..4fa6b04cc12 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; @@ -99,7 +100,7 @@ describe("onboardCommand", () => { expect(mocks.handleReset).toHaveBeenCalledWith( "config+creds+sessions", - "/tmp/openclaw-custom-workspace", + path.resolve("/tmp/openclaw-custom-workspace"), runtime, ); }); diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts index 188039637b9..71ebb3e3870 100644 --- a/src/config/includes.test.ts +++ b/src/config/includes.test.ts @@ -630,7 +630,7 @@ describe("security: path traversal protection (CWE-22)", () => { "{ logging: { redactSensitive: 'tools' } }\n", "utf-8", ); - await fs.symlink(realRoot, linkRoot); + await fs.symlink(realRoot, linkRoot, process.platform === "win32" ? "junction" : undefined); const result = resolveConfigIncludes( { $include: "./includes/extra.json5" }, diff --git a/src/infra/path-guards.ts b/src/infra/path-guards.ts index 55330fa8bc4..751da0a9db0 100644 --- a/src/infra/path-guards.ts +++ b/src/infra/path-guards.ts @@ -3,6 +3,17 @@ import path from "node:path"; const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]); const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]); +function normalizeWindowsPathForComparison(input: string): string { + let normalized = path.win32.normalize(input); + if (normalized.startsWith("\\\\?\\")) { + normalized = normalized.slice(4); + if (normalized.toUpperCase().startsWith("UNC\\")) { + normalized = `\\\\${normalized.slice(4)}`; + } + } + return normalized.replaceAll("/", "\\").toLowerCase(); +} + export function isNodeError(value: unknown): value is NodeJS.ErrnoException { return Boolean( value && typeof value === "object" && "code" in (value as Record), @@ -26,7 +37,9 @@ export function isPathInside(root: string, target: string): boolean { const resolvedTarget = path.resolve(target); if (process.platform === "win32") { - const relative = path.win32.relative(resolvedRoot.toLowerCase(), resolvedTarget.toLowerCase()); + const rootForCompare = normalizeWindowsPathForComparison(resolvedRoot); + const targetForCompare = normalizeWindowsPathForComparison(resolvedTarget); + const relative = path.win32.relative(rootForCompare, targetForCompare); return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative)); } diff --git a/src/infra/restart.test.ts b/src/infra/restart.test.ts index 0203817016c..23795e46f8e 100644 --- a/src/infra/restart.test.ts +++ b/src/infra/restart.test.ts @@ -37,7 +37,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("findGatewayPidsOnPortSync", () => { +describe.runIf(process.platform !== "win32")("findGatewayPidsOnPortSync", () => { it("parses lsof output and filters non-openclaw/current processes", () => { spawnSyncMock.mockReturnValue({ error: undefined, @@ -76,7 +76,7 @@ describe("findGatewayPidsOnPortSync", () => { }); }); -describe("cleanStaleGatewayProcessesSync", () => { +describe.runIf(process.platform !== "win32")("cleanStaleGatewayProcessesSync", () => { it("kills stale gateway pids discovered on the gateway port", () => { spawnSyncMock.mockReturnValue({ error: undefined, diff --git a/src/plugins/path-safety.ts b/src/plugins/path-safety.ts index 48c2da8e6fa..7935312cbe4 100644 --- a/src/plugins/path-safety.ts +++ b/src/plugins/path-safety.ts @@ -1,12 +1,8 @@ import fs from "node:fs"; -import path from "node:path"; +import { isPathInside as isBoundaryPathInside } from "../infra/path-guards.js"; export function isPathInside(baseDir: string, targetPath: string): boolean { - const rel = path.relative(baseDir, targetPath); - if (!rel) { - return true; - } - return !rel.startsWith("..") && !path.isAbsolute(rel); + return isBoundaryPathInside(baseDir, targetPath); } export function safeRealpathSync(targetPath: string, cache?: Map): string | null {