From 1b462ed174e662374ad6ced13f57a03caf4c8b28 Mon Sep 17 00:00:00 2001 From: Artale <117890364+arosstale@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:45:19 +0100 Subject: [PATCH] fix(test): use NTFS junctions and platform guards for symlink tests on Windows (openclaw#28747) thanks @arosstale Verified: - pnpm install --frozen-lockfile - pnpm test src/agents/apply-patch.test.ts src/agents/sandbox/fs-bridge.test.ts src/agents/sandbox/validate-sandbox-security.test.ts src/infra/archive.test.ts Co-authored-by: arosstale <117890364+arosstale@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/apply-patch.test.ts | 20 +++++++++++++++++-- src/agents/sandbox/fs-bridge.test.ts | 4 ++++ .../sandbox/validate-sandbox-security.test.ts | 17 ++++++++++------ src/infra/archive.test.ts | 8 +++++++- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b5b5463..6bbfd4c69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths. +- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale. - Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff. - Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent. - Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3. diff --git a/src/agents/apply-patch.test.ts b/src/agents/apply-patch.test.ts index 575f3f21d..b14179f59 100644 --- a/src/agents/apply-patch.test.ts +++ b/src/agents/apply-patch.test.ts @@ -148,6 +148,10 @@ describe("applyPatch", () => { }); it("rejects symlink escape attempts by default", async () => { + // File symlinks require SeCreateSymbolicLinkPrivilege on Windows. + if (process.platform === "win32") { + return; + } await withTempDir(async (dir) => { const outside = path.join(path.dirname(dir), "outside-target.txt"); const linkPath = path.join(dir, "link.txt"); @@ -232,6 +236,10 @@ describe("applyPatch", () => { }); it("allows symlinks that resolve within cwd by default", async () => { + // File symlinks require SeCreateSymbolicLinkPrivilege on Windows. + if (process.platform === "win32") { + return; + } await withTempDir(async (dir) => { const target = path.join(dir, "target.txt"); const linkPath = path.join(dir, "link.txt"); @@ -259,7 +267,9 @@ describe("applyPatch", () => { await fs.writeFile(outsideFile, "victim\n", "utf8"); const linkDir = path.join(dir, "linkdir"); - await fs.symlink(outsideDir, linkDir); + // Use 'junction' on Windows — junctions target directories without + // requiring SeCreateSymbolicLinkPrivilege. + await fs.symlink(outsideDir, linkDir, process.platform === "win32" ? "junction" : undefined); const patch = `*** Begin Patch *** Delete File: linkdir/victim.txt @@ -310,7 +320,13 @@ describe("applyPatch", () => { await fs.writeFile(outsideTarget, "keep\n", "utf8"); const linkDir = path.join(dir, "link"); - await fs.symlink(outsideDir, linkDir); + // Use 'junction' on Windows — junctions target directories without + // requiring SeCreateSymbolicLinkPrivilege. + await fs.symlink( + outsideDir, + linkDir, + process.platform === "win32" ? "junction" : undefined, + ); const patch = `*** Begin Patch *** Delete File: link diff --git a/src/agents/sandbox/fs-bridge.test.ts b/src/agents/sandbox/fs-bridge.test.ts index 4a9243310..e6679744e 100644 --- a/src/agents/sandbox/fs-bridge.test.ts +++ b/src/agents/sandbox/fs-bridge.test.ts @@ -308,6 +308,10 @@ describe("sandbox fs bridge shell compatibility", () => { it("rejects pre-existing host symlink escapes before docker exec", async () => { await withTempDir("openclaw-fs-bridge-", async (stateDir) => { const { workspaceDir, outsideFile } = await createHostEscapeFixture(stateDir); + // File symlinks require SeCreateSymbolicLinkPrivilege on Windows. + if (process.platform === "win32") { + return; + } await fs.symlink(outsideFile, path.join(workspaceDir, "link.txt")); const bridge = createSandboxFsBridge({ diff --git a/src/agents/sandbox/validate-sandbox-security.test.ts b/src/agents/sandbox/validate-sandbox-security.test.ts index cc3bd2e00..3f06b1daa 100644 --- a/src/agents/sandbox/validate-sandbox-security.test.ts +++ b/src/agents/sandbox/validate-sandbox-security.test.ts @@ -103,17 +103,22 @@ describe("validateBindMounts", () => { }); it("blocks symlink escapes into blocked directories", () => { - const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); - const link = join(dir, "etc-link"); - symlinkSync("/etc", link); - const run = () => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`]); - if (process.platform === "win32") { - // Windows source paths (e.g. C:\...) are intentionally rejected as non-POSIX. + // Symlinks to non-existent targets like /etc require + // SeCreateSymbolicLinkPrivilege on Windows. The Windows branch of this + // test does not need a real symlink — it only asserts that Windows source + // paths are rejected as non-POSIX. + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const fakePath = join(dir, "etc-link", "passwd"); + const run = () => validateBindMounts([`${fakePath}:/mnt/passwd:ro`]); expect(run).toThrow(/non-absolute source path/); return; } + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const link = join(dir, "etc-link"); + symlinkSync("/etc", link); + const run = () => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`]); expect(run).toThrow(/blocked path/); }); diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index bb790bdb5..16df39104 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -120,7 +120,13 @@ describe("archive utils", () => { await withArchiveCase("zip", async ({ workDir, archivePath, extractDir }) => { const outsideDir = path.join(workDir, "outside"); await fs.mkdir(outsideDir, { recursive: true }); - await fs.symlink(outsideDir, path.join(extractDir, "escape")); + // Use 'junction' on Windows — junctions target directories without + // requiring SeCreateSymbolicLinkPrivilege. + await fs.symlink( + outsideDir, + path.join(extractDir, "escape"), + process.platform === "win32" ? "junction" : undefined, + ); const zip = new JSZip(); zip.file("escape/pwn.txt", "owned");