diff --git a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts index cd793d07420..f3496ce565a 100644 --- a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts +++ b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import { basename, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { slugifySessionKey } from "../agents/sandbox/shared.js"; +import { CONFIG_DIR } from "../utils.js"; import { createSandboxMediaContexts, createSandboxMediaStageConfig, @@ -50,7 +52,7 @@ function createRemoteStageParams(home: string): { cfg: createSandboxMediaStageConfig(home), workspaceDir: join(home, "openclaw"), sessionKey, - remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", sessionKey), + remoteCacheDir: join(home, ".openclaw", "media", "remote-cache", slugifySessionKey(sessionKey)), }; } @@ -86,4 +88,33 @@ describe("stageSandboxMedia scp remote paths", () => { expect(sessionCtx.MediaUrl).toBe(remotePath); }); }); + + it("uses a slugged remote cache directory for session keys with path separators", async () => { + await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { + const { cfg, workspaceDir } = createRemoteStageParams(home); + const sessionKey = "agent:main:explicit:../../escape"; + const remotePath = "/Users/demo/Library/Messages/Attachments/ab/cd/photo.jpg"; + const { ctx, sessionCtx } = createRemoteContexts(remotePath); + childProcessMocks.spawn.mockImplementation(() => { + throw new Error("stop before scp"); + }); + + await stageSandboxMedia({ + ctx, + sessionCtx, + cfg, + sessionKey, + workspaceDir, + }); + + const remoteCacheRoot = join(CONFIG_DIR, "media", "remote-cache"); + const expectedSafeDir = join(remoteCacheRoot, slugifySessionKey(sessionKey)); + try { + await expect(fs.stat(expectedSafeDir)).resolves.toBeTruthy(); + await expect(fs.stat(join(CONFIG_DIR, "escape"))).rejects.toThrow(); + } finally { + await fs.rm(expectedSafeDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 1aab3f7371b..1689fdbbb6d 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { assertSandboxPath } from "../../agents/sandbox-paths.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; +import { slugifySessionKey } from "../../agents/sandbox/shared.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { copyFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js"; @@ -40,7 +41,7 @@ export async function stageSandboxMedia(params: { // For remote attachments without sandbox, use ~/.openclaw/media (not agent workspace for privacy) const remoteMediaCacheDir = ctx.MediaRemoteHost - ? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey) + ? path.join(CONFIG_DIR, "media", "remote-cache", slugifySessionKey(sessionKey)) : null; const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir; if (!effectiveWorkspaceDir) {