Skip to content

Commit ca7349b

Browse files
committed
fix(media): normalize cross-platform media paths
1 parent dd4c68b commit ca7349b

20 files changed

Lines changed: 246 additions & 38 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ Docs: https://docs.openclaw.ai
2323
- Telegram/WhatsApp: keep Telegram same-chat replies ordered behind active no-delay turns without blocking WhatsApp follow-up message dispatch.
2424
- Codex migration: avoid duplicate cached plugin bundle warnings when app-server plugin inventory is available.
2525
- Agents: suppress aborted embedded assistant partials, reasoning text, reply directives, and stale prior replies before user-facing delivery while preserving clean timeout/error payloads. Fixes #48241. Thanks @BunsDev, @andyliu, and @yassinebkr.
26+
- Agents: allow dot-dot-prefixed filenames such as `..file.txt` inside workspace and sandbox path policy while still rejecting real parent traversal.
27+
- Native image input: detect Windows drive image paths in plain prompts so `C:\...\screenshot.png` references are not missed.
28+
- Media: normalize Windows-style filename hints before staging attachments, remote media, audio transcodes, and saved-media display names, so POSIX hosts do not preserve drive or directory text in generated filenames.
29+
- Media references: resolve first-level inbound media files whose IDs start with dots instead of treating names like `..photo.png` as parent traversal.
2630
- iOS/chat: resize PhotosPicker image attachments to capped JPEGs before staging and sending, stripping source metadata and keeping oversized camera photos under the chat upload budget. Fixes #68524. Thanks @BunsDev.
2731
- Control UI: keep shared form, config, and usage text-entry controls at 16px on touch-primary devices while preserving chat composer input sizing, so iOS Safari no longer auto-zooms focused fields. Fixes #64651; carries forward #64673. Thanks @NianJiuZst and @BunsDev.
2832
- Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.

src/agents/path-policy.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,17 @@ describe("toRelativeWorkspacePath (windows semantics)", () => {
3636
}
3737
});
3838
});
39+
40+
describe("toRelativeWorkspacePath", () => {
41+
it("accepts dot-dot-prefixed filenames inside the workspace", () => {
42+
expect(toRelativeWorkspacePath("/workspace/root", "/workspace/root/..file.txt")).toBe(
43+
"..file.txt",
44+
);
45+
});
46+
47+
it("rejects parent directory traversal outside the workspace", () => {
48+
expect(() => toRelativeWorkspacePath("/workspace/root", "/workspace/root/../file.txt")).toThrow(
49+
"Path escapes workspace root",
50+
);
51+
});
52+
});

src/agents/path-policy.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ function validateRelativePathWithinBoundary(params: {
3636
candidate: params.candidate,
3737
});
3838
}
39-
if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) {
39+
if (
40+
params.relativePath === ".." ||
41+
params.relativePath.startsWith("../") ||
42+
params.relativePath.startsWith("..\\") ||
43+
params.isAbsolutePath(params.relativePath)
44+
) {
4045
throwPathEscapesBoundary({
4146
options: params.options,
4247
rootResolved: params.rootResolved,

src/agents/pi-embedded-runner/run/images.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4+
import { pathToFileURL } from "node:url";
45
import { describe, expect, it, vi } from "vitest";
56
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
67
import { createUnsafeMountedSandbox } from "../../test-helpers/unsafe-mounted-sandbox.js";
@@ -103,8 +104,10 @@ describe("detectImageReferences", () => {
103104
expect(detectImageReferences("[Image: source: /tmp/second.jpg]")).toStrictEqual([
104105
{ raw: "/tmp/second.jpg", type: "path", resolved: "/tmp/second.jpg" },
105106
]);
106-
expect(detectImageReferences("See file:///tmp/third.webp")).toStrictEqual([
107-
{ raw: "file:///tmp/third.webp", type: "path", resolved: "/tmp/third.webp" },
107+
const thirdPath = path.join(os.tmpdir(), "third.webp");
108+
const thirdUrl = pathToFileURL(thirdPath).href;
109+
expect(detectImageReferences(`See ${thirdUrl}`)).toStrictEqual([
110+
{ raw: thirdUrl, type: "path", resolved: thirdPath },
108111
]);
109112
expect(detectImageReferences("See ./fourth.jpeg")).toStrictEqual([
110113
{ raw: "./fourth.jpeg", type: "path", resolved: "./fourth.jpeg" },
@@ -192,6 +195,18 @@ describe("detectImageReferences", () => {
192195
});
193196
});
194197

198+
it("detects Windows drive image paths in plain prompts", () => {
199+
const ref = expectSingleImageReference(
200+
String.raw`Look at C:\Users\Ada\Pictures\screenshot.png`,
201+
);
202+
203+
expect(ref).toStrictEqual({
204+
raw: String.raw`C:\Users\Ada\Pictures\screenshot.png`,
205+
type: "path",
206+
resolved: String.raw`C:\Users\Ada\Pictures\screenshot.png`,
207+
});
208+
});
209+
195210
it("detects [Image: source: ...] format from messaging systems", () => {
196211
const ref = expectSingleImageReference(`What does this image show?
197212
[Image: source: /Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg]`);

src/agents/pi-embedded-runner/run/images.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@ const MEDIA_ATTACHED_PATH_REGEX_SOURCE =
4040
const MESSAGE_IMAGE_REGEX_SOURCE =
4141
"\\[Image:\\s*source:\\s*([^\\]]+\\.(?:" + IMAGE_EXTENSION_PATTERN + "))\\]";
4242
const FILE_URL_REGEX_SOURCE = "file://[^\\s<>\"'`\\]]+\\.(?:" + IMAGE_EXTENSION_PATTERN + ")";
43+
const WINDOWS_DRIVE_PATH_REGEX_SOURCE =
44+
"(?:^|\\s|[\"'`(])([A-Za-z]:[\\\\/][^\\s\"'`()\\[\\]]*\\.(?:" + IMAGE_EXTENSION_PATTERN + "))";
4345
const PATH_REGEX_SOURCE =
4446
"(?:^|\\s|[\"'`(])((\\.\\.?/|[~/])[^\\s\"'`()\\[\\]]*\\.(?:" + IMAGE_EXTENSION_PATTERN + "))";
4547
const MEDIA_ATTACHED_PATTERN = /\[media attached(?:\s+\d+\/\d+)?:\s*([^\]]+)\]/gi;
4648
const MEDIA_ATTACHED_PATH_PATTERN = new RegExp(MEDIA_ATTACHED_PATH_REGEX_SOURCE, "i");
4749
const MESSAGE_IMAGE_PATTERN = new RegExp(MESSAGE_IMAGE_REGEX_SOURCE, "gi");
4850
const FILE_URL_PATTERN = new RegExp(FILE_URL_REGEX_SOURCE, "gi");
51+
const WINDOWS_DRIVE_PATH_PATTERN = new RegExp(WINDOWS_DRIVE_PATH_REGEX_SOURCE, "gi");
4952
const PATH_PATTERN = new RegExp(PATH_REGEX_SOURCE, "gi");
5053

5154
/**
@@ -265,6 +268,7 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
265268
MEDIA_ATTACHED_PATTERN.lastIndex = 0;
266269
MESSAGE_IMAGE_PATTERN.lastIndex = 0;
267270
FILE_URL_PATTERN.lastIndex = 0;
271+
WINDOWS_DRIVE_PATH_PATTERN.lastIndex = 0;
268272
PATH_PATTERN.lastIndex = 0;
269273
let match: RegExpExecArray | null;
270274
while ((match = MEDIA_ATTACHED_PATTERN.exec(prompt)) !== null) {
@@ -326,6 +330,13 @@ export function detectImageReferences(prompt: string): DetectedImageRef[] {
326330
}
327331
}
328332

333+
// Pattern for Windows drive paths.
334+
while ((match = WINDOWS_DRIVE_PATH_PATTERN.exec(prompt)) !== null) {
335+
if (match[1]) {
336+
addPathRef(match[1]);
337+
}
338+
}
339+
329340
// Pattern for file paths (absolute, relative, or home)
330341
// Matches:
331342
// - /absolute/path/to/file.ext (including paths with special chars like Messages/Attachments)

src/agents/sandbox-paths.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,16 @@ describe("resolveSandboxedMediaSource", () => {
167167
});
168168
});
169169

170+
it("allows dot-dot-prefixed filenames inside the sandbox root", async () => {
171+
await withSandboxRoot(async (sandboxDir) => {
172+
const result = await resolveSandboxedMediaSource({
173+
media: "./..image.png",
174+
sandboxRoot: sandboxDir,
175+
});
176+
expect(result).toBe(path.join(sandboxDir, "..image.png"));
177+
});
178+
});
179+
170180
it("maps container /workspace absolute paths into sandbox root", async () => {
171181
await withSandboxRoot(async (sandboxDir) => {
172182
const result = await resolveSandboxedMediaSource({

src/agents/sandbox-paths.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ export function resolveSandboxPath(params: { filePath: string; cwd: string; root
6868
if (!relative || relative === "") {
6969
return { resolved, relative: "" };
7070
}
71-
if (relative.startsWith("..") || path.isAbsolute(relative) || isWindowsDrivePath(relative)) {
71+
if (
72+
relative === ".." ||
73+
relative.startsWith("../") ||
74+
relative.startsWith("..\\") ||
75+
path.isAbsolute(relative) ||
76+
isWindowsDrivePath(relative)
77+
) {
7278
throw new Error(`Path escapes sandbox root (${shortPath(rootResolved)}): ${params.filePath}`);
7379
}
7480
return { resolved, relative };

src/infra/outbound/message-action-params.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,23 @@ describe("message action media helpers", () => {
375375
expect(fileArgs.filename).toBe("report.pdf");
376376
});
377377

378+
it("uses only the leaf filename from Windows-style attachment hints", async () => {
379+
const args: Record<string, unknown> = {
380+
fileUrl: String.raw`C:\Users\Ada\Downloads\report.pdf`,
381+
};
382+
383+
await hydrateAttachmentParamsForAction({
384+
cfg,
385+
channel: "workspace",
386+
args,
387+
action: "sendAttachment",
388+
dryRun: true,
389+
mediaPolicy: { mode: "host" },
390+
});
391+
392+
expect(args.filename).toBe("report.pdf");
393+
});
394+
378395
it("falls back to extension-based attachment names for remote-host file URLs", async () => {
379396
const args: Record<string, unknown> = {
380397
media: "file://attacker/share/photo.png",

src/infra/outbound/message-action-params.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
66
import { root } from "../../infra/fs-safe.js";
77
import { basenameFromMediaSource } from "../../infra/local-file-access.js";
88
import { resolveChannelAccountMediaMaxMb } from "../../media/configured-max-bytes.js";
9+
import { basenameFromAnyPath } from "../../media/file-name.js";
910
import {
1011
buildOutboundMediaLoadOptions,
1112
resolveOutboundMediaAccess,
@@ -131,8 +132,9 @@ function inferAttachmentFilename(params: {
131132
const mediaHint = params.mediaHint?.trim();
132133
if (mediaHint) {
133134
const base = basenameFromMediaSource(mediaHint);
134-
if (base) {
135-
return base;
135+
const safeBase = base ? basenameFromAnyPath(base) : undefined;
136+
if (safeBase) {
137+
return safeBase;
136138
}
137139
}
138140
const ext = params.contentType ? extensionForMime(params.contentType) : undefined;

src/media/audio-transcode.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,26 @@ describe("transcodeAudioBufferToOpus", () => {
133133
expect(capturedInputPath?.startsWith(tempRoot)).toBe(true);
134134
expect(capturedOutputPath ? existsSync(capturedOutputPath) : true).toBe(false);
135135
});
136+
137+
it("preserves Windows-style output filename leaves on POSIX hosts", async () => {
138+
let capturedOutputPath: string | undefined;
139+
runFfmpegMock.mockImplementationOnce(async (args: string[]) => {
140+
capturedOutputPath = args.at(-1);
141+
const outputPath = capturedOutputPath;
142+
if (!outputPath) {
143+
throw new Error("missing ffmpeg output path");
144+
}
145+
expect(path.basename(outputPath)).toContain("reply.opus");
146+
await import("node:fs/promises").then((fs) =>
147+
fs.writeFile(outputPath, Buffer.from("opus-output")),
148+
);
149+
});
150+
151+
await transcodeAudioBufferToOpus({
152+
audioBuffer: Buffer.from("source"),
153+
outputFileName: String.raw`C:\Users\Ada\Downloads\reply.opus`,
154+
});
155+
156+
expect(capturedOutputPath ? existsSync(capturedOutputPath) : true).toBe(false);
157+
});
136158
});

0 commit comments

Comments
 (0)