fix(desktop): bridge the already-decoded media stream path once

The hermes-media://stream resolveLocalFile boundary ran
resolveMediaRequestPath on the path the protocol handler had already
percent-decoded and slash-stripped (parseMediaProtocolTarget), so the
second decode+strip turned every absolute POSIX media path
(/home/.../.hermes/...) into a cwd-relative one. The resolver then
pinned it under the app's cwd and the file ENOENTed into the handler's
silent 404 — inline TTS/audio playback dead with no log line, which is
the Linux symptom in hermes-agent 123823. A second decodeURIComponent
also threw URIError on filenames containing a literal %.

Restore the single decode at the protocol layer: the boundary now only
bridges (resolveMediaStreamFile), matching what 7722761581 intended as
byte-for-byte behavior but broke when it rewired main.ts.

Fixes https://github.com/NousResearch/hermes-agent/issues/123823
This commit is contained in:
Brooklyn Nicholson
2026-09-27 07:09:34 -05:00
committed by brooklyn!
parent ba5e3bfa31
commit e9c60209a7
3 changed files with 46 additions and 17 deletions

View File

@@ -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/<encoded path>.
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], '')

View File

@@ -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 ?? ''))
}
/**

View File

@@ -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'
})