fix(media): accept mixed-case local file URLs (#121226)

This commit is contained in:
Peter Steinberger
2026-08-09 16:40:55 -07:00
committed by GitHub
parent 727e48240b
commit 3bdfd60caa
12 changed files with 241 additions and 6 deletions
@@ -164,6 +164,7 @@ describe("msteams media-helpers", () => {
it("returns true for file:// URLs", () => {
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
expect(isLocalPath("FILE:///C:/Users/test/image.png")).toBe(true);
});
it("returns true for absolute paths", () => {
+1 -1
View File
@@ -81,7 +81,7 @@ export async function extractFilename(url: string): Promise<string> {
* Check if a URL refers to a local file path.
*/
export function isLocalPath(url: string): boolean {
if (url.startsWith("file://") || url.startsWith("/") || url.startsWith("~")) {
if (/^file:\/\//iu.test(url) || url.startsWith("/") || url.startsWith("~")) {
return true;
}
+33
View File
@@ -1,6 +1,7 @@
// Msteams tests cover messenger plugin behavior.
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime";
import { SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
@@ -399,6 +400,38 @@ describe("msteams messenger", () => {
expect(sendActivity).not.toHaveBeenCalled();
});
it("loads uppercase file URLs before sending personal images", async () => {
const tmpDir = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "msteams-file-url-"),
);
const localFile = path.join(tmpDir, "café image.png");
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
"base64",
);
await writeFile(localFile, png);
try {
const mediaUrl = pathToFileURL(localFile).href.replace(/^file:/u, "FILE:");
const activity = await buildActivity(
{ mediaUrl },
{
...baseRef,
conversation: { ...baseRef.conversation, conversationType: "personal" },
},
);
const attachment = (activity.attachments as Array<Record<string, unknown>>)[0];
expect(attachment).toMatchObject({
name: "café image.png",
contentType: "image/png",
contentUrl: `data:image/png;base64,${png.toString("base64")}`,
});
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("does not claim no dispatch after an earlier batch message was sent", async () => {
const sendActivity = vi.fn(async () => ({ id: "sent-first" }));
const missingPath = path.join(resolvePreferredOpenClawTmpDir(), "missing-second-file.txt");
+1 -1
View File
@@ -1954,7 +1954,7 @@
"test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts",
"test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts",
"test:watch": "node --import tsx scripts/test-projects.mts --watch",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
+4
View File
@@ -55,6 +55,8 @@ const ANDROID_NATIVE_RE = /^(apps\/android\/|apps\/shared\/)/;
const NODE_SCOPE_RE =
/^(src\/|test\/|extensions\/|packages\/|scripts\/|ui\/|\.github\/|openclaw\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig.*\.json$|vitest.*\.ts$|tsdown\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.jsonc$)/;
const WINDOWS_SQLITE_SCOPE_RE = /^src\/(?:state\/|.*sqlite.*\.ts$)/;
const WINDOWS_FILE_URL_SCOPE_RE =
/^(?:src\/media\/(?:local-media-path(?:\.windows\.test)?|local-roots(?:\.test)?|web-media(?:\.file-url\.windows\.test)?)|src\/channels\/inbound-event\/media(?:\.test)?|src\/gateway\/managed-image-attachments(?:\.test)?|extensions\/msteams\/src\/(?:media-helpers|messenger)(?:\.test)?)\.ts$/;
const WINDOWS_SCOPE_RE =
/^(extensions\/mxc\/|src\/agents\/(?:bash-tools\.exec-script-(?:preflight|target)|bash-tools\.exec\.script-preflight\.test)\.ts$|src\/config\/sessions\/(?:session-accessor\.sqlite-archive(?:\.worker(?:\.test)?)?|store\.session-lifecycle-mutation\.test)\.ts$|src\/process\/|src\/infra\/(?:(?:exec-allowlist-pattern|fs-safe-remove)(?:\.test)?|ssh-client(?:\.windows\.test)?|update-managed-service-handoff(?:-(?:command|lifecycle)\.test)?|windows-install-roots)\.ts$|src\/shared\/(?:import-specifier|runtime-import)(?:\.test)?\.ts$|src\/test-utils\/openclaw-test-state(?:\.test)?\.ts$|scripts\/(?:android-(?:app-i18n|pin-version)\.ts|ci-run-timings\.mjs|e2e\/lib\/package-compat\.mjs|generate-bundled-channel-config-metadata\.ts|install\.ps1|openclaw-cross-os-release-checks\.ts|plan-release-workflow-matrix\.mjs|run-additional-boundary-checks\.mts|verify-docker-attestations\.mjs|github\/run-openclaw-cross-os-release-checks\.sh|(?:npm-runner|pnpm-runner|ui|vitest-process-group)\.(?:mjs|mts|js)|lib\/(?:direct-run\.(?:mjs|mts)|format-generated-module\.mts|cross-os-release-checks\/[^/]+\.ts))$|test\/scripts\/(?:direct-run-entrypoints|format-generated-module|install-ps1|npm-runner|openclaw-cross-os-release-workflow|pnpm-runner|ui|vitest-process-group)\.test\.ts$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|\.github\/workflows\/(?:ci|openclaw-cross-os-release-checks-reusable)\.yml$|\.github\/actions\/setup-node-env\/action\.yml$|\.github\/actions\/setup-pnpm-store-cache\/action\.yml$)/;
const WINDOWS_TEST_SCOPE_RE =
@@ -170,10 +172,12 @@ export function detectChangedScope(changedPaths) {
if (
(WINDOWS_SCOPE_RE.test(path) ||
WINDOWS_SQLITE_SCOPE_RE.test(path) ||
WINDOWS_FILE_URL_SCOPE_RE.test(path) ||
WINDOWS_SECRETREF_SCOPE_RE.test(path) ||
WINDOWS_DAEMON_SCOPE_RE.test(path)) &&
(!facts.isTestOnly ||
WINDOWS_TEST_SCOPE_RE.test(path) ||
WINDOWS_FILE_URL_SCOPE_RE.test(path) ||
WINDOWS_SECRETREF_TEST_SCOPE_RE.test(path) ||
WINDOWS_DAEMON_SCOPE_RE.test(path))
) {
+3 -2
View File
@@ -1,4 +1,5 @@
// Inbound event media tests cover channel media attachment normalization.
import path from "node:path";
import { kindFromMime } from "@openclaw/media-core/mime";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -340,8 +341,8 @@ describe("channel inbound media facts", () => {
]);
expect(probeMediaFilesWithinBudget).toHaveBeenCalledWith(
[
{ filePath: "/tmp/voice.ogg", kind: "audio" },
{ filePath: "/tmp/clip.mp4", kind: "video" },
{ filePath: path.resolve("/tmp/voice.ogg"), kind: "audio" },
{ filePath: path.resolve("/tmp/clip.mp4"), kind: "video" },
],
{ budgetMs: 3000, concurrency: 2, maxProbes: 8 },
);
+1 -1
View File
@@ -12,7 +12,7 @@ export function resolveLocalMediaPath(source: string): string | undefined {
if (!trimmed || isPassThroughRemoteMediaSource(trimmed) || DATA_URL_RE.test(trimmed)) {
return undefined;
}
if (trimmed.startsWith("file://")) {
if (/^file:\/\//iu.test(trimmed)) {
try {
return safeFileURLToPath(trimmed);
} catch {
+111
View File
@@ -0,0 +1,111 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js";
import { toInboundMediaFactsWithMetadata } from "../channels/inbound-event/media.js";
import { createManagedOutgoingMediaBlocks } from "../gateway/managed-image-attachments.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveLocalMediaPath } from "./local-media-path.js";
import { appendLocalMediaParentRoots } from "./local-roots.js";
const { probeMediaFilesWithinBudget } = vi.hoisted(() => ({
probeMediaFilesWithinBudget: vi.fn(),
}));
vi.mock("./media-probe.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./media-probe.js")>()),
probeMediaFilesWithinBudget,
}));
function toUppercaseFileUrl(filePath: string): string {
return pathToFileURL(filePath).href.replace(/^file:/u, "FILE:");
}
async function withTempRoot<T>(run: (root: string) => Promise<T>): Promise<T> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-local-media-url-"));
try {
return await run(root);
} finally {
closeOpenClawStateDatabaseForTest();
await fs.rm(root, { recursive: true, force: true });
}
}
describe.runIf(process.platform === "win32")("Windows local media file URLs", () => {
beforeEach(() => {
probeMediaFilesWithinBudget.mockReset();
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
it("resolves mixed-case local file schemes and rejects unsafe file URLs", async () => {
await withTempRoot(async (root) => {
const sourcePath = path.join(root, "Media Folder Ω", "photo.png");
expect(resolveLocalMediaPath(toUppercaseFileUrl(sourcePath))).toBe(sourcePath);
expect(resolveLocalMediaPath("FILE://server/share/photo.png")).toBeUndefined();
expect(resolveLocalMediaPath("FILE:///C:/Media%2Fphoto.png")).toBeUndefined();
});
});
it("adds the exact parent root for an uppercase file URL", async () => {
await withTempRoot(async (root) => {
const sourcePath = path.join(root, "Media Folder Ω", "photo.png");
expect(appendLocalMediaParentRoots([], [toUppercaseFileUrl(sourcePath)])).toEqual([
path.dirname(sourcePath),
]);
});
});
it("probes inbound metadata through an uppercase file URL", async () => {
await withTempRoot(async (root) => {
const sourcePath = path.join(root, "Media Folder Ω", "voice.mp3");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
probeMediaFilesWithinBudget.mockResolvedValueOnce([{ durationMs: 1250 }]);
await expect(
toInboundMediaFactsWithMetadata([
{ path: toUppercaseFileUrl(sourcePath), contentType: "audio/mpeg" },
]),
).resolves.toEqual([
expect.objectContaining({ path: toUppercaseFileUrl(sourcePath), durationMs: 1250 }),
]);
expect(probeMediaFilesWithinBudget).toHaveBeenCalledWith(
[{ filePath: sourcePath, kind: "audio" }],
{ budgetMs: 3000, concurrency: 2, maxProbes: 8 },
);
});
});
it("ingests an uppercase file URL into managed outgoing media", async () => {
await withTempRoot(async (root) => {
const sourcePath = path.join(root, "workspace", "Media Folder Ω", "photo.png");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, createSolidPngBuffer(8, 8, { r: 24, g: 64, b: 128 }));
const sourceUrl = new URL(toUppercaseFileUrl(sourcePath));
sourceUrl.searchParams.set("sig", "secret");
sourceUrl.hash = "preview";
await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => {
const blocks = await createManagedOutgoingMediaBlocks({
stateDir: root,
sessionKey: "agent:main:main",
mediaUrls: [sourceUrl.href.replace(/^file:/u, "FILE:")],
localRoots: [path.join(root, "workspace")],
});
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({ type: "image", alt: "photo.png" });
expect(JSON.stringify(blocks[0])).not.toContain(sourcePath);
expect(JSON.stringify(blocks[0])).not.toContain("sig=secret");
});
});
});
});
@@ -0,0 +1,48 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createSolidPngBuffer } from "../../test/helpers/image-fixtures.js";
import { loadWebMedia } from "./web-media.js";
const TINY_PNG = createSolidPngBuffer(1, 1, { r: 255, g: 255, b: 255 });
describe.runIf(process.platform === "win32")("Windows web media file URLs", () => {
let fixtureRoot = "";
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-media-file-url-"));
});
afterAll(async () => {
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
});
it("loads uppercase file URLs with spaces and Unicode", async () => {
const filePath = path.join(fixtureRoot, "café image.png");
await fs.writeFile(filePath, TINY_PNG);
const fileUrl = pathToFileURL(filePath).href.replace(/^file:/u, "FILE:");
const result = await loadWebMedia(fileUrl, {
maxBytes: 1024 * 1024,
localRoots: [fixtureRoot],
});
expect(result.buffer).toEqual(TINY_PNG);
expect(result.fileName).toBe("café image.png");
});
it.each([
"FILE://attacker/share/evil.png",
"FILE:///C:/safe/folder%2Fsecret.png",
"FILE:///C:/safe/folder%5Csecret.png",
"FILE:////attacker/share/evil.png",
])("rejects unsafe uppercase file URL %s", async (fileUrl) => {
await expect(loadWebMedia(fileUrl, { localRoots: [fixtureRoot] })).rejects.toMatchObject({
code: "invalid-file-url",
});
});
});
+1 -1
View File
@@ -1030,7 +1030,7 @@ async function loadWebMediaInternal(
mediaUrl = stripLegacyMediaDirectivePrefix(mediaUrl);
mediaUrl = (await resolveMediaStoreUriToPath(mediaUrl)) ?? mediaUrl;
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
if (mediaUrl.startsWith("file://")) {
if (/^file:\/\//iu.test(mediaUrl)) {
try {
mediaUrl = safeFileURLToPath(mediaUrl);
} catch (err) {
@@ -115,6 +115,29 @@ describe("detectChangedScope Windows routing", () => {
}
});
it("routes web and Teams file URL handling to Windows", () => {
for (const fileUrlPath of [
"src/media/local-media-path.ts",
"src/media/local-media-path.windows.test.ts",
"src/media/local-roots.ts",
"src/media/local-roots.test.ts",
"src/media/web-media.ts",
"src/media/web-media.file-url.windows.test.ts",
"src/channels/inbound-event/media.ts",
"src/channels/inbound-event/media.test.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/managed-image-attachments.test.ts",
"extensions/msteams/src/media-helpers.ts",
"extensions/msteams/src/media-helpers.test.ts",
"extensions/msteams/src/messenger.test.ts",
]) {
expect(detectChangedScope([fileUrlPath]), fileUrlPath).toMatchObject({
runNode: true,
runWindows: true,
});
}
});
it("routes SecretRef path-security changes and native fixtures to Windows", () => {
for (const secretRefPath of [
"src/commands/doctor-gateway-auth-token.ts",
+14
View File
@@ -221,6 +221,12 @@ describe("package scripts", () => {
);
});
it("runs mixed-case local media file URL coverage in Windows CI", () => {
expect(readPackageJson().scripts["test:windows:ci"]).toContain(
"src/media/local-media-path.windows.test.ts",
);
});
it("runs the native OpenSSH resolver proof in Windows CI", () => {
expect(readPackageJson().scripts["test:windows:ci"]).toContain(
"src/infra/ssh-client.windows.test.ts",
@@ -312,4 +318,12 @@ describe("package scripts", () => {
"src/infra/fs-safe-remove.test.ts",
);
});
it("runs web and Teams file URL coverage in Windows CI", () => {
const script = readPackageJson().scripts["test:windows:ci"];
expect(script).toContain("src/media/web-media.file-url.windows.test.ts");
expect(script).toContain("extensions/msteams/src/media-helpers.test.ts");
expect(script).toContain("extensions/msteams/src/messenger.test.ts");
});
});