mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix: support home-relative media paths
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 `` can still become a media reply.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user