fix: allow MEDIA local paths with spaces

This commit is contained in:
Peter Steinberger
2026-01-22 07:51:04 +00:00
parent 230211fe26
commit e0c19607b7
4 changed files with 78 additions and 7 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### Fixes
- Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies.
- Config: avoid stack traces for invalid configs and log the config path.
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)

View File

@@ -247,7 +247,7 @@ export async function runPreparedReply(
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
const mediaNote = buildInboundMediaNote(ctx);
const mediaReplyHint = mediaNote
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body."
: undefined;
let prefixedCommandBody = mediaNote
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()

View File

@@ -9,6 +9,24 @@ describe("splitMediaFromOutput", () => {
expect(result.text).toBe("Hello world");
});
it("captures media paths with spaces", () => {
const result = splitMediaFromOutput("MEDIA:/Users/pete/My File.png");
expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]);
expect(result.text).toBe("");
});
it("captures quoted media paths with spaces", () => {
const result = splitMediaFromOutput('MEDIA:"/Users/pete/My File.png"');
expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]);
expect(result.text).toBe("");
});
it("captures tilde media paths with spaces", () => {
const result = splitMediaFromOutput("MEDIA:~/Pictures/My File.png");
expect(result.mediaUrls).toEqual(["~/Pictures/My File.png"]);
expect(result.text).toBe("");
});
it("keeps audio_as_voice detection stable across calls", () => {
const input = "Hello [[audio_as_voice]]";
const first = splitMediaFromOutput(input);

View File

@@ -14,11 +14,26 @@ function cleanCandidate(raw: string) {
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
}
function isValidMedia(candidate: string) {
function isValidMedia(candidate: string, opts?: { allowSpaces?: boolean }) {
if (!candidate) return false;
if (candidate.length > 1024) return false;
if (/\s/.test(candidate)) return false;
return /^https?:\/\//i.test(candidate) || candidate.startsWith("/") || candidate.startsWith("./");
if (candidate.length > 4096) return false;
if (!opts?.allowSpaces && /\s/.test(candidate)) return false;
if (/^https?:\/\//i.test(candidate)) return true;
if (candidate.startsWith("/")) return true;
if (candidate.startsWith("./")) return true;
if (candidate.startsWith("../")) return true;
if (candidate.startsWith("~")) return true;
return false;
}
function unwrapQuoted(value: string): string | undefined {
const trimmed = value.trim();
if (trimmed.length < 2) return undefined;
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if (first !== last) return undefined;
if (first !== `"` && first !== "'" && first !== "`") return undefined;
return trimmed.slice(1, -1).trim();
}
// Check if a character offset is inside any fenced code block
@@ -73,18 +88,55 @@ export function splitMediaFromOutput(raw: string): {
pieces.push(line.slice(cursor, start));
const payload = match[1];
const parts = payload.split(/\s+/).filter(Boolean);
const unwrapped = unwrapQuoted(payload);
const payloadValue = unwrapped ?? payload;
const parts = unwrapped ? [unwrapped] : payload.split(/\s+/).filter(Boolean);
const mediaStartIndex = media.length;
let validCount = 0;
const invalidParts: string[] = [];
for (const part of parts) {
const candidate = normalizeMediaSource(cleanCandidate(part));
if (isValidMedia(candidate)) {
if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) {
media.push(candidate);
hasValidMedia = true;
validCount += 1;
} else {
invalidParts.push(part);
}
}
const trimmedPayload = payloadValue.trim();
const looksLikeLocalPath =
trimmedPayload.startsWith("/") ||
trimmedPayload.startsWith("./") ||
trimmedPayload.startsWith("../") ||
trimmedPayload.startsWith("~") ||
trimmedPayload.startsWith("file://");
if (
!unwrapped &&
validCount === 1 &&
invalidParts.length > 0 &&
/\s/.test(payloadValue) &&
looksLikeLocalPath
) {
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
if (isValidMedia(fallback, { allowSpaces: true })) {
media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback);
hasValidMedia = true;
validCount = 1;
invalidParts.length = 0;
}
}
if (!hasValidMedia) {
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
if (isValidMedia(fallback, { allowSpaces: true })) {
media.push(fallback);
hasValidMedia = true;
invalidParts.length = 0;
}
}
if (hasValidMedia && invalidParts.length > 0) {
pieces.push(invalidParts.join(" "));
}