From 3312ce5acba9175e45d19b091c3d456ceea648f2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 22:23:21 +0100 Subject: [PATCH] fix: support home-relative media paths --- CHANGELOG.md | 1 + docs/reference/rich-output-protocol.md | 4 +++ docs/start/openclaw.md | 1 + .../reply/reply-media-paths.test.ts | 34 +++++++++++++++++++ src/media/parse.test.ts | 8 +++-- src/media/parse.ts | 17 ++++++---- src/media/web-media.test.ts | 14 ++++++++ 7 files changed, 70 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c590916d51f..ef4e7fe4aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Plugins/externalization: keep ACPX, Google Chat, and LINE publishable plugin dist trees out of the core npm package file list. - Plugins/ClawHub: explain unavailable explicit ClawHub ClawPack artifact downloads with a temporary npm install hint while ClawHub artifact routing rolls out. Thanks @vincentkoc. +- Media: accept home-relative `MEDIA:~/...` attachment paths while preserving existing file-read policy, traversal checks, and media type validation. Fixes #73796. Thanks @fabkury. - Onboarding/search: install official external web-search plugins such as Brave before saving provider config, and make doctor repair reconcile selected external search providers whose npm payload is missing. Thanks @vincentkoc. - Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins, keep unpublished ACPX/Google Chat/LINE bundled, and make missing-plugin repair honor npm-first metadata while ClawHub pack files roll out. Thanks @vincentkoc. - Plugins/update: detect tracked plugin install records whose package directories disappeared during `openclaw update`, reinstall them before normal plugin updates, and fail the update if any install record still points at missing disk payloads. diff --git a/docs/reference/rich-output-protocol.md b/docs/reference/rich-output-protocol.md index f31dd569f66..6deef4519f3 100644 --- a/docs/reference/rich-output-protocol.md +++ b/docs/reference/rich-output-protocol.md @@ -17,6 +17,10 @@ Remote `MEDIA:` attachments must be public `https:` URLs. Plain `http:`, loopback, link-local, private, and internal hostnames are ignored as attachment directives; server-side media fetchers still enforce their own network guards. +Local `MEDIA:` attachments can use absolute paths, workspace-relative paths, or +home-relative `~/` paths. They still pass through the agent file-read policy and +media type checks before delivery. + Plain Markdown image syntax stays text by default. Channels that intentionally map Markdown image replies to media attachments opt in at their outbound adapter; Telegram does this so `![alt](url)` can still become a media reply. diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index ca1c847994a..02948b0e9be 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -203,6 +203,7 @@ Local-path behavior follows the same file-read trust model as the agent: - If `tools.fs.workspaceOnly` is `true`, outbound `MEDIA:` local paths stay restricted to the OpenClaw temp root, the media cache, agent workspace paths, and sandbox-generated files. - If `tools.fs.workspaceOnly` is `false`, outbound `MEDIA:` can use host-local files the agent is already allowed to read. +- Local paths can be absolute, workspace-relative, or home-relative with `~/`. - Host-local sends still only allow media and safe document types (images, audio, video, PDF, and Office documents). Plain text and secret-like files are not treated as sendable media. That means generated images/files outside the workspace can now send when your fs policy already allows those reads, without reopening arbitrary host-text attachment exfiltration. diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts index 600225990f3..9a55a82c797 100644 --- a/src/auto-reply/reply/reply-media-paths.test.ts +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -486,4 +486,38 @@ describe("createReplyMediaPathNormalizer", () => { groupSpace: undefined, }); }); + + it("passes home-relative local media sources into shared outbound media access", async () => { + const homeRelativePath = "~/Pictures/chart.png"; + const normalize = createReplyMediaPathNormalizer({ + cfg: { tools: { fs: { workspaceOnly: false } } }, + sessionKey: "session-key", + workspaceDir: "/tmp/agent-workspace", + }); + + const result = await normalize({ + mediaUrls: [homeRelativePath], + }); + + expect(result).toMatchObject({ + mediaUrl: "/tmp/outbound-media/chart.png", + mediaUrls: ["/tmp/outbound-media/chart.png"], + }); + expect(resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith({ + cfg: { tools: { fs: { workspaceOnly: false } } }, + agentId: expect.any(String), + workspaceDir: "/tmp/agent-workspace", + mediaSources: [homeRelativePath], + sessionKey: "session-key", + messageProvider: undefined, + accountId: undefined, + requesterSenderId: undefined, + requesterSenderName: undefined, + requesterSenderUsername: undefined, + requesterSenderE164: undefined, + groupId: undefined, + groupChannel: undefined, + groupSpace: undefined, + }); + }); }); diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index e99eb935c40..04847cd0e78 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -51,6 +51,8 @@ describe("splitMediaFromOutput", () => { ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], ["./screenshot.png", " MEDIA:./screenshot.png"], + ["~/Pictures/My File.png", "MEDIA:~/Pictures/My File.png"], + ["~/.openclaw/media/browser/snap.png", "MEDIA:~/.openclaw/media/browser/snap.png"], ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"], ["/tmp/tts-fAJy8C/voice-1770246885083.opus", "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"], ["image.png", "MEDIA:image.png"], @@ -70,10 +72,10 @@ describe("splitMediaFromOutput", () => { it.each([ "MEDIA:../../../etc/passwd", "MEDIA:../../.env", - "MEDIA:~/.ssh/id_rsa", - "MEDIA:~/Pictures/My File.png", + "MEDIA:~user/Pictures/My File.png", + "MEDIA:~/Pictures/../../.ssh/id_rsa", "MEDIA:./foo/../../../etc/shadow", - ] as const)("rejects traversal and home-dir path: %s", (input) => { + ] as const)("rejects traversal and unsupported home-dir path: %s", (input) => { expectRejectedMediaPathCase(input); }); diff --git a/src/media/parse.ts b/src/media/parse.ts index eb79586f751..c3f8a02fdf9 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -49,11 +49,15 @@ const HAS_FILE_EXT = /\.\w{1,10}$/; // Matches ".." as a standalone path segment (start, middle, or end). const TRAVERSAL_SEGMENT_RE = /(?:^|[/\\])\.\.(?:[/\\]|$)/; -function hasTraversalOrHomeDirPrefix(candidate: string): boolean { +function isSupportedHomeRelativePath(candidate: string): boolean { + return candidate.startsWith("~/") || candidate.startsWith("~\\"); +} + +function hasTraversalOrUnsupportedHomeDirPrefix(candidate: string): boolean { return ( candidate.startsWith("../") || candidate === ".." || - candidate.startsWith("~") || + (candidate.startsWith("~") && !isSupportedHomeRelativePath(candidate)) || TRAVERSAL_SEGMENT_RE.test(candidate) ); } @@ -73,14 +77,15 @@ function looksLikeLocalFilePath(candidate: string): boolean { } // Recognize safe local file path patterns for media approval, rejecting -// traversal and home-dir paths so they never reach downstream load/send logic. +// traversal and unsupported home-dir paths so they never reach downstream load/send logic. function isLikelyLocalPath(candidate: string): boolean { - if (hasTraversalOrHomeDirPrefix(candidate)) { + if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) { return false; } return ( candidate.startsWith("/") || candidate.startsWith("./") || + isSupportedHomeRelativePath(candidate) || WINDOWS_DRIVE_RE.test(candidate) || candidate.startsWith("\\\\") || (!SCHEME_RE.test(candidate) && (candidate.includes("/") || candidate.includes("\\"))) @@ -171,9 +176,9 @@ function isValidMedia( return true; } - // Hard reject traversal/home-dir patterns before the bare-filename fallback + // Hard reject traversal/unsupported home-dir patterns before the bare-filename fallback // to prevent path traversal bypasses (e.g. "../../.env" matching HAS_FILE_EXT). - if (hasTraversalOrHomeDirPrefix(candidate)) { + if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) { return false; } diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index 3b4926bd346..146ae374b65 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -173,6 +173,20 @@ describe("loadWebMedia", () => { expect(result.buffer.length).toBeGreaterThan(0); }); + it("resolves home-relative local media paths through allowed local roots", async () => { + vi.stubEnv("OPENCLAW_HOME", fixtureRoot); + try { + const result = await loadWebMedia("~/workspace/chart.png", { + maxBytes: 1024 * 1024, + localRoots: [workspaceDir], + }); + expect(result.kind).toBe("image"); + expect(result.buffer.length).toBeGreaterThan(0); + } finally { + vi.unstubAllEnvs(); + } + }); + it("rejects host-read text files outside local roots", async () => { const secretFile = path.join(fixtureRoot, "secret.txt"); await fs.writeFile(secretFile, "secret", "utf8");