fix: make windows CI path handling deterministic

This commit is contained in:
Peter Steinberger
2026-02-22 22:34:42 +00:00
parent 3b0e62d5bf
commit 84e5ab598a
3 changed files with 59 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -85,10 +85,18 @@ export async function resolveSandboxedMediaSource(params: {
} }
let candidate = raw; let candidate = raw;
if (/^file:\/\//i.test(candidate)) { if (/^file:\/\//i.test(candidate)) {
try { const workspaceMappedFromUrl = mapContainerWorkspaceFileUrl({
candidate = fileURLToPath(candidate); fileUrl: candidate,
} catch { sandboxRoot: params.sandboxRoot,
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`); });
if (workspaceMappedFromUrl) {
candidate = workspaceMappedFromUrl;
} else {
try {
candidate = fileURLToPath(candidate);
} catch {
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
}
} }
} }
const containerWorkspaceMapped = mapContainerWorkspacePath({ const containerWorkspaceMapped = mapContainerWorkspacePath({
@@ -113,6 +121,34 @@ export async function resolveSandboxedMediaSource(params: {
return sandboxResult.resolved; 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: { function mapContainerWorkspacePath(params: {
candidate: string; candidate: string;
sandboxRoot: string; sandboxRoot: string;

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
isInterpreterLikeSafeBin, isInterpreterLikeSafeBin,
@@ -72,16 +73,18 @@ describe("exec safe-bin runtime policy", () => {
}); });
it("merges explicit safe-bin trusted dirs from global and local config", () => { 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({ const policy = resolveExecSafeBinRuntimePolicy({
global: { global: {
safeBinTrustedDirs: [" /custom/bin ", "/custom/bin"], safeBinTrustedDirs: [` ${customDir} `, customDir],
}, },
local: { local: {
safeBinTrustedDirs: ["/agent/bin"], safeBinTrustedDirs: [agentDir],
}, },
}); });
expect(policy.trustedSafeBinDirs.has("/custom/bin")).toBe(true); expect(policy.trustedSafeBinDirs.has(path.resolve(customDir))).toBe(true);
expect(policy.trustedSafeBinDirs.has("/agent/bin")).toBe(true); expect(policy.trustedSafeBinDirs.has(path.resolve(agentDir))).toBe(true);
}); });
}); });

View File

@@ -51,17 +51,19 @@ describe("withExtractedArchiveRoot", () => {
}); });
it("extracts archive and passes root directory to callback", async () => { 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 const withTempDirSpy = vi
.spyOn(installSource, "withTempDir") .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 extractSpy = vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined);
const resolveRootSpy = vi const resolveRootSpy = vi.spyOn(archive, "resolvePackedRootDir").mockResolvedValue(packageRoot);
.spyOn(archive, "resolvePackedRootDir")
.mockResolvedValue("/tmp/openclaw-install-flow/extract/package");
const onExtracted = vi.fn(async (rootDir: string) => ({ ok: true as const, rootDir })); const onExtracted = vi.fn(async (rootDir: string) => ({ ok: true as const, rootDir }));
const result = await withExtractedArchiveRoot({ const result = await withExtractedArchiveRoot({
archivePath: "/tmp/plugin.tgz", archivePath,
tempDirPrefix: "openclaw-plugin-", tempDirPrefix: "openclaw-plugin-",
timeoutMs: 1000, timeoutMs: 1000,
onExtracted, onExtracted,
@@ -70,14 +72,14 @@ describe("withExtractedArchiveRoot", () => {
expect(withTempDirSpy).toHaveBeenCalledWith("openclaw-plugin-", expect.any(Function)); expect(withTempDirSpy).toHaveBeenCalledWith("openclaw-plugin-", expect.any(Function));
expect(extractSpy).toHaveBeenCalledWith( expect(extractSpy).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
archivePath: "/tmp/plugin.tgz", archivePath,
}), }),
); );
expect(resolveRootSpy).toHaveBeenCalledWith("/tmp/openclaw-install-flow/extract"); expect(resolveRootSpy).toHaveBeenCalledWith(extractDir);
expect(onExtracted).toHaveBeenCalledWith("/tmp/openclaw-install-flow/extract/package"); expect(onExtracted).toHaveBeenCalledWith(packageRoot);
expect(result).toEqual({ expect(result).toEqual({
ok: true, ok: true,
rootDir: "/tmp/openclaw-install-flow/extract/package", rootDir: packageRoot,
}); });
}); });