mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
Stage managed inbound media for runner access (#97647)
This commit is contained in:
+64
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user