mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
fix(ui-tui): boot theme cache gets provenance — a stale hint can't pin the terminal
Review on #20379, finding 2 (High). The boot cache seeded the previous session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT OSC-11 answer occupies — so a cache written on a light terminal pinned a now-pure-black terminal to light forever: the new OSC-11 #000000 answer is distrusted by design, the pure-white OSC-10 foreground is distrusted too, and the macOS appearance fallback refuses to run while the slot is set. Seeding now records provenance (themeBoot.seedBootEnvironment, extracted and testable against a passed env). When the current terminal answers the background probe with the untrusted fingerprint, the gateway handler calls invalidateBootBackground(): the slot clears ONLY while it still holds the seeded value (a trusted answer that overwrote it is authoritative), OSC-10 gets first claim in the same startup batch, and a short settle pass re- derives from the live fallback chain if nothing answered. The cache is also pin-coherent now: commitTheme persists the config mode pin (display.tui_theme) alongside the resolved theme + physical background. Previously "/theme light" on a dark terminal cached a light theme next to a dark background — the next launch painted light, flipped dark when the skin resolved against the seeded background, then flipped light again on config hydration: the exact multi-stage flash the cache exists to eliminate. The seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can still clear it instead of mistaking it for a user shell export. Tests cover the review's sequences: stale-light cache vs current dark terminal (invalidate → fallback chain), stale-dark vs ambiguous light, pinned light on a dark physical background across restart (and the inverse), trusted-overwrite protection, and the explicit-signal guards.
This commit is contained in:
parent
b11b5ece2e
commit
e6cc5612e6
3 changed files with 259 additions and 23 deletions
130
ui-tui/src/__tests__/themeBoot.test.ts
Normal file
130
ui-tui/src/__tests__/themeBoot.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { invalidateBootBackground, seedBootEnvironment, type BootTheme } from '../lib/themeBoot.js'
|
||||
import { defaultTheme } from '../theme.js'
|
||||
|
||||
// Review on #20379 (finding 2): the boot cache seeds the previous session's
|
||||
// background into HERMES_TUI_BACKGROUND, which detectLightMode treats as a
|
||||
// CURRENT signal. Without provenance, a stale light cache pins a now-dark
|
||||
// terminal to light indefinitely (the current probe's pure-black answer is
|
||||
// distrusted, the pure-white foreground is distrusted, and the macOS
|
||||
// fallback refuses to run while the slot is occupied). These tests cover
|
||||
// the seed/invalidate contract the gateway handler drives.
|
||||
|
||||
const cache = (over: Partial<BootTheme> = {}): BootTheme => ({ theme: defaultTheme, ...over })
|
||||
|
||||
describe('seedBootEnvironment', () => {
|
||||
it('seeds the cached background when no explicit signal outranks it', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#ffffff' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
|
||||
expect(seeded).toEqual({ seededBackground: '#ffffff', seededPin: false })
|
||||
})
|
||||
|
||||
it('never seeds over explicit user signals', () => {
|
||||
for (const preset of [
|
||||
{ HERMES_TUI_THEME: 'dark' },
|
||||
{ HERMES_TUI_LIGHT: '1' }
|
||||
] as NodeJS.ProcessEnv[]) {
|
||||
const env = { ...preset }
|
||||
const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'light' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
expect(seeded).toEqual({ seededBackground: null, seededPin: false })
|
||||
}
|
||||
|
||||
// A user-exported background keeps its value; only the pin may seed.
|
||||
const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#123456' }
|
||||
|
||||
expect(seedBootEnvironment(cache({ background: '#ffffff' }), env).seededBackground).toBeNull()
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#123456')
|
||||
})
|
||||
|
||||
it('never seeds the untrusted pure-black fingerprint', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#000000' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
expect(seeded.seededBackground).toBeNull()
|
||||
})
|
||||
|
||||
it('replays a cached config pin coherently with the physical background', () => {
|
||||
// "/theme light" pinned while the physical terminal is dark: the cache
|
||||
// stores BOTH — replaying only the background would resolve the first
|
||||
// skin dark and recreate the light → dark → light flash.
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#1e1e1e', mode: 'light' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_THEME).toBe('light')
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#1e1e1e')
|
||||
expect(seeded).toEqual({ seededBackground: '#1e1e1e', seededPin: true })
|
||||
})
|
||||
|
||||
it('replays a pinned dark on a light physical background too', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'dark' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_THEME).toBe('dark')
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
|
||||
expect(seeded.seededPin).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op without a cache', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
expect(seedBootEnvironment(null, env)).toEqual({ seededBackground: null, seededPin: false })
|
||||
expect(env).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateBootBackground', () => {
|
||||
it('clears the slot while it still holds the seeded value (stale light cache, current dark terminal)', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
seedBootEnvironment(cache({ background: '#ffffff' }), env)
|
||||
|
||||
// Current terminal answers OSC-11 with distrusted #000000 → the handler
|
||||
// invalidates: the slot must clear so foreground / COLORFGBG / macOS
|
||||
// appearance / the default get their turn.
|
||||
expect(invalidateBootBackground(env)).toBe(true)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
|
||||
// Idempotent: a second distrusted answer has nothing left to clear.
|
||||
expect(invalidateBootBackground(env)).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a stale dark cache on a current ambiguous terminal the same way', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
seedBootEnvironment(cache({ background: '#1e1e1e' }), env)
|
||||
|
||||
expect(invalidateBootBackground(env)).toBe(true)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a trusted OSC answer that overwrote the seed alone', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
seedBootEnvironment(cache({ background: '#ffffff' }), env)
|
||||
|
||||
// A real OSC-11 measurement replaced the hint — it is authoritative.
|
||||
env.HERMES_TUI_BACKGROUND = '#282828'
|
||||
|
||||
expect(invalidateBootBackground(env)).toBe(false)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#282828')
|
||||
})
|
||||
|
||||
it('is a no-op when nothing was seeded', () => {
|
||||
const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#ffffff' }
|
||||
|
||||
seedBootEnvironment(null, env)
|
||||
|
||||
expect(invalidateBootBackground(env)).toBe(false)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
|
|
@ -19,7 +19,7 @@ import { openExternalUrl } from '../lib/openExternalUrl.js'
|
|||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { topLevelSubagents } from '../lib/subagentTree.js'
|
||||
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
|
||||
import { writeBootTheme } from '../lib/themeBoot.js'
|
||||
import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js'
|
||||
import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js'
|
||||
import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
|
||||
|
||||
|
|
@ -83,7 +83,14 @@ const commitTheme = (theme: Theme) => {
|
|||
|
||||
lastCommittedTheme = theme
|
||||
patchUiState({ theme })
|
||||
writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND)
|
||||
// Persist the config pin alongside the resolved theme + physical
|
||||
// background: a pinned session's resolved polarity intentionally
|
||||
// disagrees with the background, and caching one without the other
|
||||
// recreates the multi-stage flash on the next launch (light first frame →
|
||||
// dark skin resolve against the cached background → light config pin).
|
||||
const pin = configPinnedTheme ? process.env.HERMES_TUI_THEME : undefined
|
||||
|
||||
writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND, pin === 'light' || pin === 'dark' ? pin : undefined)
|
||||
|
||||
if (changed) {
|
||||
setTimeout(() => forceRedraw(process.stdout), 40).unref?.()
|
||||
|
|
@ -130,8 +137,11 @@ export function reapplyTheme(): void {
|
|||
* terminal background unset.
|
||||
*/
|
||||
// True once CONFIG (via light/dark) owns the HERMES_TUI_THEME env pin, so an
|
||||
// 'auto' hydrate knows not to clobber a user's shell-exported pin.
|
||||
let configPinnedTheme = false
|
||||
// 'auto' hydrate knows not to clobber a user's shell-exported pin. A pin the
|
||||
// boot cache replayed counts as config-owned — it originated from
|
||||
// display.tui_theme last session, and treating it as a shell export would
|
||||
// make a stale cached pin unclearable by 'auto'.
|
||||
let configPinnedTheme = bootSeededPin
|
||||
|
||||
export function applyConfiguredTuiTheme(raw: unknown): void {
|
||||
const mode = String(raw ?? '')
|
||||
|
|
@ -224,6 +234,21 @@ export function syncThemeToTerminalBackground(): void {
|
|||
// foreground below resolves the pole for transparent hosts, and a truly
|
||||
// pure-black terminal lands on dark either way.
|
||||
if (hex === '#000000') {
|
||||
// The CURRENT terminal answered with an untrusted value — a background
|
||||
// the boot cache seeded is from another era and must not keep
|
||||
// outranking the live fallback chain (previous light session + new
|
||||
// pure-black terminal stayed light forever: OSC-10 pure-white is also
|
||||
// rejected, and the macOS fallback refuses to run while the slot is
|
||||
// occupied). Clear the stale hint, give OSC-10 (same startup batch)
|
||||
// first claim, then settle via env heuristics if nothing answered.
|
||||
if (invalidateBootBackground()) {
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
reapplyTheme()
|
||||
}
|
||||
}, 250).unref?.()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ import type { Theme } from '../theme.js'
|
|||
interface BootThemeFile {
|
||||
/** The resolved background hex that detection settled on, if any. */
|
||||
background?: string
|
||||
/** The config mode pin (`display.tui_theme`) active when this cache was
|
||||
* written. Without it a pinned theme caches incoherently — a light
|
||||
* resolved theme next to the dark PHYSICAL background — and the next
|
||||
* launch flashes light → dark (skin resolves against the seeded
|
||||
* background) → light (config pin rehydrates). */
|
||||
mode?: 'dark' | 'light'
|
||||
/** The fully-resolved Theme (palette + brand) from the last session. */
|
||||
theme?: Theme
|
||||
version: 1
|
||||
|
|
@ -55,8 +61,14 @@ const looksLikeTheme = (value: unknown): value is Theme => {
|
|||
)
|
||||
}
|
||||
|
||||
export interface BootTheme {
|
||||
background?: string
|
||||
mode?: 'dark' | 'light'
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
/** Read the cached boot theme. Null on first launch / damage / test runs. */
|
||||
export function readBootTheme(): { background?: string; theme: Theme } | null {
|
||||
export function readBootTheme(): BootTheme | null {
|
||||
if (isTestRun()) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -68,7 +80,11 @@ export function readBootTheme(): { background?: string; theme: Theme } | null {
|
|||
return null
|
||||
}
|
||||
|
||||
return { background: typeof raw.background === 'string' ? raw.background : undefined, theme: raw.theme }
|
||||
return {
|
||||
background: typeof raw.background === 'string' ? raw.background : undefined,
|
||||
mode: raw.mode === 'light' || raw.mode === 'dark' ? raw.mode : undefined,
|
||||
theme: raw.theme
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
@ -77,7 +93,7 @@ export function readBootTheme(): { background?: string; theme: Theme } | null {
|
|||
let writeTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/** Persist the resolved theme (debounced, atomic, fire-and-forget). */
|
||||
export function writeBootTheme(theme: Theme, background?: string): void {
|
||||
export function writeBootTheme(theme: Theme, background?: string, mode?: 'dark' | 'light'): void {
|
||||
if (isTestRun()) {
|
||||
return
|
||||
}
|
||||
|
|
@ -90,7 +106,7 @@ export function writeBootTheme(theme: Theme, background?: string): void {
|
|||
writeTimer = null
|
||||
|
||||
try {
|
||||
const payload: BootThemeFile = { background, theme, version: 1 }
|
||||
const payload: BootThemeFile = { background, mode, theme, version: 1 }
|
||||
const path = bootFilePath()
|
||||
const tmp = `${path}.tmp`
|
||||
|
||||
|
|
@ -104,25 +120,90 @@ export function writeBootTheme(theme: Theme, background?: string): void {
|
|||
writeTimer.unref?.()
|
||||
}
|
||||
|
||||
// ── Boot-time seeding (with provenance) ──────────────────────────────
|
||||
//
|
||||
// The cache seeds env slots so the first skin resolution matches the last
|
||||
// session — but a previous-session background must NOT occupy the same
|
||||
// authoritative slot as a current OSC answer forever. Provenance is
|
||||
// recorded so that once the CURRENT terminal answers with a distrusted
|
||||
// value (pure-black OSC-11, unusable OSC-10), the stale hint can be
|
||||
// invalidated and detection falls through to foreground / COLORFGBG /
|
||||
// macOS appearance / the default — instead of a light cache pinning a now-
|
||||
// dark terminal to light indefinitely (review on #20379, finding 2).
|
||||
|
||||
export interface BootSeedResult {
|
||||
/** The background hex this boot seeded into env, or null. */
|
||||
seededBackground: null | string
|
||||
/** True when the cached config pin was seeded into HERMES_TUI_THEME. */
|
||||
seededPin: boolean
|
||||
}
|
||||
|
||||
// Provenance of the last seeding pass — read by invalidateBootBackground.
|
||||
// Module-load seeds process.env; tests re-seed against a fake env.
|
||||
let seeded: BootSeedResult = { seededBackground: null, seededPin: false }
|
||||
|
||||
/** Seeding step (exported for tests — module-load runs it on process.env).
|
||||
* Explicit user signals always outrank the cache. Records provenance so
|
||||
* invalidateBootBackground can later demote the hint. */
|
||||
export function seedBootEnvironment(boot: BootTheme | null, env: NodeJS.ProcessEnv): BootSeedResult {
|
||||
const result: BootSeedResult = { seededBackground: null, seededPin: false }
|
||||
|
||||
seeded = result
|
||||
|
||||
if (!boot || env.HERMES_TUI_THEME || env.HERMES_TUI_LIGHT) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Replay the config mode pin first: a pinned session caches a resolved
|
||||
// theme whose polarity intentionally disagrees with the physical
|
||||
// background, and without the pin the first skin resolution flips to the
|
||||
// physical pole before config hydration flips it back (multi-stage flash).
|
||||
if (boot.mode) {
|
||||
env.HERMES_TUI_THEME = boot.mode
|
||||
result.seededPin = true
|
||||
}
|
||||
|
||||
if (
|
||||
boot.background &&
|
||||
// Never seed the untrusted "unset default" fingerprint — a cache written
|
||||
// before the distrust rule existed must not poison this session's
|
||||
// detection (it would also suppress the macOS-appearance fallback).
|
||||
boot.background.toLowerCase() !== '#000000' &&
|
||||
!env.HERMES_TUI_BACKGROUND
|
||||
) {
|
||||
env.HERMES_TUI_BACKGROUND = boot.background
|
||||
result.seededBackground = boot.background
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time seeding, run once at module load (imported by uiStore before the
|
||||
* first render): make the cached background visible to `detectLightMode`
|
||||
* unless an explicit signal already outranks it.
|
||||
* Invalidate the cache-seeded background: called when the CURRENT terminal
|
||||
* answered the background probe with an untrusted value, meaning the seeded
|
||||
* hint is from another era and must not outrank the live fallback chain.
|
||||
* Only clears the slot while it still holds the seeded value — a trusted
|
||||
* OSC answer or explicit export that overwrote it is left alone.
|
||||
* Returns true when the slot was cleared.
|
||||
*/
|
||||
export function invalidateBootBackground(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (!seeded.seededBackground || env.HERMES_TUI_BACKGROUND !== seeded.seededBackground) {
|
||||
return false
|
||||
}
|
||||
|
||||
delete env.HERMES_TUI_BACKGROUND
|
||||
seeded.seededBackground = null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const boot = readBootTheme()
|
||||
|
||||
if (
|
||||
boot?.background &&
|
||||
// Never seed the untrusted "unset default" fingerprint — a cache written
|
||||
// before the distrust rule existed must not poison this session's
|
||||
// detection (it would also suppress the macOS-appearance fallback).
|
||||
boot.background.toLowerCase() !== '#000000' &&
|
||||
!process.env.HERMES_TUI_BACKGROUND &&
|
||||
!process.env.HERMES_TUI_THEME &&
|
||||
!process.env.HERMES_TUI_LIGHT
|
||||
) {
|
||||
process.env.HERMES_TUI_BACKGROUND = boot.background
|
||||
}
|
||||
/** True when this boot replayed a cached config pin into HERMES_TUI_THEME.
|
||||
* applyConfiguredTuiTheme treats it as config-owned so a later 'auto' can
|
||||
* clear it — otherwise a stale cached pin masquerades as a user shell
|
||||
* export and becomes unclearable. */
|
||||
export const bootSeededPin: boolean = seedBootEnvironment(boot, process.env).seededPin
|
||||
|
||||
/** The cached theme for the first frame, or null on first launch. */
|
||||
export const bootTheme: Theme | null = boot?.theme ?? null
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue