diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index f848a1a4697..31203715f99 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, URL } from "node:url"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; @@ -85,10 +85,18 @@ export async function resolveSandboxedMediaSource(params: { } let candidate = raw; if (/^file:\/\//i.test(candidate)) { - try { - candidate = fileURLToPath(candidate); - } catch { - throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`); + const workspaceMappedFromUrl = mapContainerWorkspaceFileUrl({ + fileUrl: candidate, + sandboxRoot: params.sandboxRoot, + }); + if (workspaceMappedFromUrl) { + candidate = workspaceMappedFromUrl; + } else { + try { + candidate = fileURLToPath(candidate); + } catch { + throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`); + } } } const containerWorkspaceMapped = mapContainerWorkspacePath({ @@ -113,6 +121,34 @@ export async function resolveSandboxedMediaSource(params: { return sandboxResult.resolved; } +function mapContainerWorkspaceFileUrl(params: { + fileUrl: string; + sandboxRoot: string; +}): string | undefined { + let parsed: URL; + try { + parsed = new URL(params.fileUrl); + } catch { + return undefined; + } + if (parsed.protocol !== "file:") { + return undefined; + } + // Sandbox paths are Linux-style (/workspace/*). Parse the URL path directly so + // Windows hosts can still accept file:///workspace/... media references. + const normalizedPathname = decodeURIComponent(parsed.pathname).replace(/\\/g, "/"); + if ( + normalizedPathname !== SANDBOX_CONTAINER_WORKDIR && + !normalizedPathname.startsWith(`${SANDBOX_CONTAINER_WORKDIR}/`) + ) { + return undefined; + } + return mapContainerWorkspacePath({ + candidate: normalizedPathname, + sandboxRoot: params.sandboxRoot, + }); +} + function mapContainerWorkspacePath(params: { candidate: string; sandboxRoot: string; diff --git a/src/infra/exec-safe-bin-runtime-policy.test.ts b/src/infra/exec-safe-bin-runtime-policy.test.ts index 51846c79473..29f29864be2 100644 --- a/src/infra/exec-safe-bin-runtime-policy.test.ts +++ b/src/infra/exec-safe-bin-runtime-policy.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { isInterpreterLikeSafeBin, @@ -72,16 +73,18 @@ describe("exec safe-bin runtime policy", () => { }); it("merges explicit safe-bin trusted dirs from global and local config", () => { + const customDir = path.join(path.sep, "custom", "bin"); + const agentDir = path.join(path.sep, "agent", "bin"); const policy = resolveExecSafeBinRuntimePolicy({ global: { - safeBinTrustedDirs: [" /custom/bin ", "/custom/bin"], + safeBinTrustedDirs: [` ${customDir} `, customDir], }, local: { - safeBinTrustedDirs: ["/agent/bin"], + safeBinTrustedDirs: [agentDir], }, }); - expect(policy.trustedSafeBinDirs.has("/custom/bin")).toBe(true); - expect(policy.trustedSafeBinDirs.has("/agent/bin")).toBe(true); + expect(policy.trustedSafeBinDirs.has(path.resolve(customDir))).toBe(true); + expect(policy.trustedSafeBinDirs.has(path.resolve(agentDir))).toBe(true); }); }); diff --git a/src/infra/install-flow.test.ts b/src/infra/install-flow.test.ts index 62148f8e075..1c3c46ac5ee 100644 --- a/src/infra/install-flow.test.ts +++ b/src/infra/install-flow.test.ts @@ -51,17 +51,19 @@ describe("withExtractedArchiveRoot", () => { }); it("extracts archive and passes root directory to callback", async () => { + const tmpRoot = path.join(path.sep, "tmp", "openclaw-install-flow"); + const archivePath = path.join(path.sep, "tmp", "plugin.tgz"); + const extractDir = path.join(tmpRoot, "extract"); + const packageRoot = path.join(extractDir, "package"); const withTempDirSpy = vi .spyOn(installSource, "withTempDir") - .mockImplementation(async (_prefix, fn) => await fn("/tmp/openclaw-install-flow")); + .mockImplementation(async (_prefix, fn) => await fn(tmpRoot)); const extractSpy = vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined); - const resolveRootSpy = vi - .spyOn(archive, "resolvePackedRootDir") - .mockResolvedValue("/tmp/openclaw-install-flow/extract/package"); + const resolveRootSpy = vi.spyOn(archive, "resolvePackedRootDir").mockResolvedValue(packageRoot); const onExtracted = vi.fn(async (rootDir: string) => ({ ok: true as const, rootDir })); const result = await withExtractedArchiveRoot({ - archivePath: "/tmp/plugin.tgz", + archivePath, tempDirPrefix: "openclaw-plugin-", timeoutMs: 1000, onExtracted, @@ -70,14 +72,14 @@ describe("withExtractedArchiveRoot", () => { expect(withTempDirSpy).toHaveBeenCalledWith("openclaw-plugin-", expect.any(Function)); expect(extractSpy).toHaveBeenCalledWith( expect.objectContaining({ - archivePath: "/tmp/plugin.tgz", + archivePath, }), ); - expect(resolveRootSpy).toHaveBeenCalledWith("/tmp/openclaw-install-flow/extract"); - expect(onExtracted).toHaveBeenCalledWith("/tmp/openclaw-install-flow/extract/package"); + expect(resolveRootSpy).toHaveBeenCalledWith(extractDir); + expect(onExtracted).toHaveBeenCalledWith(packageRoot); expect(result).toEqual({ ok: true, - rootDir: "/tmp/openclaw-install-flow/extract/package", + rootDir: packageRoot, }); });