feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).
This commit is contained in:
Brooklyn Nicholson
2026-05-11 21:38:47 -04:00
parent fdf73f0adf
commit d208f2c2c0
64 changed files with 5614 additions and 2703 deletions
@@ -3,11 +3,7 @@ const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
+184
View File
@@ -0,0 +1,184 @@
const fs = require('node:fs')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
const fallback =
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
const parsed = Number(timeoutMs)
if (Number.isFinite(parsed) && parsed > 0) {
return Math.round(parsed)
}
return fallback
}
function encryptDesktopSecret(value, safeStorageApi) {
const raw = String(value || '')
if (!raw) {
return null
}
let encryptionAvailable = false
try {
encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.())
} catch {
encryptionAvailable = false
}
if (!encryptionAvailable) {
throw new Error(
'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' +
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.'
)
}
try {
return {
encoding: 'safeStorage',
value: safeStorageApi.encryptString(raw).toString('base64')
}
} catch (error) {
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
throw new Error(
`Failed to encrypt the remote gateway token for secure storage${detail}. ` +
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.'
)
}
}
function sensitiveFileBlockReason(filePath) {
const normalized = String(filePath || '')
.replace(/\\/g, '/')
.toLowerCase()
const basename = path.basename(normalized)
const ext = path.extname(basename)
if (!basename) {
return null
}
if (normalized.includes('/.ssh/')) {
return 'SSH key/config files are blocked.'
}
if (normalized.includes('/.gnupg/')) {
return 'GPG key material is blocked.'
}
if (normalized.endsWith('/.aws/credentials')) {
return 'AWS credential files are blocked.'
}
if (basename === '.env') {
return '.env files are blocked because they commonly contain secrets.'
}
if (basename.startsWith('.env.')) {
const suffix = basename.slice('.env.'.length)
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
return `${basename} is blocked because it appears to contain environment secrets.`
}
}
if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) {
return 'SSH private key files are blocked.'
}
if (SENSITIVE_EXTENSIONS.has(ext)) {
return `${ext} key/certificate files are blocked.`
}
if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') {
return `${basename} is blocked because it may include auth credentials.`
}
return null
}
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error(`${purpose} failed: file path is required.`)
}
if (raw.includes('\0')) {
throw new Error(`${purpose} failed: file path is invalid.`)
}
if (/^file:/i.test(raw)) {
try {
return fileURLToPath(raw)
} catch {
throw new Error(`${purpose} failed: file URL is invalid.`)
}
}
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
return path.resolve(resolvedBase, raw)
}
async function resolveReadableFileForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
if (options.blockSensitive !== false) {
const blockReason = sensitiveFileBlockReason(resolvedPath)
if (blockReason) {
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
}
}
let stat
try {
stat = await fs.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new Error(`${purpose} failed: file does not exist.`)
}
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
}
if (stat.isDirectory()) {
throw new Error(`${purpose} failed: path points to a directory.`)
}
if (!stat.isFile()) {
throw new Error(`${purpose} failed: only regular files can be read.`)
}
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) {
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
}
try {
await fs.promises.access(resolvedPath, fs.constants.R_OK)
} catch {
throw new Error(`${purpose} failed: file is not readable.`)
}
return { resolvedPath, stat }
}
module.exports = {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret,
resolveReadableFileForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
}
+116
View File
@@ -0,0 +1,116 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveReadableFileForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} = require('./hardening.cjs')
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs('2750'), 2750)
})
test('encryptDesktopSecret requires available secure storage', () => {
assert.equal(
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
null
)
assert.throws(
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
/Secure token storage is unavailable/
)
})
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
const secret = encryptDesktopSecret('token-123', {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
})
assert.deepEqual(secret, {
encoding: 'safeStorage',
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
})
})
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
})
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const textPath = path.join(tempDir, 'notes.txt')
fs.writeFileSync(textPath, 'hello world', 'utf8')
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
baseDir: tempDir,
maxBytes: 256,
purpose: 'File preview'
})
assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromFileUrl.resolvedPath, textPath)
await assert.rejects(
resolveReadableFileForIpc('missing.txt', {
baseDir: tempDir,
purpose: 'Text preview'
}),
/file does not exist/
)
const nestedDir = path.join(tempDir, 'directory')
fs.mkdirSync(nestedDir)
await assert.rejects(
resolveReadableFileForIpc(nestedDir, {
purpose: 'Text preview'
}),
/path points to a directory/
)
const largePath = path.join(tempDir, 'large.txt')
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
await assert.rejects(
resolveReadableFileForIpc(largePath, {
maxBytes: 8,
purpose: 'File preview'
}),
/file is too large/
)
const envPath = path.join(tempDir, '.env')
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
await assert.rejects(
resolveReadableFileForIpc(envPath, {
purpose: 'File preview'
}),
/blocked for sensitive file/
)
const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview'
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
})
+52 -50
View File
@@ -21,6 +21,14 @@ const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { spawn } = require('node:child_process')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret: encryptDesktopSecretStrict,
resolveReadableFileForIpc,
resolveTimeoutMs
} = require('./hardening.cjs')
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
if (USER_DATA_OVERRIDE) {
@@ -843,7 +851,16 @@ function resolveHermesBackend(dashboardArgs) {
if (backend) return backend
}
// 2. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
// 2. Development source — when running `npm run dev` from a checkout, the
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any
// installed `hermes` on PATH so local Python edits are actually exercised.
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
if (backend) return backend
}
// 3. Existing `hermes` on PATH — installed via install.ps1 / install.sh, or
// pip-installed system-wide. Skip when HERMES_DESKTOP_IGNORE_EXISTING=1
// (used by test:desktop:fresh to force the factory-image bootstrap path).
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
@@ -876,15 +893,6 @@ function resolveHermesBackend(dashboardArgs) {
}
}
// 3. Development source — when running `npm run dev` from a checkout, the
// cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE so the
// desktop uses the dev's local edits, not whatever's under HERMES_HOME.
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
if (backend) return backend
}
// 4. ACTIVE_HERMES_ROOT — the canonical mutable install at
// %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent.
// On packaged installs this is populated from FACTORY_HERMES_ROOT during
@@ -1155,6 +1163,7 @@ function fetchJson(url, token, options = {}) {
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
const parsed = new URL(url)
const client = parsed.protocol === 'https:' ? https : http
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
@@ -1190,11 +1199,9 @@ function fetchJson(url, token, options = {}) {
)
req.on('error', reject)
if (options.timeoutMs) {
req.setTimeout(options.timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${options.timeoutMs}ms`))
})
}
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
})
if (body) req.write(body)
req.end()
})
@@ -1456,7 +1463,9 @@ function fetchLinkTitle(rawUrl) {
const pending = fetchHtmlTitleWithCurl(url)
.catch(() => '')
.then(value => usableTitle((value || '').slice(0, 240)))
.then(async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)))
.then(
async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))
)
.then(clean => {
cacheTitle(key, clean)
titleInflight.delete(key)
@@ -2022,24 +2031,7 @@ function tokenPreview(value) {
}
function encryptDesktopSecret(value) {
const raw = String(value || '')
if (!raw) {
return null
}
try {
if (safeStorage.isEncryptionAvailable()) {
return {
encoding: 'safeStorage',
value: safeStorage.encryptString(raw).toString('base64')
}
}
} catch {
// Fall through to plaintext for platforms where Electron cannot encrypt.
}
return { encoding: 'plain', value: raw }
return encryptDesktopSecretStrict(value, safeStorage)
}
function decryptDesktopSecret(secret) {
@@ -2108,14 +2100,19 @@ function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig())
}
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig()) {
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const mode = input.mode === 'remote' ? 'remote' : 'local'
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const existingToken = existing.remote?.token
const nextRemote = {
url: remoteUrl,
token: incomingToken ? encryptDesktopSecret(incomingToken) : existingToken
token: incomingToken
? persistToken
? encryptDesktopSecret(incomingToken)
: { encoding: 'plain', value: incomingToken }
: existingToken
}
if (mode === 'remote') {
@@ -2181,7 +2178,7 @@ function resolveRemoteBackend() {
}
async function testDesktopConnectionConfig(input = {}) {
const config = coerceDesktopConnectionConfig(input)
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const remote =
config.mode === 'remote'
? {
@@ -2455,9 +2452,11 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes()
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
method: request.method,
body: request.body
method: request?.method,
body: request?.body,
timeoutMs
})
})
@@ -2472,18 +2471,21 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const input = String(filePath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
const data = await fs.promises.readFile(resolved)
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
maxBytes: DATA_URL_READ_MAX_BYTES,
purpose: 'File preview'
})
const data = await fs.promises.readFile(resolvedPath)
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
})
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
const input = String(filePath || '')
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
const ext = path.extname(resolved).toLowerCase()
const stat = await fs.promises.stat(resolved)
const handle = await fs.promises.open(resolved, 'r')
const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, {
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
purpose: 'Text preview'
})
const ext = path.extname(resolvedPath).toLowerCase()
const handle = await fs.promises.open(resolvedPath, 'r')
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
try {
@@ -2494,8 +2496,8 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
byteSize: stat.size,
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
mimeType: mimeTypeForPath(resolved),
path: resolved,
mimeType: mimeTypeForPath(resolvedPath),
path: resolvedPath,
text: buffer.subarray(0, bytesRead).toString('utf8'),
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
}