Files
hermes-agent/apps/desktop/electron/open-missing-file.test.ts
Brooklyn Nicholson ba5e3bfa31 fix(desktop): say "File not found" when opening a missing file
Opening a deleted/renamed artifact reported "No application found to open
URL": macOS LaunchServices answers a non-existent path with
kLSApplicationNotFoundErr (-10814), and the open route handed resolved
paths to the OS without ever stat-ing. On current main the file route is
reveal-in-folder only, which is worse on a miss — showItemInFolder of a
non-existent path is a silent no-op on macOS, so the click does nothing
at all.

- hardening.ts: assertExistingPathForOpen — pre-open stat wired into
  openExternalFile before the reveal-dedupe window is touched (a miss
  must not poison dedupe against a legitimate retry). Only
  ENOENT/ENOTDIR become an honest missing-file failure.
- external-open.ts: reportPreOpenStatFailure — the guard's
  classification lives in the electron-free module with the rest of the
  open logic, so it unit-tests without electron. A miss is reported and
  the route stops; every other stat failure (EACCES on a locked volume,
  ELOOP, Windows EPERM, ENAMETOOLONG) is logged and the OS is still
  asked, so an existing-but-locked file keeps its real error — a stat
  failure never fabricates a miss and never swallows the click.
- preload.ts: the failure dialog merged in #122023 had no preload
  forwarder for hermes:external-open-failed, so every open failure was
  silently swallowed in every window (the optional-chained bridge is
  invisible to typecheck). Adds the onExternalOpenFailed listener plus a
  test tripwire so the dead wiring cannot come back.
- The failure payload gains code: 'missing-file'; the dialog shows
  localized "File not found" copy for it instead of the no-browser
  text (i18n types + en).

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

Co-authored-by: Andrew Ho <andrewho.sf@gmail.com>
Co-authored-by: Enough1122 <10966420+Enough1122@users.noreply.github.com>
2026-09-27 07:10:01 -05:00

96 lines
3.2 KiB
TypeScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { assertExistingPathForOpen } from './hardening'
// #122027: a deleted/renamed file handed to the OS comes back as
// "No application found to open URL" on the open path (LaunchServices
// kLSApplicationNotFoundErr doubles as the missing-file answer on macOS), and
// the current reveal-in-folder route silently no-ops on a missing path — the
// click does nothing at all. The guard must say "missing" before the OS is
// asked, on all platforms — fs.statSync's ENOENT/ENOTDIR are uniform across
// macOS/Windows/Linux.
test('a missing path throws a missing-file error instead of reaching the OS', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-missing-'))
const ghost = path.join(dir, 'report.html')
assert.throws(
() => assertExistingPathForOpen(ghost, 'Open preview in browser'),
(error: Error & { code?: string }) => {
assert.equal(error.code, 'missing-file')
assert.match(error.message, /does not exist/)
assert.match(error.message, /another machine/)
return true
}
)
})
test('a path under a missing directory (ENOTDIR) also reads as missing', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-missing-'))
const underFile = path.join(path.join(dir, 'plain.txt'), 'nested.txt')
fs.writeFileSync(path.join(dir, 'plain.txt'), 'not a directory')
assert.throws(
() => assertExistingPathForOpen(underFile),
(error: Error & { code?: string }) => error.code === 'missing-file'
)
})
test('an existing file passes the guard', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-present-'))
const real = path.join(dir, 'report.html')
fs.writeFileSync(real, '<html></html>')
assert.doesNotThrow(() => assertExistingPathForOpen(real, 'Open preview in browser'))
})
test('a non-ENOENT stat failure is not converted into a fabricated miss', () => {
// The stat itself is the seam under test: an EACCES-style failure on an
// EXISTING file must propagate untouched so the OS still gets a chance
// (root-owned trees are openable by their owner's other tooling, and the
// OS error there is honest — it is not "no application").
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-eacces-'))
const real = path.join(dir, 'locked.txt')
fs.writeFileSync(real, 'x')
const eacces = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
const original = fs.statSync
Object.defineProperty(fs, 'statSync', {
configurable: true,
value: (p: fs.PathLike) => {
if (String(p) === real) {
throw eacces
}
return original(p)
}
})
try {
assert.throws(
() => assertExistingPathForOpen(real),
(error: Error & { code?: string }) => error.code === 'EACCES'
)
} finally {
Object.defineProperty(fs, 'statSync', { configurable: true, value: original })
}
})
test('the purpose string reaches the user-facing message', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-purpose-'))
assert.throws(
() => assertExistingPathForOpen(path.join(dir, 'gone.pdf'), 'Open external file'),
/Open external file failed/
)
})