Stage managed inbound media for runner access (#97647)

This commit is contained in:
Josh Avant
2026-06-28 22:40:42 -05:00
committed by GitHub
parent 6299b679c0
commit 7c47904bb4
2 changed files with 125 additions and 18 deletions
@@ -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);
+61 -18
View File
@@ -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<string>();
const staged = new Map<string, string>(); // absolute source -> relative sandbox path
const staged = new Map<string, string>(); // 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<StageableMediaSource | null> {
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;
};