mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix: allow workspace-rooted absolute media paths in auto-reply (#66689)
Merged via squash.
Prepared head SHA: 48206b5627
Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -142,6 +142,15 @@ describe("runReplyAgent runtime config", () => {
|
||||
cfg: freshCfg,
|
||||
sessionKey: undefined,
|
||||
workspaceDir: "/tmp",
|
||||
messageProvider: "telegram",
|
||||
accountId: undefined,
|
||||
groupId: undefined,
|
||||
groupChannel: undefined,
|
||||
groupSpace: undefined,
|
||||
requesterSenderId: undefined,
|
||||
requesterSenderName: undefined,
|
||||
requesterSenderUsername: undefined,
|
||||
requesterSenderE164: undefined,
|
||||
});
|
||||
expect(runPreflightCompactionIfNeededMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -605,6 +605,15 @@ export async function runAgentTurnWithFallback(params: {
|
||||
cfg: runtimeConfig,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir: params.followupRun.run.workspaceDir,
|
||||
messageProvider: params.followupRun.run.messageProvider,
|
||||
accountId: params.followupRun.originatingAccountId ?? params.followupRun.run.agentAccountId,
|
||||
groupId: params.followupRun.run.groupId,
|
||||
groupChannel: params.followupRun.run.groupChannel,
|
||||
groupSpace: params.followupRun.run.groupSpace,
|
||||
requesterSenderId: params.followupRun.run.senderId,
|
||||
requesterSenderName: params.followupRun.run.senderName,
|
||||
requesterSenderUsername: params.followupRun.run.senderUsername,
|
||||
requesterSenderE164: params.followupRun.run.senderE164,
|
||||
});
|
||||
let didNotifyAgentRunStart = false;
|
||||
const notifyAgentRunStart = () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ const waitForEmbeddedPiRunEndMock = vi.fn();
|
||||
const enqueueFollowupRunMock = vi.fn();
|
||||
const scheduleFollowupDrainMock = vi.fn();
|
||||
const refreshQueuedFollowupSessionMock = vi.fn();
|
||||
const resolveOutboundAttachmentFromUrlMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: (params: {
|
||||
@@ -46,6 +47,11 @@ vi.mock("./queue.js", () => ({
|
||||
scheduleFollowupDrain: scheduleFollowupDrainMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../media/outbound-attachment.js", () => ({
|
||||
resolveOutboundAttachmentFromUrl: (...args: unknown[]) =>
|
||||
resolveOutboundAttachmentFromUrlMock(...args),
|
||||
}));
|
||||
|
||||
let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent;
|
||||
|
||||
describe("runReplyAgent media path normalization", () => {
|
||||
@@ -66,7 +72,11 @@ describe("runReplyAgent media path normalization", () => {
|
||||
enqueueFollowupRunMock.mockReset();
|
||||
scheduleFollowupDrainMock.mockReset();
|
||||
refreshQueuedFollowupSessionMock.mockReset();
|
||||
resolveOutboundAttachmentFromUrlMock.mockReset();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
|
||||
path: path.join("/tmp/outbound-media", path.basename(mediaUrl)),
|
||||
}));
|
||||
runWithModelFallbackMock.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
@@ -137,8 +147,17 @@ describe("runReplyAgent media path normalization", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: path.join("/tmp/workspace", "out", "generated.png"),
|
||||
mediaUrls: [path.join("/tmp/workspace", "out", "generated.png")],
|
||||
mediaUrl: "/tmp/outbound-media/generated.png",
|
||||
mediaUrls: ["/tmp/outbound-media/generated.png"],
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith(
|
||||
path.join("/tmp/workspace", "out", "generated.png"),
|
||||
5 * 1024 * 1024,
|
||||
expect.objectContaining({
|
||||
mediaAccess: expect.objectContaining({
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1031,6 +1031,15 @@ export async function runReplyAgent(params: {
|
||||
cfg,
|
||||
sessionKey,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
messageProvider: followupRun.run.messageProvider,
|
||||
accountId: followupRun.originatingAccountId ?? followupRun.run.agentAccountId,
|
||||
groupId: followupRun.run.groupId,
|
||||
groupChannel: followupRun.run.groupChannel,
|
||||
groupSpace: followupRun.run.groupSpace,
|
||||
requesterSenderId: followupRun.run.senderId,
|
||||
requesterSenderName: followupRun.run.senderName,
|
||||
requesterSenderUsername: followupRun.run.senderUsername,
|
||||
requesterSenderE164: followupRun.run.senderE164,
|
||||
});
|
||||
const blockReplyCoalescing =
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { appendUntrustedContext } from "./untrusted-context.js";
|
||||
|
||||
export const REPLY_MEDIA_HINT =
|
||||
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg (spaces ok, quote if needed) or a safe relative path like MEDIA:./image.jpg. Avoid absolute paths (MEDIA:/...) and ~ paths - they are blocked for security. Keep caption in the text body.";
|
||||
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg (spaces ok, quote if needed) or a safe relative path like MEDIA:./image.jpg. Absolute and ~ paths only work when they stay inside your allowed file-read boundary; host file:// URLs are blocked. Keep caption in the text body.";
|
||||
|
||||
export function buildReplyPromptBodies(params: {
|
||||
ctx: MsgContext;
|
||||
|
||||
@@ -2,23 +2,19 @@ import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn());
|
||||
const resolvePreferredOpenClawTmpDir = vi.hoisted(() => vi.fn(() => "/private/tmp/openclaw-501"));
|
||||
const saveMediaSource = vi.hoisted(() => vi.fn());
|
||||
const resolveOutboundAttachmentFromUrl = vi.hoisted(() => vi.fn());
|
||||
const resolveAgentScopedOutboundMediaAccess = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../agents/sandbox.js", () => ({
|
||||
ensureSandboxWorkspaceForSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/tmp-openclaw-dir.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../infra/tmp-openclaw-dir.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
};
|
||||
});
|
||||
vi.mock("../../media/outbound-attachment.js", () => ({
|
||||
resolveOutboundAttachmentFromUrl,
|
||||
}));
|
||||
|
||||
vi.mock("../../media/store.js", () => ({
|
||||
saveMediaSource,
|
||||
vi.mock("../../media/read-capability.js", () => ({
|
||||
resolveAgentScopedOutboundMediaAccess,
|
||||
}));
|
||||
|
||||
import { createReplyMediaPathNormalizer } from "./reply-media-paths.js";
|
||||
@@ -26,12 +22,20 @@ import { createReplyMediaPathNormalizer } from "./reply-media-paths.js";
|
||||
describe("createReplyMediaPathNormalizer", () => {
|
||||
beforeEach(() => {
|
||||
ensureSandboxWorkspaceForSession.mockReset().mockResolvedValue(null);
|
||||
resolvePreferredOpenClawTmpDir.mockReset().mockReturnValue("/private/tmp/openclaw-501");
|
||||
saveMediaSource.mockReset();
|
||||
resolveOutboundAttachmentFromUrl.mockReset().mockImplementation(async (mediaUrl: string) => ({
|
||||
path: path.join("/tmp/outbound-media", path.basename(mediaUrl.replace(/^file:\/\//i, ""))),
|
||||
}));
|
||||
resolveAgentScopedOutboundMediaAccess
|
||||
.mockReset()
|
||||
.mockImplementation(({ workspaceDir }: { workspaceDir?: string }) => ({
|
||||
workspaceDir,
|
||||
localRoots: workspaceDir ? [workspaceDir] : undefined,
|
||||
readFile: async () => Buffer.from("image"),
|
||||
}));
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("resolves workspace-relative media against the agent workspace", async () => {
|
||||
it("stages workspace-relative media through shared outbound attachment loading", async () => {
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
@@ -43,12 +47,21 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: path.join("/tmp/agent-workspace", "out", "photo.png"),
|
||||
mediaUrls: [path.join("/tmp/agent-workspace", "out", "photo.png")],
|
||||
mediaUrl: "/tmp/outbound-media/photo.png",
|
||||
mediaUrls: ["/tmp/outbound-media/photo.png"],
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
|
||||
path.join("/tmp/agent-workspace", "out", "photo.png"),
|
||||
5 * 1024 * 1024,
|
||||
expect.objectContaining({
|
||||
mediaAccess: expect.objectContaining({
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps sandbox-relative media back to the host sandbox workspace", async () => {
|
||||
it("maps sandbox-relative media back to the host sandbox workspace before staging", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
@@ -64,15 +77,70 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
|
||||
mediaUrls: [
|
||||
path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
|
||||
path.join("/tmp/sandboxes/session-1", "screens", "final.png"),
|
||||
],
|
||||
mediaUrl: "/tmp/outbound-media/photo.png",
|
||||
mediaUrls: ["/tmp/outbound-media/photo.png", "/tmp/outbound-media/final.png"],
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
|
||||
5 * 1024 * 1024,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
path.join("/tmp/sandboxes/session-1", "screens", "final.png"),
|
||||
5 * 1024 * 1024,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops arbitrary host-local media paths when sandbox exists", async () => {
|
||||
it("drops sandbox-mapped media when staging fails instead of retrying the workspace fallback", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
resolveOutboundAttachmentFromUrl.mockRejectedValueOnce(new Error("media too large"));
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["./out/photo.png"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledTimes(1);
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
|
||||
path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
|
||||
5 * 1024 * 1024,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops host file URLs when no sandbox mapping applies", async () => {
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["file:///Users/peter/Documents/report.pdf"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops host file URLs even when sandbox exists", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
@@ -84,40 +152,113 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
mediaUrls: ["file:///Users/peter/Documents/report.pdf"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops relative sandbox escapes when tools.fs.workspaceOnly is enabled", async () => {
|
||||
it("drops absolute host-local media paths when sandbox mapping fails", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: { tools: { fs: { workspaceOnly: true } } },
|
||||
cfg: { tools: { fs: { workspaceOnly: false } } },
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["../sandboxes/session-1/screens/final.png"],
|
||||
mediaUrls: ["/Users/peter/Documents/report.pdf"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps managed generated media under the shared media root", async () => {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
it("stages absolute workspace media paths so the PR scenario now works", async () => {
|
||||
const absolutePath = "/Users/peter/.openclaw/workspace/exports/images/chart.png";
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 8 } } },
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/Users/peter/.openclaw/workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: [absolutePath],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: "/tmp/outbound-media/chart.png",
|
||||
mediaUrls: ["/tmp/outbound-media/chart.png"],
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
|
||||
absolutePath,
|
||||
8 * 1024 * 1024,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channel account media limits when staging reply attachments", async () => {
|
||||
const absolutePath = "/Users/peter/.openclaw/workspace/exports/images/chart.png";
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
mediaMaxMb: 50,
|
||||
accounts: {
|
||||
work: {
|
||||
mediaMaxMb: 64,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults: { mediaMaxMb: 8 } },
|
||||
},
|
||||
sessionKey: undefined,
|
||||
workspaceDir: "/Users/peter/.openclaw/workspace",
|
||||
messageProvider: "whatsapp",
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
await normalize({
|
||||
mediaUrls: [absolutePath],
|
||||
});
|
||||
|
||||
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
|
||||
absolutePath,
|
||||
64 * 1024 * 1024,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops workspace-relative media paths that escape the agent workspace", async () => {
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["../../etc/passwd"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops sandbox-relative media paths that escape both sandbox and workspace", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
@@ -128,6 +269,25 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["../../etc/passwd"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps managed generated media under the shared media root", async () => {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
|
||||
});
|
||||
@@ -136,14 +296,13 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
mediaUrl: "/Users/peter/.openclaw/media/tool-image-generation/generated.png",
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops absolute file URLs outside managed reply media roots", async () => {
|
||||
ensureSandboxWorkspaceForSession.mockResolvedValue({
|
||||
workspaceDir: "/tmp/sandboxes/session-1",
|
||||
containerWorkdir: "/workspace",
|
||||
});
|
||||
it("drops host-local media when shared outbound attachment policy rejects it", async () => {
|
||||
resolveOutboundAttachmentFromUrl.mockRejectedValueOnce(
|
||||
new Error("Local media path is not under an allowed directory"),
|
||||
);
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
@@ -151,7 +310,7 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["file:///Users/peter/.openclaw/media/inbound/photo.png"],
|
||||
mediaUrls: ["/Users/peter/secrets/photo.png"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
@@ -160,98 +319,71 @@ describe("createReplyMediaPathNormalizer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists volatile agent-state media from the workspace into host outbound media", async () => {
|
||||
saveMediaSource.mockResolvedValue({
|
||||
path: "/Users/peter/.openclaw/media/outbound/persisted.png",
|
||||
});
|
||||
it("threads requester context into shared outbound media access", async () => {
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: { agents: { defaults: { mediaMaxMb: 8 } } },
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/Users/peter/.openclaw/workspace",
|
||||
cfg: {},
|
||||
sessionKey: undefined,
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
messageProvider: "whatsapp",
|
||||
accountId: "source-account",
|
||||
groupId: "ops",
|
||||
groupChannel: "whatsapp",
|
||||
groupSpace: "team",
|
||||
requesterSenderId: "sender-1",
|
||||
requesterSenderName: "Sender Name",
|
||||
requesterSenderUsername: "sender-user",
|
||||
requesterSenderE164: "+15551234567",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: [
|
||||
"/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png",
|
||||
],
|
||||
await normalize({
|
||||
mediaUrls: ["./out/photo.png"],
|
||||
});
|
||||
|
||||
expect(saveMediaSource).toHaveBeenCalledWith(
|
||||
"/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png",
|
||||
undefined,
|
||||
"outbound",
|
||||
8 * 1024 * 1024,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: "/Users/peter/.openclaw/media/outbound/persisted.png",
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/outbound/persisted.png"],
|
||||
expect(resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
agentId: undefined,
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
mediaSources: [path.join("/tmp/agent-workspace", "out", "photo.png")],
|
||||
sessionKey: undefined,
|
||||
messageProvider: "whatsapp",
|
||||
accountId: "source-account",
|
||||
requesterSenderId: "sender-1",
|
||||
requesterSenderName: "Sender Name",
|
||||
requesterSenderUsername: "sender-user",
|
||||
requesterSenderE164: "+15551234567",
|
||||
groupId: "ops",
|
||||
groupChannel: "whatsapp",
|
||||
groupSpace: "team",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists TTS voice output from the preferred OpenClaw temp directory", async () => {
|
||||
const tmpVoicePath = path.join(
|
||||
"/private/tmp/openclaw-501",
|
||||
"tts-abc123",
|
||||
"voice-1234567890.opus",
|
||||
);
|
||||
saveMediaSource.mockResolvedValue({
|
||||
path: "/Users/peter/.openclaw/media/outbound/tts-voice.opus",
|
||||
});
|
||||
it("passes absolute local media sources into shared outbound media access", async () => {
|
||||
const absolutePath = "/Users/peter/Pictures/chart.png";
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
cfg: { tools: { fs: { workspaceOnly: false } } },
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: [tmpVoicePath],
|
||||
await normalize({
|
||||
mediaUrls: [absolutePath],
|
||||
});
|
||||
|
||||
expect(saveMediaSource).toHaveBeenCalledWith(tmpVoicePath, undefined, "outbound", undefined);
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: "/Users/peter/.openclaw/media/outbound/tts-voice.opus",
|
||||
mediaUrls: ["/Users/peter/.openclaw/media/outbound/tts-voice.opus"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the original preferred tmp path when persisting TTS media fails", async () => {
|
||||
const tmpVoicePath = path.join(
|
||||
"/private/tmp/openclaw-501",
|
||||
"tts-fallback",
|
||||
"voice-1234567890.opus",
|
||||
);
|
||||
saveMediaSource.mockRejectedValue(new Error("disk full"));
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
sessionKey: "session-key",
|
||||
expect(resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith({
|
||||
cfg: { tools: { fs: { workspaceOnly: false } } },
|
||||
agentId: expect.any(String),
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: [tmpVoicePath],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: tmpVoicePath,
|
||||
mediaUrls: [tmpVoicePath],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops host tmp paths outside the preferred OpenClaw temp directory", async () => {
|
||||
const normalize = createReplyMediaPathNormalizer({
|
||||
cfg: {},
|
||||
mediaSources: [absolutePath],
|
||||
sessionKey: "session-key",
|
||||
workspaceDir: "/tmp/agent-workspace",
|
||||
messageProvider: undefined,
|
||||
accountId: undefined,
|
||||
requesterSenderId: undefined,
|
||||
requesterSenderName: undefined,
|
||||
requesterSenderUsername: undefined,
|
||||
requesterSenderE164: undefined,
|
||||
groupId: undefined,
|
||||
groupChannel: undefined,
|
||||
groupSpace: undefined,
|
||||
});
|
||||
|
||||
const result = await normalize({
|
||||
mediaUrls: ["/private/tmp/not-openclaw/voice-1234567890.opus"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mediaUrl: undefined,
|
||||
mediaUrls: undefined,
|
||||
});
|
||||
expect(saveMediaSource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolvePathFromInput } from "../../agents/path-policy.js";
|
||||
import { resolvePathFromInput, toRelativeWorkspacePath } from "../../agents/path-policy.js";
|
||||
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||
import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
|
||||
import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js";
|
||||
import { isPassThroughRemoteMediaSource } from "../../media/media-source-url.js";
|
||||
import { saveMediaSource } from "../../media/store.js";
|
||||
import { resolveOutboundAttachmentFromUrl } from "../../media/outbound-attachment.js";
|
||||
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
|
||||
import { MEDIA_MAX_BYTES } from "../../media/store.js";
|
||||
import { resolveConfigDir } from "../../utils.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
@@ -18,14 +17,7 @@ const FILE_URL_RE = /^file:\/\//i;
|
||||
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
|
||||
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
||||
const HAS_FILE_EXT_RE = /\.\w{1,10}$/;
|
||||
const AGENT_STATE_MEDIA_DIRNAME = path.join(".openclaw", "media");
|
||||
const MANAGED_GLOBAL_MEDIA_SUBDIRS = new Set(["outbound"]);
|
||||
let cachedPreferredTmpRoot: string | null | undefined;
|
||||
|
||||
function isPathInside(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isManagedGlobalReplyMediaPath(candidate: string): boolean {
|
||||
const globalMediaRoot = path.join(resolveConfigDir(), "media");
|
||||
@@ -37,43 +29,6 @@ function isManagedGlobalReplyMediaPath(candidate: string): boolean {
|
||||
return MANAGED_GLOBAL_MEDIA_SUBDIRS.has(firstSegment) || firstSegment.startsWith("tool-");
|
||||
}
|
||||
|
||||
function resolvePreferredReplyMediaTmpRoot(): string | undefined {
|
||||
if (cachedPreferredTmpRoot !== undefined) {
|
||||
return cachedPreferredTmpRoot ?? undefined;
|
||||
}
|
||||
try {
|
||||
cachedPreferredTmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
} catch {
|
||||
cachedPreferredTmpRoot = null;
|
||||
}
|
||||
return cachedPreferredTmpRoot ?? undefined;
|
||||
}
|
||||
|
||||
function buildVolatileReplyMediaRoots(params: {
|
||||
workspaceDir: string;
|
||||
sandboxRoot?: string;
|
||||
}): string[] {
|
||||
const roots = [params.workspaceDir, params.sandboxRoot]
|
||||
.filter((root): root is string => Boolean(root))
|
||||
.map((root) => path.join(path.resolve(root), AGENT_STATE_MEDIA_DIRNAME));
|
||||
const preferredTmpRoot = resolvePreferredReplyMediaTmpRoot();
|
||||
if (preferredTmpRoot) {
|
||||
roots.push(preferredTmpRoot);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function isAllowedAbsoluteReplyMediaPath(params: {
|
||||
candidate: string;
|
||||
workspaceDir: string;
|
||||
sandboxRoot?: string;
|
||||
}): boolean {
|
||||
if (isManagedGlobalReplyMediaPath(params.candidate)) {
|
||||
return true;
|
||||
}
|
||||
return buildVolatileReplyMediaRoots(params).some((root) => isPathInside(root, params.candidate));
|
||||
}
|
||||
|
||||
function isLikelyLocalMediaSource(media: string): boolean {
|
||||
return (
|
||||
FILE_URL_RE.test(media) ||
|
||||
@@ -92,19 +47,60 @@ function getPayloadMediaList(payload: ReplyPayload): string[] {
|
||||
return resolveSendableOutboundReplyParts(payload).mediaUrls;
|
||||
}
|
||||
|
||||
function resolveReplyMediaMaxBytes(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}): number {
|
||||
const channelId = params.channel?.trim();
|
||||
const accountId = params.accountId?.trim();
|
||||
const channelCfg = channelId ? params.cfg.channels?.[channelId] : undefined;
|
||||
const channelObj =
|
||||
channelCfg && typeof channelCfg === "object"
|
||||
? (channelCfg as Record<string, unknown>)
|
||||
: undefined;
|
||||
const channelMediaMax =
|
||||
typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined;
|
||||
const accountsObj =
|
||||
channelObj?.accounts && typeof channelObj.accounts === "object"
|
||||
? (channelObj.accounts as Record<string, unknown>)
|
||||
: undefined;
|
||||
const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined;
|
||||
const accountMediaMax =
|
||||
accountCfg && typeof accountCfg === "object"
|
||||
? (accountCfg as Record<string, unknown>).mediaMaxMb
|
||||
: undefined;
|
||||
const limitMb =
|
||||
(typeof accountMediaMax === "number" ? accountMediaMax : undefined) ??
|
||||
channelMediaMax ??
|
||||
params.cfg.agents?.defaults?.mediaMaxMb;
|
||||
return typeof limitMb === "number" && Number.isFinite(limitMb) && limitMb > 0
|
||||
? Math.floor(limitMb * 1024 * 1024)
|
||||
: MEDIA_MAX_BYTES;
|
||||
}
|
||||
|
||||
export function createReplyMediaPathNormalizer(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
workspaceDir: string;
|
||||
messageProvider?: string;
|
||||
accountId?: string;
|
||||
groupId?: string;
|
||||
groupChannel?: string;
|
||||
groupSpace?: string;
|
||||
requesterSenderId?: string;
|
||||
requesterSenderName?: string;
|
||||
requesterSenderUsername?: string;
|
||||
requesterSenderE164?: string;
|
||||
}): (payload: ReplyPayload) => Promise<ReplyPayload> {
|
||||
const agentId = params.sessionKey
|
||||
? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg })
|
||||
: undefined;
|
||||
const workspaceOnly = resolveEffectiveToolFsWorkspaceOnly({
|
||||
const maxBytes = resolveReplyMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
channel: params.messageProvider,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const configuredMediaMaxBytes = resolveConfiguredMediaMaxBytes(params.cfg);
|
||||
let sandboxRootPromise: Promise<string | undefined> | undefined;
|
||||
const persistedMediaBySource = new Map<string, Promise<string>>();
|
||||
|
||||
@@ -119,35 +115,52 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
return await sandboxRootPromise;
|
||||
};
|
||||
|
||||
const persistVolatileReplyMedia = async (media: string): Promise<string> => {
|
||||
if (!path.isAbsolute(media)) {
|
||||
const resolveMediaAccessForSource = (media: string) =>
|
||||
resolveAgentScopedOutboundMediaAccess({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
workspaceDir: params.workspaceDir,
|
||||
mediaSources: [media],
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.sessionKey ? undefined : params.messageProvider,
|
||||
accountId: params.accountId,
|
||||
requesterSenderId: params.requesterSenderId,
|
||||
requesterSenderName: params.requesterSenderName,
|
||||
requesterSenderUsername: params.requesterSenderUsername,
|
||||
requesterSenderE164: params.requesterSenderE164,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
});
|
||||
|
||||
const persistLocalReplyMedia = async (media: string): Promise<string> => {
|
||||
if (!isLikelyLocalMediaSource(media)) {
|
||||
return media;
|
||||
}
|
||||
const sandboxRoot = await resolveSandboxRoot();
|
||||
const volatileRoots = buildVolatileReplyMediaRoots({
|
||||
workspaceDir: params.workspaceDir,
|
||||
sandboxRoot,
|
||||
});
|
||||
if (!volatileRoots.some((root) => isPathInside(root, media))) {
|
||||
if (path.isAbsolute(media) && isManagedGlobalReplyMediaPath(media)) {
|
||||
return media;
|
||||
}
|
||||
const cached = persistedMediaBySource.get(media);
|
||||
if (cached) {
|
||||
return await cached;
|
||||
}
|
||||
const persistPromise = saveMediaSource(media, undefined, "outbound", configuredMediaMaxBytes)
|
||||
const persistPromise = resolveOutboundAttachmentFromUrl(media, maxBytes, {
|
||||
mediaAccess: resolveMediaAccessForSource(media),
|
||||
})
|
||||
.then((saved) => saved.path)
|
||||
.catch((err) => {
|
||||
persistedMediaBySource.delete(media);
|
||||
throw err;
|
||||
});
|
||||
persistedMediaBySource.set(media, persistPromise);
|
||||
try {
|
||||
return await persistPromise;
|
||||
} catch (err) {
|
||||
logVerbose(`failed to persist volatile reply media ${media}: ${String(err)}`);
|
||||
return media;
|
||||
}
|
||||
return await persistPromise;
|
||||
};
|
||||
|
||||
const resolveWorkspaceRelativeMedia = (media: string): string => {
|
||||
const relativeWorkspacePath = toRelativeWorkspacePath(params.workspaceDir, media, {
|
||||
cwd: params.workspaceDir,
|
||||
});
|
||||
return resolvePathFromInput(relativeWorkspacePath, params.workspaceDir);
|
||||
};
|
||||
|
||||
const normalizeMediaSource = async (raw: string): Promise<string> => {
|
||||
@@ -159,61 +172,43 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
if (isPassThroughRemoteMediaSource(media)) {
|
||||
return media;
|
||||
}
|
||||
const isRelativeLocalMedia =
|
||||
isLikelyLocalMediaSource(media) &&
|
||||
!FILE_URL_RE.test(media) &&
|
||||
!media.startsWith("~") &&
|
||||
!path.isAbsolute(media) &&
|
||||
!WINDOWS_DRIVE_RE.test(media);
|
||||
const sandboxRoot = await resolveSandboxRoot();
|
||||
if (sandboxRoot) {
|
||||
let sandboxResolvedMedia: string;
|
||||
try {
|
||||
return await resolveSandboxedMediaSource({
|
||||
sandboxResolvedMedia = await resolveSandboxedMediaSource({
|
||||
media,
|
||||
sandboxRoot,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isLikelyLocalMediaSource(media) || FILE_URL_RE.test(media)) {
|
||||
throw err;
|
||||
if (FILE_URL_RE.test(media)) {
|
||||
throw new Error(
|
||||
"Host-local MEDIA file URLs are blocked in normal replies. Use a safe path or the message tool.",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (workspaceOnly) {
|
||||
throw err;
|
||||
}
|
||||
if (!path.isAbsolute(media)) {
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
}
|
||||
if (
|
||||
isAllowedAbsoluteReplyMediaPath({
|
||||
candidate: media,
|
||||
workspaceDir: params.workspaceDir,
|
||||
sandboxRoot,
|
||||
})
|
||||
) {
|
||||
return media;
|
||||
}
|
||||
throw new Error(
|
||||
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
|
||||
{ cause: err },
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
return await persistLocalReplyMedia(sandboxResolvedMedia);
|
||||
}
|
||||
if (isRelativeLocalMedia) {
|
||||
return await persistLocalReplyMedia(resolveWorkspaceRelativeMedia(media));
|
||||
}
|
||||
if (!isLikelyLocalMediaSource(media)) {
|
||||
return media;
|
||||
}
|
||||
if (FILE_URL_RE.test(media)) {
|
||||
throw new Error(
|
||||
"Absolute host-local MEDIA file URLs are blocked in normal replies. Use a safe relative path or the message tool.",
|
||||
"Host-local MEDIA file URLs are blocked in normal replies. Use a safe path or the message tool.",
|
||||
);
|
||||
}
|
||||
if (!path.isAbsolute(media)) {
|
||||
return resolvePathFromInput(media, params.workspaceDir);
|
||||
}
|
||||
if (
|
||||
isAllowedAbsoluteReplyMediaPath({
|
||||
candidate: media,
|
||||
workspaceDir: params.workspaceDir,
|
||||
sandboxRoot,
|
||||
})
|
||||
) {
|
||||
return media;
|
||||
}
|
||||
throw new Error(
|
||||
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
|
||||
);
|
||||
return await persistLocalReplyMedia(media);
|
||||
};
|
||||
|
||||
return async (payload) => {
|
||||
@@ -227,7 +222,7 @@ export function createReplyMediaPathNormalizer(params: {
|
||||
for (const media of mediaList) {
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = await persistVolatileReplyMedia(await normalizeMediaSource(media));
|
||||
normalized = await normalizeMediaSource(media);
|
||||
} catch (err) {
|
||||
logVerbose(`dropping blocked reply media ${media}: ${String(err)}`);
|
||||
continue;
|
||||
|
||||
@@ -36,6 +36,24 @@ describe("message action media helpers", () => {
|
||||
mediaAccess: {
|
||||
localRoots: ["/tmp/a"],
|
||||
},
|
||||
mediaLocalRoots: ["/tmp/a"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit any local roots for host read opt-ins", () => {
|
||||
const mediaReadFile = async () => Buffer.from("x");
|
||||
expect(
|
||||
resolveAttachmentMediaPolicy({
|
||||
mediaLocalRoots: "any",
|
||||
mediaReadFile,
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "host",
|
||||
mediaAccess: {
|
||||
readFile: mediaReadFile,
|
||||
},
|
||||
mediaLocalRoots: "any",
|
||||
mediaReadFile,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { basenameFromMediaSource } from "../../infra/local-file-access.js";
|
||||
import {
|
||||
buildOutboundMediaLoadOptions,
|
||||
resolveOutboundMediaAccess,
|
||||
resolveOutboundMediaLocalRoots,
|
||||
type OutboundMediaAccess,
|
||||
type OutboundMediaReadFile,
|
||||
} from "../../media/load-options.js";
|
||||
@@ -177,12 +178,14 @@ export type AttachmentMediaPolicy =
|
||||
| {
|
||||
mode: "host";
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[] | "any";
|
||||
mediaReadFile?: OutboundMediaReadFile;
|
||||
};
|
||||
|
||||
export function resolveAttachmentMediaPolicy(params: {
|
||||
sandboxRoot?: string;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaLocalRoots?: readonly string[] | "any";
|
||||
mediaReadFile?: OutboundMediaReadFile;
|
||||
}): AttachmentMediaPolicy {
|
||||
const sandboxRoot = params.sandboxRoot?.trim();
|
||||
@@ -192,13 +195,20 @@ export function resolveAttachmentMediaPolicy(params: {
|
||||
sandboxRoot,
|
||||
};
|
||||
}
|
||||
const explicitLocalRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots);
|
||||
return {
|
||||
mode: "host",
|
||||
mediaAccess: resolveOutboundMediaAccess({
|
||||
mediaAccess: params.mediaAccess,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
mediaReadFile: params.mediaReadFile,
|
||||
mediaLocalRoots: explicitLocalRoots === "any" ? undefined : explicitLocalRoots,
|
||||
mediaReadFile: params.mediaAccess?.readFile ? undefined : params.mediaReadFile,
|
||||
}),
|
||||
...(explicitLocalRoots !== undefined ? { mediaLocalRoots: explicitLocalRoots } : {}),
|
||||
...(params.mediaAccess?.readFile
|
||||
? {}
|
||||
: params.mediaReadFile
|
||||
? { mediaReadFile: params.mediaReadFile }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,6 +240,8 @@ function buildAttachmentMediaLoadOptions(params: {
|
||||
return buildOutboundMediaLoadOptions({
|
||||
maxBytes: params.maxBytes,
|
||||
mediaAccess: params.policy.mediaAccess,
|
||||
mediaLocalRoots: params.policy.mediaLocalRoots,
|
||||
mediaReadFile: params.policy.mediaReadFile,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ describe("runMessageAction media behavior", () => {
|
||||
const call = vi.mocked(loadWebMedia).mock.calls[0];
|
||||
expect(call?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
localRoots: "any",
|
||||
localRoots: expect.any(Array),
|
||||
readFile: expect.any(Function),
|
||||
hostReadCapability: true,
|
||||
}),
|
||||
|
||||
@@ -3,8 +3,8 @@ import { buildOutboundMediaLoadOptions, resolveOutboundMediaLocalRoots } from ".
|
||||
|
||||
describe("media load options", () => {
|
||||
function expectResolvedOutboundMediaRoots(
|
||||
mediaLocalRoots: readonly string[] | undefined,
|
||||
expectedLocalRoots: readonly string[] | undefined,
|
||||
mediaLocalRoots: readonly string[] | "any" | undefined,
|
||||
expectedLocalRoots: readonly string[] | "any" | undefined,
|
||||
) {
|
||||
expect(resolveOutboundMediaLocalRoots(mediaLocalRoots)).toEqual(expectedLocalRoots);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ describe("media load options", () => {
|
||||
{ mediaLocalRoots: undefined, expectedLocalRoots: undefined },
|
||||
{ mediaLocalRoots: [], expectedLocalRoots: undefined },
|
||||
{ mediaLocalRoots: ["/tmp/workspace"], expectedLocalRoots: ["/tmp/workspace"] },
|
||||
{ mediaLocalRoots: "any", expectedLocalRoots: "any" },
|
||||
] as const)("resolves outbound local roots %#", ({ mediaLocalRoots, expectedLocalRoots }) => {
|
||||
expectResolvedOutboundMediaRoots(mediaLocalRoots, expectedLocalRoots);
|
||||
});
|
||||
@@ -33,7 +34,52 @@ describe("media load options", () => {
|
||||
params: { maxBytes: 2048, mediaLocalRoots: undefined },
|
||||
expected: { maxBytes: 2048, localRoots: undefined },
|
||||
},
|
||||
{
|
||||
params: {
|
||||
maxBytes: 4096,
|
||||
mediaAccess: {
|
||||
localRoots: ["/tmp/workspace"],
|
||||
readFile: async () => Buffer.from("x"),
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
maxBytes: 4096,
|
||||
localRoots: ["/tmp/workspace"],
|
||||
readFile: expect.any(Function),
|
||||
hostReadCapability: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
params: {
|
||||
maxBytes: 4096,
|
||||
mediaLocalRoots: "any",
|
||||
mediaReadFile: async () => Buffer.from("x"),
|
||||
},
|
||||
expected: {
|
||||
maxBytes: 4096,
|
||||
localRoots: "any",
|
||||
readFile: expect.any(Function),
|
||||
hostReadCapability: true,
|
||||
},
|
||||
},
|
||||
] as const)("builds outbound media load options %#", ({ params, expected }) => {
|
||||
expectBuiltOutboundMediaLoadOptions(params, expected);
|
||||
});
|
||||
|
||||
it("rejects host read capability without explicit local roots", () => {
|
||||
expect(() =>
|
||||
buildOutboundMediaLoadOptions({
|
||||
maxBytes: 1024,
|
||||
mediaAccess: {
|
||||
readFile: async () => Buffer.from("x"),
|
||||
},
|
||||
}),
|
||||
).toThrow("Host media read requires explicit localRoots");
|
||||
expect(() =>
|
||||
buildOutboundMediaLoadOptions({
|
||||
maxBytes: 1024,
|
||||
mediaReadFile: async () => Buffer.from("x"),
|
||||
}),
|
||||
).toThrow("Host media read requires explicit localRoots");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export type OutboundMediaAccess = {
|
||||
export type OutboundMediaLoadParams = {
|
||||
maxBytes?: number;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaLocalRoots?: readonly string[] | "any";
|
||||
mediaReadFile?: OutboundMediaReadFile;
|
||||
optimizeImages?: boolean;
|
||||
/** Agent workspace directory for resolving relative MEDIA: paths. */
|
||||
@@ -28,8 +28,11 @@ export type OutboundMediaLoadOptions = {
|
||||
};
|
||||
|
||||
export function resolveOutboundMediaLocalRoots(
|
||||
mediaLocalRoots?: readonly string[],
|
||||
): readonly string[] | undefined {
|
||||
mediaLocalRoots?: readonly string[] | "any",
|
||||
): readonly string[] | "any" | undefined {
|
||||
if (mediaLocalRoots === "any") {
|
||||
return mediaLocalRoots;
|
||||
}
|
||||
return mediaLocalRoots && mediaLocalRoots.length > 0 ? mediaLocalRoots : undefined;
|
||||
}
|
||||
|
||||
@@ -40,9 +43,10 @@ export function resolveOutboundMediaAccess(
|
||||
mediaReadFile?: OutboundMediaReadFile;
|
||||
} = {},
|
||||
): OutboundMediaAccess | undefined {
|
||||
const localRoots = resolveOutboundMediaLocalRoots(
|
||||
const resolvedLocalRoots = resolveOutboundMediaLocalRoots(
|
||||
params.mediaAccess?.localRoots ?? params.mediaLocalRoots,
|
||||
);
|
||||
const localRoots = resolvedLocalRoots === "any" ? undefined : resolvedLocalRoots;
|
||||
const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile;
|
||||
const workspaceDir = params.mediaAccess?.workspaceDir;
|
||||
if (!localRoots && !readFile && !workspaceDir) {
|
||||
@@ -58,19 +62,30 @@ export function resolveOutboundMediaAccess(
|
||||
export function buildOutboundMediaLoadOptions(
|
||||
params: OutboundMediaLoadParams = {},
|
||||
): OutboundMediaLoadOptions {
|
||||
const mediaAccess = resolveOutboundMediaAccess(params);
|
||||
const explicitLocalRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots);
|
||||
const mediaAccess = resolveOutboundMediaAccess({
|
||||
mediaAccess: params.mediaAccess,
|
||||
mediaLocalRoots: explicitLocalRoots === "any" ? undefined : explicitLocalRoots,
|
||||
mediaReadFile: params.mediaAccess?.readFile ? undefined : params.mediaReadFile,
|
||||
});
|
||||
const workspaceDir = mediaAccess?.workspaceDir ?? params.workspaceDir;
|
||||
if (mediaAccess?.readFile) {
|
||||
const readFile = mediaAccess?.readFile ?? params.mediaReadFile;
|
||||
const localRoots = mediaAccess?.localRoots ?? explicitLocalRoots;
|
||||
if (readFile) {
|
||||
if (!localRoots) {
|
||||
throw new Error(
|
||||
'Host media read requires explicit localRoots. Pass mediaAccess.localRoots or opt in with localRoots: "any".',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
|
||||
localRoots: "any",
|
||||
readFile: mediaAccess.readFile,
|
||||
localRoots,
|
||||
readFile,
|
||||
hostReadCapability: true,
|
||||
...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}),
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
};
|
||||
}
|
||||
const localRoots = mediaAccess?.localRoots;
|
||||
return {
|
||||
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
|
||||
...(localRoots ? { localRoots } : {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { resolveAgentScopedOutboundMediaAccess } from "./read-capability.js";
|
||||
|
||||
@@ -7,6 +7,10 @@ vi.mock("../channels/plugins/index.js", () => ({
|
||||
}));
|
||||
|
||||
describe("resolveAgentScopedOutboundMediaAccess", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("preserves caller-provided workspaceDir from mediaAccess", () => {
|
||||
const result = resolveAgentScopedOutboundMediaAccess({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -49,12 +53,14 @@ describe("resolveAgentScopedOutboundMediaAccess", () => {
|
||||
const result = resolveAgentScopedOutboundMediaAccess({
|
||||
cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:ops",
|
||||
mediaSources: ["/Users/peter/Pictures/photo.png"],
|
||||
// Production call sites set messageProvider: undefined when sessionKey is present;
|
||||
// resolveGroupToolPolicy derives channel from the session key instead.
|
||||
requesterSenderId: "attacker",
|
||||
});
|
||||
|
||||
expect(result.readFile).toBeUndefined();
|
||||
expect(result.localRoots).not.toContain("/Users/peter/Pictures");
|
||||
});
|
||||
|
||||
it("keeps host reads enabled when sender group policy allows read", () => {
|
||||
@@ -80,10 +86,12 @@ describe("resolveAgentScopedOutboundMediaAccess", () => {
|
||||
const result = resolveAgentScopedOutboundMediaAccess({
|
||||
cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:ops",
|
||||
mediaSources: ["/Users/peter/Pictures/photo.png"],
|
||||
requesterSenderId: "trusted-user",
|
||||
});
|
||||
|
||||
expect(result.readFile).toBeTypeOf("function");
|
||||
expect(result.localRoots).toContain("/Users/peter/Pictures");
|
||||
});
|
||||
|
||||
it("keeps host reads enabled when no group policy applies", () => {
|
||||
|
||||
@@ -8,7 +8,10 @@ import type { OpenClawConfig } from "../config/types.js";
|
||||
import { readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js";
|
||||
import { getAgentScopedMediaLocalRootsForSources } from "./local-roots.js";
|
||||
import {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRootsForSources,
|
||||
} from "./local-roots.js";
|
||||
|
||||
type OutboundHostMediaPolicyContext = {
|
||||
sessionKey?: string;
|
||||
@@ -87,13 +90,16 @@ export function resolveAgentScopedOutboundMediaAccess(
|
||||
mediaReadFile?: OutboundMediaReadFile;
|
||||
} & OutboundHostMediaPolicyContext,
|
||||
): OutboundMediaAccess {
|
||||
const hostMediaReadAllowed = isAgentScopedHostMediaReadAllowed(params);
|
||||
const localRoots =
|
||||
params.mediaAccess?.localRoots ??
|
||||
getAgentScopedMediaLocalRootsForSources({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
mediaSources: params.mediaSources,
|
||||
});
|
||||
(hostMediaReadAllowed
|
||||
? getAgentScopedMediaLocalRootsForSources({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
mediaSources: params.mediaSources,
|
||||
})
|
||||
: getAgentScopedMediaLocalRoots(params.cfg, params.agentId));
|
||||
const resolvedWorkspaceDir =
|
||||
params.workspaceDir ??
|
||||
params.mediaAccess?.workspaceDir ??
|
||||
@@ -101,21 +107,23 @@ export function resolveAgentScopedOutboundMediaAccess(
|
||||
const readFile =
|
||||
params.mediaAccess?.readFile ??
|
||||
params.mediaReadFile ??
|
||||
createAgentScopedHostMediaReadFile({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: resolvedWorkspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.messageProvider,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
requesterSenderId: params.requesterSenderId,
|
||||
requesterSenderName: params.requesterSenderName,
|
||||
requesterSenderUsername: params.requesterSenderUsername,
|
||||
requesterSenderE164: params.requesterSenderE164,
|
||||
});
|
||||
(hostMediaReadAllowed
|
||||
? createAgentScopedHostMediaReadFile({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
workspaceDir: resolvedWorkspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.messageProvider,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
requesterSenderId: params.requesterSenderId,
|
||||
requesterSenderName: params.requesterSenderName,
|
||||
requesterSenderUsername: params.requesterSenderUsername,
|
||||
requesterSenderE164: params.requesterSenderE164,
|
||||
})
|
||||
: undefined);
|
||||
return {
|
||||
...(localRoots?.length ? { localRoots } : {}),
|
||||
...(readFile ? { readFile } : {}),
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("loadOutboundMediaFromUrl", () => {
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith("https://example.com/image.png", {});
|
||||
});
|
||||
|
||||
it("prefers host read capability over local roots when provided", async () => {
|
||||
it("keeps local roots when host read capability is provided", async () => {
|
||||
const mediaReadFile = vi.fn(async () => Buffer.from("x"));
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("x"),
|
||||
@@ -65,6 +65,37 @@ describe("loadOutboundMediaFromUrl", () => {
|
||||
mediaReadFile,
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith("/Users/peter/Pictures/image.png", {
|
||||
maxBytes: 2048,
|
||||
localRoots: ["/tmp/workspace-agent"],
|
||||
readFile: mediaReadFile,
|
||||
hostReadCapability: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects host read capability without explicit local roots", async () => {
|
||||
await expect(
|
||||
loadOutboundMediaFromUrl("/Users/peter/Pictures/image.png", {
|
||||
maxBytes: 2048,
|
||||
mediaReadFile: async () => Buffer.from("x"),
|
||||
}),
|
||||
).rejects.toThrow("Host media read requires explicit localRoots");
|
||||
});
|
||||
|
||||
it("allows explicit any opt-in for host read capability", async () => {
|
||||
const mediaReadFile = vi.fn(async () => Buffer.from("x"));
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("x"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await loadOutboundMediaFromUrl("/Users/peter/Pictures/image.png", {
|
||||
maxBytes: 2048,
|
||||
mediaLocalRoots: "any",
|
||||
mediaReadFile,
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith("/Users/peter/Pictures/image.png", {
|
||||
maxBytes: 2048,
|
||||
localRoots: "any",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { loadWebMedia } from "./web-media.js";
|
||||
export type OutboundMediaLoadOptions = {
|
||||
maxBytes?: number;
|
||||
mediaAccess?: OutboundMediaAccess;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaLocalRoots?: readonly string[] | "any";
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user