diff --git a/CHANGELOG.md b/CHANGELOG.md index a7721797709..64baf0be4c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Control UI: render Dream Diary prose through the sanitized markdown pipeline, so diary bold/italic/header markdown no longer appears as literal source text. Fixes #62413. - Control UI: render tool results whose output arrives as text-block arrays and give expanded tool output a scrollable block, so read/exec output remains visible in WebChat. Fixes #77054. - MCP: include serialized conversation/message payloads in the primary text content for `conversations_list` and `messages_read`, while preserving `structuredContent` for capable clients. Fixes #77024. +- Media: treat `EPERM` from the post-write media fsync step as best-effort, allowing WebChat and channel uploads to finish on Windows filesystems that reject `fsync` after a successful write. Fixes #76844. - Diagnostics: keep webhook/message OTEL attributes and Prometheus delivery labels low-cardinality and omit raw chat/message IDs from spans, so progress-draft and message-tool modes do not leak high-cardinality messaging identifiers. - Google Meet: stop advertising legacy `mode: "realtime"` to agents and config UIs, while keeping it as a hidden compatibility alias for `mode: "agent"`, so new joins use the STT -> OpenClaw agent -> TTS path instead of selecting the direct realtime voice fallback. - Google Meet: add `chrome.audioBufferBytes` for generated command-pair SoX audio commands and lower the default buffer from SoX's 8192 bytes to 4096 bytes to reduce Chrome talk-back latency. diff --git a/src/media/store.test.ts b/src/media/store.test.ts index bb536f00a88..44f84d01ebf 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -341,6 +341,35 @@ describe("media store", () => { }); }, }, + { + name: "saves buffers when the best-effort fsync step reports EPERM", + run: async () => { + await withTempStore(async (store) => { + const originalOpen = fs.open.bind(fs); + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const handle = await originalOpen(...args); + const filePath = args[0]; + if ( + typeof filePath === "string" && + filePath.includes(`${path.sep}fsync-eperm${path.sep}`) + ) { + vi.spyOn(handle, "sync").mockRejectedValueOnce( + Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + ); + } + return handle; + }); + + const saved = await store.saveMediaBuffer( + Buffer.from("docx"), + "application/zip", + "fsync-eperm", + ); + + await expect(fs.readFile(saved.path, "utf8")).resolves.toBe("docx"); + }); + }, + }, { name: "rejects traversal media subdirs before saving buffers", run: async () => { diff --git a/src/media/store.ts b/src/media/store.ts index 0e1055fbb18..e1086921349 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -351,7 +351,7 @@ async function writeSavedMediaBuffer(params: { await fs.writeFile(tempDest, params.buffer, { mode: MEDIA_FILE_MODE }); const handle = await fs.open(tempDest, "r"); try { - await handle.sync(); + await syncSavedMediaHandle(handle); } finally { await handle.close(); } @@ -364,6 +364,17 @@ async function writeSavedMediaBuffer(params: { return dest; } +async function syncSavedMediaHandle(handle: fs.FileHandle): Promise { + try { + await handle.sync(); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === "EPERM") { + return; + } + throw err; + } +} + export type SaveMediaSourceErrorCode = | "invalid-path" | "not-found"