fix: support home-relative media paths

This commit is contained in:
Peter Steinberger
2026-05-02 22:23:21 +01:00
parent 24d0562d7d
commit 3312ce5acb
7 changed files with 70 additions and 9 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,
});
});
});

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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");