diff --git a/apps/desktop/electron/local-read-path.test.ts b/apps/desktop/electron/local-read-path.test.ts index 5e8dec9aaf..e79a1151a6 100644 --- a/apps/desktop/electron/local-read-path.test.ts +++ b/apps/desktop/electron/local-read-path.test.ts @@ -10,7 +10,11 @@ const { bridge } = vi.hoisted(() => ({ bridge: vi.fn() })) vi.mock('./wsl-path-bridge', () => ({ resolveLocalReadPath: bridge })) -import { resolveIpcFileReadPath, resolveMediaRequestPath, resolvePreviewTargetPath } from './local-read-path' +import { + resolveIpcFileReadPath, + resolveMediaStreamFile, + resolvePreviewTargetPath +} from './local-read-path' const BRIDGED = '\\\\wsl.localhost\\Ubuntu\\home\\alex\\file' @@ -19,26 +23,44 @@ beforeEach(() => { bridge.mockImplementation(() => BRIDGED) }) -test('resolveMediaRequestPath (hermes-media:// handler) bridges the decoded request pathname', () => { - // Mirror how the renderer builds the URL: hermes-media://stream/. - const url = new URL(`hermes-media://stream/${encodeURIComponent('/home/alex/My Clips/clip.mp4')}`) - - const result = resolveMediaRequestPath(url.pathname) +// Regression for hermes-agent 123823: the media protocol handler decodes the +// request pathname itself (parseMediaProtocolTarget in media-protocol.ts), so +// the resolveLocalFile boundary must NOT decode or strip leading slashes again. +// The pre-fix wiring ran decodeURIComponent + strip on the already-decoded +// path, turning `/home/...` into a cwd-relative `home/...` that ENOENTs into +// the handler's silent 404, and threw URIError on filenames with a literal `%`. +test('resolveMediaStreamFile (hermes-media:// resolveLocalFile) bridges the already-decoded path unchanged', () => { + const result = resolveMediaStreamFile('/home/alex/My Clips/clip.mp4') assert.equal(bridge.mock.calls.length, 1) assert.equal(bridge.mock.calls[0][0], '/home/alex/My Clips/clip.mp4') assert.equal(result, BRIDGED) }) -test('resolveMediaRequestPath strips leading slashes before decoding and bridging', () => { - resolveMediaRequestPath(`///${encodeURIComponent('/mnt/c/Users/alex/clip.mp4')}`) +test('resolveMediaStreamFile preserves a leading slash so absolute POSIX paths stay absolute', () => { + // What the protocol handler hands over for hermes-media://stream/%2Fhome%2F...: + // already decoded, still absolute. Stripping the leading slash here is the + // 123823 regression (cwd-relative ENOENT -> silent 404 on Linux/macOS). + const url = new URL(`hermes-media://stream/${encodeURIComponent('/home/alex/clip.mp4')}`) + const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, '')) + + resolveMediaStreamFile(filePath) assert.equal(bridge.mock.calls.length, 1) - assert.equal(bridge.mock.calls[0][0], '/mnt/c/Users/alex/clip.mp4') + assert.equal(bridge.mock.calls[0][0], '/home/alex/clip.mp4') }) -test('resolveMediaRequestPath tolerates nullish pathname', () => { - const result = resolveMediaRequestPath(undefined as unknown as string) +test('resolveMediaStreamFile keeps percent-sign bytes in filenames intact', () => { + // A filename containing a literal `%` (e.g. "100% clip.mp4") arrives + // decoded; a second decodeURIComponent would throw URIError. + resolveMediaStreamFile('/home/alex/100% clip.mp4') + + assert.equal(bridge.mock.calls.length, 1) + assert.equal(bridge.mock.calls[0][0], '/home/alex/100% clip.mp4') +}) + +test('resolveMediaStreamFile tolerates nullish input', () => { + const result = resolveMediaStreamFile(undefined as unknown as string) assert.equal(bridge.mock.calls.length, 1) assert.equal(bridge.mock.calls[0][0], '') diff --git a/apps/desktop/electron/local-read-path.ts b/apps/desktop/electron/local-read-path.ts index d3d7c76145..7c5c0c53ab 100644 --- a/apps/desktop/electron/local-read-path.ts +++ b/apps/desktop/electron/local-read-path.ts @@ -9,11 +9,16 @@ import { resolveLocalReadPath } from './wsl-path-bridge' // so the wiring is exercised in isolation instead of buried in main.ts. /** - * hermes-media:// stream handler: the request `pathname` (leading slashes plus - * percent-encoding) → a bridged fs path. + * hermes-media:// stream handler's `resolveLocalFile` dependency: the protocol + * handler already percent-decoded the request pathname into `filePath` + * (`parseMediaProtocolTarget` in media-protocol.ts), so this boundary only + * bridges. Decoding or stripping leading slashes again would turn an absolute + * POSIX path (`/home/...`) into a cwd-relative one that ENOENTs into the + * handler's silent 404, and a second `decodeURIComponent` throws on filenames + * containing a literal `%`. */ -export function resolveMediaRequestPath(pathname: string): string { - return resolveLocalReadPath(decodeURIComponent(String(pathname ?? '').replace(/^\/+/, ''))) +export function resolveMediaStreamFile(filePath: unknown): string { + return resolveLocalReadPath(String(filePath ?? '')) } /** diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d2d333e670..da2e5b9976 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -315,7 +315,7 @@ import { CHROMIUM_LOG_FILENAME, enableLinuxCrashDiagnostics, linuxCrashDiagnosti import { notifyLauncherWindowRevealed } from './linux-launcher-ready' import { decideNvidiaEglFallback, parseNvidiaDriverMajor } from './linux-nvidia-egl-fallback' import { createLocalBackendLifecycle, waitForTeardown } from './local-backend-lifecycle' -import { resolveIpcFileReadPath, resolveMediaRequestPath, resolvePreviewTargetPath } from './local-read-path' +import { resolveIpcFileReadPath, resolveMediaStreamFile, resolvePreviewTargetPath } from './local-read-path' import { localSkinProfileKey, readLocalSkinPayload } from './local-skin' import { ACTIVE_LOG_POLL_MS, planLogRotation, reclaimActiveLogIfOversized } from './log-rotation' import { registerMachineProfile } from './machine-profile' @@ -1543,7 +1543,9 @@ function registerMediaProtocol(): void { // On a Windows host with a WSL backend the media path arrives as a // WSL/POSIX path (`/home/...`, `/mnt/c/...`) the Windows fs can't open // as-is; bridge it to a UNC/drive form first, same as directory reads. - const { resolvedPath } = await resolveReadableFileForIpc(resolveMediaRequestPath(filePath), { + // The protocol handler already percent-decoded the pathname, so this + // boundary bridges only — re-decoding/stripping would corrupt the path. + const { resolvedPath } = await resolveReadableFileForIpc(resolveMediaStreamFile(filePath), { purpose: 'Media stream' })