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>
186 lines
6.3 KiB
TypeScript
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 }
|
|
}
|