Files
hermes-agent/apps/desktop/electron/external-open.ts
Brooklyn Nicholson b5a5ef7c16 fix(desktop): open bare local paths instead of "Invalid external URL"
The `hermes:openExternal` route only accepted URL-parseable input, so every
bare local filesystem path a chat media link, a markdown href, or an
artifacts-panel value carried was a dead click: `new URL('C:\Users\x\a.md')`
parses a Windows drive letter as the bogus `c:` scheme (rejected by the web
allowlist as "Invalid external URL"), and POSIX `/...`, `~/...`, UNC and
relative paths make the parser throw into the same reject. Protocol-relative
`//cdn.example.com/img.png` URLs were also dead — the parser rejects them
without a base, and they are a URL, not a path.

- electron/local-filesystem-path.ts (new, from #84542): pure predicates —
  `looksLikeLocalFilesystemPath` (POSIX, `~/`, drive, UNC; never `//host`)
  and `absolutizeProtocolRelativeUrl` (`//host/...` opens as https).
- external-open.ts: a bare-path branch ahead of `new URL()` routes local
  path shapes through a new `openLocalPath` dep; an unresolved path logs the
  path and answers `invalid`, a thrown open reports through the existing
  failure modal.
- main.ts: `openLocalFilesystemPath` resolves through the same audited
  `resolveRequestedPathForIpc` as the `file:` route, runs the existing
  missing-file pre-open stat guard, then `shell.openPath` with a
  reveal-in-folder fallback — the reveal never re-opens, so archive artifacts
  with no usable association can't loop (#53170). Open failures log the path
  so "Failed to open path" is diagnosable.

Fixes https://github.com/NousResearch/hermes-agent/issues/80946

Co-authored-by: 686f6c61 <github@00b.tech>
Co-authored-by: Uttkarsh Tiwari <38376852+uttkarsh-26@users.noreply.github.com>
2026-09-29 19:14:11 -05:00

186 lines
6.3 KiB
TypeScript

/**
* external-open.ts
*
* The single route every external URL open in the desktop app flows through.
* Electron-free: every side effect (shell.openExternal, spawn, the file
* opener, the failure channel) is injected, so the "open failed → notify"
* behavior is unit-tested without loading electron — the same shape as
* native-oauth-login.ts and connection-config.ts. main.ts wires the real deps.
*
* A URL is opened exactly once. An open failure is logged and reported
* through notifyFailure (the renderer shows a fallback modal with the URL);
* the caller reads the result to decide whether an open-failure must also
* abort its own flow (the native-OAuth path fails fast, link paths don't).
*/
import type { ChildProcess, SpawnOptions } from 'node:child_process'
import { absolutizeProtocolRelativeUrl, looksLikeLocalFilesystemPath } from './local-filesystem-path'
export type ExternalOpenResult =
{ ok: true } | { ok: false; reason: 'invalid' } | { ok: false; reason: 'failed'; message: string }
export interface FileOpenGuardDeps {
log: (line: string) => void
reportMissing: (rawUrl: string, message: string) => void
}
/**
* Decide what a pre-open stat failure means for a file open (#122027). Lives
* here, not inline in main.ts, so the classification is unit-tested without
* loading electron — same injected-deps shape as the rest of the module.
*
* Returns true when the failure was a MISS: it has been reported through
* reportMissing and the caller must NOT hand the path to the OS (a reveal of
* a non-existent path is silently a no-op on macOS; an open of one reads as
* "No application found" on LaunchServices). Returns false for every other
* stat failure (EACCES on a locked volume, ELOOP, Windows EPERM): those are
* logged and the caller still proceeds to the OS, so an existing-but-locked
* file keeps its real error and a stat failure never fabricates a miss.
*/
export function reportPreOpenStatFailure(error: unknown, rawUrl: string, deps: FileOpenGuardDeps): boolean {
if (error && typeof error === 'object' && (error as { code?: string }).code === 'missing-file') {
deps.reportMissing(rawUrl, externalOpenErrorMessage(error))
return true
}
deps.log(`[file] pre-open stat failed: ${externalOpenErrorMessage(error)}`)
return false
}
export interface ExternalOpenDeps {
isWsl: boolean
spawn: (cmd: string, args: readonly string[], opts: SpawnOptions) => ChildProcess
openExternal: (url: string) => Promise<void>
openFile: (rawUrl: string) => Promise<void>
/**
* Open a BARE local filesystem path (POSIX `/…`, `~/…`, Windows drive/UNC)
* that is not a URL. The impl resolves it through the same audited
* `resolveRequestedPathForIpc` the `file:` route uses, then `shell.openPath`,
* with the same reveal-in-folder fallback and missing-file reporting as the
* file route. Resolves false when the path could not be resolved at all
* (rejected syntax, blocked device path) so the caller logs and reports it.
*/
openLocalPath: (rawPath: string) => Promise<boolean>
notifyFailure: (url: string, message: string) => void
log: (line: string) => void
}
const SUPPORTED_WEB = ['http:', 'https:', 'mailto:']
export function externalOpenErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Open a URL in the system browser. Resolves `invalid` for a URL the route
* does not open (empty, malformed, unsupported scheme). NEVER rejects: an open
* failure is logged + reported through notifyFailure and resolves `failed`,
* so fire-and-forget callers can `void` the result safely.
*/
export async function openExternalUrl(rawUrl: string, deps: ExternalOpenDeps): Promise<ExternalOpenResult> {
const raw = absolutizeProtocolRelativeUrl(String(rawUrl || '').trim())
if (!raw) {
return { ok: false, reason: 'invalid' }
}
// Bare local paths (POSIX `/…`, `~/…`, Windows drive/UNC) are not valid
// absolute URLs: `new URL()` either throws (POSIX, `~`, UNC) or mis-parses a
// drive letter as a bogus `c:` scheme, which the web allowlist below would
// reject as "Invalid external URL". Route them through the audited file
// resolver instead (hermes-agent 80946, 84361).
if (looksLikeLocalFilesystemPath(raw)) {
let opened: boolean
try {
opened = await deps.openLocalPath(raw)
} catch (error) {
return failOpen(deps, raw, error)
}
if (!opened) {
deps.log(`[file] openPath resolve rejected: path=${raw}`)
return { ok: false, reason: 'invalid' }
}
return { ok: true }
}
let parsed: URL
try {
parsed = new URL(raw)
} catch {
return { ok: false, reason: 'invalid' }
}
if (parsed.protocol === 'file:') {
try {
await deps.openFile(raw)
} catch {
// main's openFile handles its own fallback; never surfaced here
}
return { ok: true }
}
if (!SUPPORTED_WEB.includes(parsed.protocol)) {
return { ok: false, reason: 'invalid' }
}
const url = parsed.toString()
if (deps.isWsl) {
return openViaWsl(url, deps)
}
try {
await deps.openExternal(url)
return { ok: true }
} catch (error) {
return failOpen(deps, url, error)
}
}
async function openViaWsl(url: string, deps: ExternalOpenDeps): Promise<ExternalOpenResult> {
deps.log(`[link] opening via WSL→Windows: ${url}`)
const proc = deps.spawn('cmd.exe', ['/c', 'start', '""', url], {
detached: true,
stdio: 'ignore',
windowsHide: true
})
// 'error' only fires when the process could not be spawned. In that case
// fall back to xdg-open; if that also fails, surface it. The handler runs
// asynchronously after this function has already resolved.
proc.on('error', error => {
deps.log(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`)
deps.openExternal(url).catch(openError => {
failOpen(deps, url, openError)
})
})
try {
proc.unref()
} catch {
// unref can throw on an already-closed handle in some node versions
}
return { ok: true }
}
function failOpen(deps: ExternalOpenDeps, url: string, error: unknown): ExternalOpenResult {
const message = externalOpenErrorMessage(error)
deps.log(`[link] openExternal failed: ${message}`)
deps.notifyFailure(url, message)
return { ok: false, reason: 'failed', message }
}