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>
96 lines
3.2 KiB
TypeScript
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/
|
|
)
|
|
})
|