From 7c47904bb42d70ceff5b80796d84b8cd8959e954 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:40:42 -0500 Subject: [PATCH] Stage managed inbound media for runner access (#97647) --- ...bound-media-into-sandbox-workspace.test.ts | 64 +++++++++++++++ src/auto-reply/reply/stage-sandbox-media.ts | 79 ++++++++++++++----- 2 files changed, 125 insertions(+), 18 deletions(-) diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index f5981615568a..bdf11341cfec 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -168,6 +168,70 @@ async function writeInboundMedia( } describe("stageSandboxMedia", () => { + it("stages managed inbound media URIs into the sandbox workspace", async () => { + await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { + const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); + const fileName = "report.pdf"; + const mediaPath = await writeInboundMedia(home, fileName, "pdf-bytes"); + const mediaUri = `media://inbound/${fileName}`; + const { ctx, sessionCtx } = createSandboxMediaContexts(mediaUri); + ctx.MediaType = "application/pdf"; + sessionCtx.MediaType = "application/pdf"; + + const result = await stageSandboxMedia({ + ctx, + sessionCtx, + cfg, + sessionKey: "agent:main:main", + workspaceDir, + }); + + const stagedPath = `media/inbound/${fileName}`; + expect(result.staged.get(mediaUri)).toBe(stagedPath); + expect(result.staged.get(await fs.realpath(mediaPath))).toBe(stagedPath); + expect(ctx.MediaPath).toBe(stagedPath); + expect(sessionCtx.MediaPath).toBe(stagedPath); + expect(ctx.MediaUrl).toBe(stagedPath); + expect(sessionCtx.MediaUrl).toBe(stagedPath); + await expect(fs.readFile(join(sandboxDir, stagedPath), "utf8")).resolves.toBe("pdf-bytes"); + }); + }); + + it("stages managed inbound media URIs into the host workspace when sandboxing is off", async () => { + await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { + const cfg = createSandboxMediaStageConfig(home); + const workspaceDir = join(home, "openclaw"); + sandboxMocks.ensureSandboxWorkspaceForSession.mockResolvedValue(null); + const fileName = "host-report.pdf"; + await writeInboundMedia(home, fileName, "host-pdf-bytes"); + const existingProjectFile = join(workspaceDir, "media", "inbound", fileName); + await fs.mkdir(dirname(existingProjectFile), { recursive: true }); + await fs.writeFile(existingProjectFile, "project-file"); + const mediaUri = `media://inbound/${fileName}`; + const { ctx, sessionCtx } = createSandboxMediaContexts(mediaUri); + ctx.MediaType = "application/pdf"; + sessionCtx.MediaType = "application/pdf"; + + const result = await stageSandboxMedia({ + ctx, + sessionCtx, + cfg, + sessionKey: "agent:main:main", + workspaceDir, + }); + + const stagedPath = ctx.MediaPath ?? ""; + const stagedRelativePath = path.relative(workspaceDir, stagedPath); + expect(stagedRelativePath).toMatch( + new RegExp(`^media/inbound/openclaw-staged-[0-9a-f-]+/${fileName}$`), + ); + expect(result.staged.get(mediaUri)).toBe(stagedPath); + expect(sessionCtx.MediaPath).toBe(stagedPath); + await expect(fs.readFile(stagedPath, "utf8")).resolves.toBe("host-pdf-bytes"); + await expect(fs.readFile(existingProjectFile, "utf8")).resolves.toBe("project-file"); + }); + }); + it("stages allowed media and blocks unsafe paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 31a5926696d4..98d5c92ee990 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -1,5 +1,6 @@ // Stages inbound media into sandbox workspaces before agent execution. import { spawn } from "node:child_process"; +import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -35,6 +36,12 @@ export type StageSandboxMediaResult = { const EMPTY_STAGE_RESULT: StageSandboxMediaResult = { staged: new Map() }; +type StageableMediaSource = { + lookupKey: string; + pathForFileName: string; + physicalPath: string; +}; + export async function stageSandboxMedia(params: { ctx: MsgContext; sessionCtx: TemplateContext; @@ -59,11 +66,13 @@ export async function stageSandboxMedia(params: { workspaceDir, }); - // For remote attachments without sandbox, use ~/.openclaw/media (not agent workspace for privacy) + // For remote attachments without sandbox, use ~/.openclaw/media (not agent workspace for privacy). + // Managed local inbound refs are already in OpenClaw's media store; when no sandbox is + // active, copy them into the runner workspace so host-mode shell/doc readers get a path. const remoteMediaCacheDir = ctx.MediaRemoteHost ? path.join(CONFIG_DIR, "media", "remote-cache", slugifySessionKey(sessionKey)) : null; - const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir; + const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir ?? workspaceDir; if (!effectiveWorkspaceDir) { return EMPTY_STAGE_RESULT; } @@ -74,39 +83,46 @@ export async function stageSandboxMedia(params: { : []; const usedNames = new Set(); - const staged = new Map(); // absolute source -> relative sandbox path + const staged = new Map(); // original/resolved source -> runner-visible path + const hostWorkspaceStagingDir = + !sandbox && !ctx.MediaRemoteHost + ? path.join("media", "inbound", `openclaw-staged-${crypto.randomUUID()}`) + : undefined; for (const raw of rawPaths) { - const source = resolveAbsolutePath(raw); - if (!source || staged.has(source)) { + const source = await resolveStageableMediaSource(raw); + if (!source || staged.has(source.lookupKey) || staged.has(source.physicalPath)) { continue; } const allowed = await isAllowedSourcePath({ - source, + source: source.physicalPath, mediaRemoteHost: ctx.MediaRemoteHost, remoteAttachmentRoots, }); if (!allowed) { continue; } - const fileName = allocateStagedFileName(source, usedNames); + const fileName = allocateStagedFileName(source.pathForFileName, usedNames); if (!fileName) { continue; } - const relativeDest = sandbox ? path.join("media", "inbound", fileName) : fileName; + const stageIntoSandboxMediaDir = Boolean(sandbox); + const relativeDest = stageIntoSandboxMediaDir || hostWorkspaceStagingDir + ? path.join(hostWorkspaceStagingDir ?? path.join("media", "inbound"), fileName) + : fileName; const dest = path.join(effectiveWorkspaceDir, relativeDest); try { if (ctx.MediaRemoteHost) { await stageRemoteFileIntoRoot({ remoteHost: ctx.MediaRemoteHost, - remotePath: source, + remotePath: source.physicalPath, rootDir: effectiveWorkspaceDir, relativeDestPath: relativeDest, maxBytes: STAGED_MEDIA_MAX_BYTES, }); } else { - const copySource = await fs.realpath(source).catch(() => source); + const copySource = await fs.realpath(source.physicalPath).catch(() => source.physicalPath); await stageLocalFileIntoRoot({ sourcePath: copySource, rootDir: effectiveWorkspaceDir, @@ -117,17 +133,20 @@ export async function stageSandboxMedia(params: { } catch (err) { if (err instanceof FsSafeError && err.code === "too-large") { logVerbose( - `Blocking inbound media staging above ${STAGED_MEDIA_MAX_BYTES} bytes: ${source}`, + `Blocking inbound media staging above ${STAGED_MEDIA_MAX_BYTES} bytes: ${source.physicalPath}`, ); } else { - logVerbose(`Failed to stage inbound media path ${source}: ${String(err)}`); + logVerbose(`Failed to stage inbound media path ${source.physicalPath}: ${String(err)}`); } continue; } // For sandbox use relative path, for remote cache use absolute path - const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest; - staged.set(source, stagedPath); + const stagedPath = stageIntoSandboxMediaDir ? toPosixRelativePath(relativeDest) : dest; + staged.set(source.lookupKey, stagedPath); + if (source.physicalPath !== source.lookupKey) { + staged.set(source.physicalPath, stagedPath); + } } rewriteStagedMediaPaths({ @@ -141,6 +160,33 @@ export async function stageSandboxMedia(params: { return { staged }; } +function toPosixRelativePath(filePath: string): string { + return filePath.split(path.sep).join(path.posix.sep); +} + +async function resolveStageableMediaSource(value: string): Promise { + const raw = value.trim(); + if (!raw) { + return null; + } + const inboundReference = await resolveInboundMediaReference(raw).catch(() => null); + if (inboundReference) { + return { + lookupKey: raw, + pathForFileName: inboundReference.physicalPath, + physicalPath: inboundReference.physicalPath, + }; + } + const source = resolveAbsolutePath(raw); + return source + ? { + lookupKey: source, + pathForFileName: source, + physicalPath: source, + } + : null; +} + async function stageLocalFileIntoRoot(params: { sourcePath: string; rootDir: string; @@ -279,10 +325,7 @@ function rewriteStagedMediaPaths(params: { return value; } const abs = resolveAbsolutePath(raw); - if (!abs) { - return value; - } - const mapped = params.staged.get(abs); + const mapped = params.staged.get(raw) ?? (abs ? params.staged.get(abs) : undefined); return mapped ?? value; };