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:
Brooklyn Nicholson 2026-07-21 19:06:25 -05:00
parent b11b5ece2e
commit e6cc5612e6
3 changed files with 259 additions and 23 deletions

View 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')
})
})

View file

@ -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
}

View file

@ -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