Merge origin/main into lane/c3-memory-panel

This commit is contained in:
teknium1 2026-07-18 16:26:30 -07:00
commit 54f5696bbb
No known key found for this signature in database
23 changed files with 1252 additions and 89 deletions

View file

@ -6253,6 +6253,13 @@ def _resolve_task_provider_model(
cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
# Resolve key_env → env var when api_key is not set directly
if not cfg_api_key:
cfg_key_env = str(
task_config.get("key_env") or task_config.get("api_key_env") or ""
).strip()
if cfg_key_env:
cfg_api_key = os.getenv(cfg_key_env, "").strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
# 'auto' is a sentinel meaning "inherit from main runtime / auto-detect", not

View file

@ -46,6 +46,7 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from hermes_constants import get_hermes_home
from utils import is_truthy_value
@ -395,7 +396,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if active_profile == "default":
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under ~/.hermes/profiles/<name>/. Each profile has its own "
"under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
"session than this one. Do not modify another profile's "
"skills/plugins/cron/memories unless the user explicitly directs "
@ -404,9 +405,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
else:
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes ~/.hermes/profiles/{active_profile}/. The default "
f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a "
f"different session run from a different shell. Do NOT modify "
f"another profile's skills/plugins/cron/memories unless the user "
f"explicitly directs you to. The cross-profile write guard will "

View file

@ -2,7 +2,6 @@
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import {
parseMarkdownIntoBlocks,
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps,
@ -17,6 +16,7 @@ import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlig
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
downloadGatewayMediaFile,
@ -59,43 +59,6 @@ function preprocessWithTailRepair(text: string): string {
}
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed
// on the text — but the same text is re-lexed every time a message REMOUNTS
// (virtualizer scroll, session switch). A small module-level
// LRU keyed by the exact source string removes all of those repeat parses
// with zero correctness risk (same input → same output). Streaming tail
// growth misses the cache by design (every flush is a new string) — that
// single lex is the irreducible cost.
const BLOCK_CACHE_MAX = 64
const BLOCK_CACHE_MIN_LENGTH = 1024
const blockCache = new Map<string, string[]>()
function parseMarkdownIntoBlocksCached(markdown: string): string[] {
if (markdown.length < BLOCK_CACHE_MIN_LENGTH) {
return parseMarkdownIntoBlocks(markdown)
}
const hit = blockCache.get(markdown)
if (hit) {
// Refresh recency (Map iteration order is insertion order).
blockCache.delete(markdown)
blockCache.set(markdown, hit)
return hit
}
const blocks = parseMarkdownIntoBlocks(markdown)
blockCache.set(markdown, blocks)
if (blockCache.size > BLOCK_CACHE_MAX) {
blockCache.delete(blockCache.keys().next().value as string)
}
return blocks
}
async function mediaSrc(path: string): Promise<string> {
if (/^(?:https?|data):/i.test(path)) {
return path

View file

@ -39,7 +39,7 @@ import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolInlineDiff } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
@ -283,8 +283,9 @@ function ToolEntry({ part }: ToolEntryProps) {
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(stablePart)}`
const dismissed = useStore($toolRowDismissed(disclosureId))
const isPending = messageRunning && result === undefined
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
// Subscribe to this tool's diff only, so a live patch for one tool doesn't
// re-render every mounted tool row (the factory caches a per-id atom).
const sideDiff = useStore($toolInlineDiff(toolCallId ?? ''))
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const isFileEdit = isFileEditTool(toolName)
const defaultOpen = Boolean(inlineDiff)

View file

@ -0,0 +1,164 @@
import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
import { describe, expect, it } from 'vitest'
import { parseMarkdownIntoBlocksCached } from './markdown-blocks'
// The contract: streaming through the cached splitter (one call per growing
// prefix, exactly how Streamdown calls it per flush) must produce, at every
// step, the same blocks as a fresh full lex of that prefix. Byte equality —
// a divergence would change what the memoized block renderer paints.
const CORPUS = `# Heading
Intro paragraph with **bold**, [a link](https://example.com), \`inline\` and $x^2$ math.
- list item one
- list item two
- nested item
1. ordered a
2. loose ordered b
\`\`\`python
def f(x):
return x * 2 # comment with \`\`\` inside string? no — fence chars below
\`\`\`
A paragraph that will be followed by a setext underline
===
| col a | col b |
|---|---|
| 1 | 2 |
| 3 | 4 |
> blockquote line one
> blockquote line two
with a lazy continuation line
<div class="raw">
html block content
</div>
$$
\\int_0^1 x\\,dx = \\tfrac12
$$
Final paragraph after everything, long enough to stream in pieces so the tail
block keeps getting reinterpreted while earlier blocks stay settled.
`
// Deterministic PRNG so failures reproduce.
function mulberry32(seed: number) {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Push the text past the cache MIN_LENGTH thresholds so the incremental
// path actually engages.
const LONG_CORPUS = Array.from({ length: 6 }, () => CORPUS).join('\n')
describe('parseMarkdownIntoBlocksCached', () => {
it('matches a full lex at every random streaming cut (property)', () => {
for (let seed = 1; seed <= 5; seed++) {
const rand = mulberry32(seed)
let cursor = 0
while (cursor < LONG_CORPUS.length) {
cursor = Math.min(LONG_CORPUS.length, cursor + 1 + Math.floor(rand() * 120))
const prefix = LONG_CORPUS.slice(0, cursor)
expect(parseMarkdownIntoBlocksCached(prefix)).toEqual(parseMarkdownIntoBlocks(prefix))
}
}
})
it('matches a full lex when streaming token-by-token through a fence boundary', () => {
const base = `${'settled paragraph one.\n\n'.repeat(100)}opening a fence now:\n`
const tail = '```js\nconst a = 1\nconst b = 2\n```\n\nafter the fence\n'
for (let i = 1; i <= tail.length; i++) {
const text = base + tail.slice(0, i)
expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text))
}
})
it('reconstructs the input exactly (join property the offsets rely on)', () => {
const blocks = parseMarkdownIntoBlocksCached(LONG_CORPUS)
expect(blocks.join('')).toBe(LONG_CORPUS)
})
it('falls back to a full lex for non-append rewrites (edit / branch swap)', () => {
const grown = `${LONG_CORPUS}\n\nappended tail paragraph`
parseMarkdownIntoBlocksCached(grown)
// A REWRITE that shares no prefix lineage must still be correct.
const rewritten = `completely different start\n\n${LONG_CORPUS.slice(500)}`
expect(parseMarkdownIntoBlocksCached(rewritten)).toEqual(parseMarkdownIntoBlocks(rewritten))
})
it('matches a full lex when a trailing setext underline merges the previous block (regression)', () => {
// A trailing `-`/`=` line is a setext underline of the block ABOVE it, so
// appending to it can retroactively merge the previous parse's LAST TWO
// blocks into one. Cached `"…#e\n5\n-"` lexes to [ …, "#e\n", "5\n-" ], but
// grown to `"…#e\n5\n-p2=kj:c"` collapses `#e`/`5\n-` into one block. The
// old boundary dropped only the single last content block, so it reused a
// `"#e\n"` block that no longer exists. `blocks.join('') === text` still
// holds for the wrong split, so the reconstruction guard cannot catch it.
// The settled prefix pushes the text past the append-cache threshold so
// the incremental path actually engages.
const settled = 'settled line paragraph text.\n\n'.repeat(80)
const prev = `${settled}#e\n5\n-`
const grown = `${prev}p2=kj:c`
// Seed the append cache with `prev`, then grow it — the exact two-call
// sequence a streaming flush produces.
parseMarkdownIntoBlocksCached(prev)
expect(parseMarkdownIntoBlocksCached(grown)).toEqual(parseMarkdownIntoBlocks(grown))
})
// 12 seeds × 500 growing prefixes is ~6000 full+cached lexes; it first trips
// the pre-fix boundary at seed 11 / step 257, so the workload can't shrink
// without gutting the guard. The work is bounded but exceeds one test's 5s
// default budget, so raise the timeout rather than weaken the coverage.
it('matches a full lex at every char-level streaming cut over noisy markdown (property fuzz)', () => {
// Character-level append fuzz over the markdown control alphabet — the
// harness that surfaced the setext-underline merge above. Growing a single
// lineage one small chunk at a time keeps `startsWith` lineage intact so
// the incremental path runs on nearly every step; each prefix must
// deep-equal a fresh full lex.
const alphabet = '\n `#*-_>[]()|~:=abcdefghijklmnopqrstuvwxyz0123456789'
for (let seed = 1; seed <= 12; seed++) {
const rand = mulberry32(seed)
// Seed past the append-cache threshold so the incremental path engages.
let text = `seed ${seed}\n\n`.repeat(180)
for (let step = 0; step < 500; step++) {
const n = 1 + Math.floor(rand() * 24)
let chunk = ''
for (let j = 0; j < n; j++) {
chunk += alphabet[Math.floor(rand() * alphabet.length)]
}
text += chunk
expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text))
}
}
}, 30_000)
})

View file

@ -0,0 +1,136 @@
import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
/**
* Block splitting for the streaming markdown pipeline, without re-lexing the
* whole message on every token flush.
*
* `parseMarkdownIntoBlocks` is a full `marked` lex of the entire text
* measured 3.49.6ms per call at 64192KB. During streaming every flush is a
* new string, so the stock splitter pays that O(full-text) cost ~30×/s on
* long replies. Two caches remove it:
*
* 1. Exact-string LRU a message that REMOUNTS with unchanged text
* (virtualizer scroll, session switch) reuses its parse outright.
* 2. Streaming-append cache when the new text starts with a recently parsed
* text (the token-append case), the previous parse's blocks are reused up
* to a settled boundary and only the suffix is lexed. The boundary drops
* the previous parse's trailing whitespace-only blocks AND its last content
* block, because appended text can retroactively change how that last
* block parses (open fence, list/table continuation, setext underline, a
* lazy blockquote line). Blocks before it are separated by settled blank
* lines and cannot be affected. Cross-block reference links can't regress:
* Streamdown renders each block as an independent markdown document
* already. Verified property: `blocks.join('') === text`, and incremental
* output is asserted byte-identical to a full lex in tests across fences,
* lists, tables, setext headings, blockquotes, and HTML blocks.
*
* Any doubt no prefix match, reconstruction mismatch falls back to the
* full lex, i.e. exactly the previous behavior.
*/
const EXACT_CACHE_MAX = 64
const EXACT_CACHE_MIN_LENGTH = 1024
const exactCache = new Map<string, string[]>()
// Streaming messages grow monotonically, and only a handful stream at once
// (main reply + reasoning part, maybe a tile). A tiny ring is enough; each
// entry holds the last parse for one growing text lineage.
const APPEND_CACHE_MAX = 4
const APPEND_CACHE_MIN_LENGTH = 2048
const appendCache: { blocks: string[]; text: string }[] = []
function rememberAppend(text: string, blocks: string[]): void {
if (text.length < APPEND_CACHE_MIN_LENGTH) {
return
}
// Replace the lineage this text grew from (its cached prefix), else push.
const index = appendCache.findIndex(entry => text.startsWith(entry.text))
if (index !== -1) {
appendCache.splice(index, 1)
}
appendCache.push({ blocks, text })
if (appendCache.length > APPEND_CACHE_MAX) {
appendCache.shift()
}
}
function lexIncrementally(text: string): null | string[] {
const entry = appendCache.find(cached => text.length > cached.text.length && text.startsWith(cached.text))
if (!entry) {
return null
}
// Settled boundary: drop the last TWO content blocks (skipping any
// whitespace-only blocks around them). Dropping only the single last content
// block is unsound: appended text can retroactively merge the previous
// parse's last two blocks into one. The trigger is a trailing Setext
// underline — `marked` only treats `-`/`=` as an underline for the paragraph
// ABOVE it, so a settled `"#e\n5\n-"` lexes as ["#e\n", "5\n-"], but growing
// the tail to `"#e\n5\n-p2=kj:c"` collapses both into one paragraph. The
// block before the last is the deepest an append can reach (the underline
// consumes exactly one preceding block), so re-lexing the last two is safe;
// earlier blocks are fenced off by settled blank lines. join('') === text
// still holds either way, so the reconstruction check below can't catch this.
let keep = entry.blocks.length
for (let dropped = 0; dropped < 2 && keep > 0; dropped += 1) {
while (keep > 0 && !entry.blocks[keep - 1].trim()) {
keep -= 1
}
if (keep > 0) {
keep -= 1
}
}
if (keep === 0) {
return null
}
const settled = entry.blocks.slice(0, keep)
let settledLength = 0
for (const block of settled) {
settledLength += block.length
}
// Defensive reconstruction check — the splitter's join(blocks) === text
// property is what makes offsets exact. If it ever doesn't hold, full lex.
if (settledLength > entry.text.length || !text.startsWith(entry.text.slice(0, settledLength), 0)) {
return null
}
return [...settled, ...parseMarkdownIntoBlocks(text.slice(settledLength))]
}
export function parseMarkdownIntoBlocksCached(markdown: string): string[] {
if (markdown.length < EXACT_CACHE_MIN_LENGTH) {
return parseMarkdownIntoBlocks(markdown)
}
const hit = exactCache.get(markdown)
if (hit) {
// Refresh recency (Map iteration order is insertion order).
exactCache.delete(markdown)
exactCache.set(markdown, hit)
return hit
}
const blocks = lexIncrementally(markdown) ?? parseMarkdownIntoBlocks(markdown)
rememberAppend(markdown, blocks)
exactCache.set(markdown, blocks)
if (exactCache.size > EXACT_CACHE_MAX) {
exactCache.delete(exactCache.keys().next().value as string)
}
return blocks
}

View file

@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { invalidateProfileScopedQueries, queryClient } from './query-client'
function invalidated(key: unknown[]): boolean {
return queryClient.getQueryState(key)?.isInvalidated ?? false
}
describe('invalidateProfileScopedQueries', () => {
beforeEach(() => {
queryClient.clear()
})
it('invalidates profile-scoped caches and leaves account/global caches intact', () => {
const profileScoped = [
['hermes-config-record'],
['hermes-config-schema'],
['skills-list'],
['toolsets-list'],
['model-options', 'global'],
['command-palette', 'sessions'],
['session-picker', 'sessions']
]
const global = [
['billing', 'state'],
['billing', 'subscription'],
['marketplace-themes', 'all'],
['marketplace-themes-settings', 'x'],
['onboarding-model-options', 'y'],
['contrib-logs-tail']
]
for (const key of [...profileScoped, ...global]) {
queryClient.setQueryData(key, { seeded: true })
}
invalidateProfileScopedQueries()
for (const key of profileScoped) {
expect(invalidated(key), `${JSON.stringify(key)} should be invalidated`).toBe(true)
}
for (const key of global) {
expect(invalidated(key), `${JSON.stringify(key)} should be left intact`).toBe(false)
}
})
it('invalidates unknown/non-string-rooted keys by default (correctness-safe)', () => {
queryClient.setQueryData(['some-future-profile-query'], 1)
queryClient.setQueryData([{ scope: 'weird' }], 1)
invalidateProfileScopedQueries()
expect(invalidated(['some-future-profile-query'])).toBe(true)
expect(invalidated([{ scope: 'weird' }])).toBe(true)
})
})

View file

@ -18,3 +18,31 @@ export const writeCache =
<T>(key: QueryKey) =>
(next: T | undefined | ((prev: T | undefined) => T | undefined)): void =>
void queryClient.setQueryData<T>(key, next)
// Query-key roots that are NOT profile-scoped: account/billing, the theme
// marketplace, onboarding, and contrib log tails all read global or
// account-level state, so a profile/gateway swap must not refetch them. Any
// other key is treated as profile-scoped and invalidated -- a denylist is
// correctness-safe here: a root we forget to list just gets refetched (a small
// cost), whereas an allowlist that misses a profile-scoped key would paint the
// previous profile's data (a bug).
const PROFILE_INDEPENDENT_QUERY_ROOTS = new Set<string>([
'billing',
'marketplace-themes',
'marketplace-themes-settings',
'onboarding-model-options',
'contrib-logs-tail'
])
// Invalidate profile-scoped query caches on a profile / gateway switch, leaving
// account/global caches intact. Replaces a keyless invalidateQueries() that
// refetched everything (billing, marketplace, onboarding) on every switch.
export function invalidateProfileScopedQueries(): void {
void queryClient.invalidateQueries({
predicate: query => {
const root = query.queryKey[0]
return typeof root !== 'string' || !PROFILE_INDEPENDENT_QUERY_ROOTS.has(root)
}
})
}

View file

@ -19,7 +19,7 @@ import {
import { $gatewaySwitching, wipeSessionListsForGatewaySwitch } from './gateway-switch'
vi.mock('@/lib/query-client', () => ({
queryClient: { invalidateQueries: vi.fn() }
invalidateProfileScopedQueries: vi.fn()
}))
describe('wipeSessionListsForGatewaySwitch', () => {

View file

@ -1,6 +1,6 @@
import { atom } from 'nanostores'
import { queryClient } from '@/lib/query-client'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
import { resetSessionsLimit } from '@/store/layout'
import {
$unreadFinishedSessionIds,
@ -57,5 +57,7 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessages([])
setFreshDraftReady(true)
void queryClient.invalidateQueries()
// Narrowed: account/marketplace/onboarding caches are global, not gateway-
// scoped, so a mode swap must not refetch them.
invalidateProfileScopedQueries()
}

View file

@ -16,14 +16,14 @@ vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, prewarmProfileBackend, refreshProfiles } =
await import('./profile')
const { $connection } = await import('./session')
const { queryClient } = await import('@/lib/query-client')
const { invalidateProfileScopedQueries } = await import('@/lib/query-client')
const { getProfiles } = await import('@/hermes')
const profile = (name: string, isDefault = false): ProfileInfo => ({
@ -53,7 +53,7 @@ beforeEach(() => {
$connection.set(localConn())
$profiles.set([])
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
vi.mocked(queryClient.invalidateQueries).mockClear()
vi.mocked(invalidateProfileScopedQueries).mockClear()
resetStarmapGraph.mockClear()
})
@ -114,7 +114,7 @@ describe('profile-scoped cache invalidation', () => {
it('drops the memory graph cache when the active gateway profile changes', () => {
$activeGatewayProfile.set('coder')
expect(queryClient.invalidateQueries).toHaveBeenCalled()
expect(invalidateProfileScopedQueries).toHaveBeenCalled()
expect(resetStarmapGraph).toHaveBeenCalledTimes(1)
})
})

View file

@ -1,7 +1,7 @@
import { atom, computed } from 'nanostores'
import { getProfiles, setApiRequestProfile, STARTUP_REQUEST_TIMEOUT_MS } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
import {
arraysEqual,
persistBoolean,
@ -177,7 +177,9 @@ $activeGatewayProfile.subscribe(value => {
if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) {
// Profile-scoped settings + the unified session list are now stale.
void queryClient.invalidateQueries()
// Narrowed so account/marketplace/onboarding caches don't refetch on
// every profile switch.
invalidateProfileScopedQueries()
resetStarmapGraph()
}

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { $toolInlineDiff, getToolDiff, recordToolDiff } from './tool-diffs'
describe('tool-diffs per-tool subscriptions', () => {
it('returns a stable cached atom per toolCallId', () => {
expect($toolInlineDiff('a')).toBe($toolInlineDiff('a'))
expect($toolInlineDiff('a')).not.toBe($toolInlineDiff('b'))
})
it('notifies only the tool whose diff changed', () => {
const aCalls: string[] = []
const bCalls: string[] = []
const unsubA = $toolInlineDiff('notify-a').listen(v => aCalls.push(v))
const unsubB = $toolInlineDiff('notify-b').listen(v => bCalls.push(v))
recordToolDiff('notify-a', 'diffA')
expect(aCalls).toEqual(['diffA'])
expect(bCalls).toEqual([]) // the unrelated tool row is never notified
recordToolDiff('notify-b', 'diffB')
expect(aCalls).toEqual(['diffA']) // still not re-notified
expect(bCalls).toEqual(['diffB'])
unsubA()
unsubB()
})
it('does not re-notify when the same diff is recorded again', () => {
const calls: string[] = []
const unsub = $toolInlineDiff('same').listen(v => calls.push(v))
recordToolDiff('same', 'x')
recordToolDiff('same', 'x')
expect(calls).toEqual(['x'])
unsub()
})
it('reads the current diff for a tool and empty for unknown/blank ids', () => {
recordToolDiff('read-me', 'value')
expect(getToolDiff('read-me')).toBe('value')
expect(getToolDiff('missing')).toBe('')
expect(getToolDiff('')).toBe('')
expect($toolInlineDiff('').get()).toBe('')
})
})

View file

@ -1,7 +1,13 @@
import { atom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
const $toolDiffs = atom<Record<string, string>>({})
// Per-tool derived atoms, cached by toolCallId. A `ToolEntry` subscribes only
// to its own id's diff, so recording a diff for one tool re-renders that one
// row -- not every mounted tool row. computed() only notifies when the derived
// string actually changes, so unrelated writes to the map are inert here.
const inlineDiffCache = new Map<string, ReadableAtom<string>>()
export function recordToolDiff(toolCallId: string, diff: string) {
if (!toolCallId || !diff) {
return
@ -20,4 +26,13 @@ export function getToolDiff(toolCallId: string): string {
return toolCallId ? $toolDiffs.get()[toolCallId] || '' : ''
}
export const $toolInlineDiffs = $toolDiffs
export function $toolInlineDiff(toolCallId: string): ReadableAtom<string> {
let cached = inlineDiffCache.get(toolCallId)
if (!cached) {
cached = computed($toolDiffs, diffs => (toolCallId ? diffs[toolCallId] || '' : ''))
inlineDiffCache.set(toolCallId, cached)
}
return cached
}

View file

@ -7829,11 +7829,16 @@ def _quote_env_value(value: str) -> str:
"""Quote .env values containing characters with special dotenv meaning."""
if value == "":
return value
# Internal whitespace (space/tab/etc.) must be quoted so shell `set -a; . file`
# word-splits don't break paths like macOS "Application Support". Leading/
# trailing whitespace is already covered by the strip check; any() covers
# internal runs that strip() would leave alone.
needs_quoting = (
"#" in value
or '"' in value
or "'" in value
or value != value.strip()
or any(c.isspace() for c in value)
)
if not needs_quoting:
return value

View file

@ -2,6 +2,8 @@
from __future__ import annotations
import codecs
import io
import os
import sys
from pathlib import Path
@ -21,6 +23,12 @@ _CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY")
# tests) don't spam the same warning multiple times.
_WARNED_KEYS: set[str] = set()
# Paths we've already emitted a UTF-32 refuse-to-mangle warning for.
# load_hermes_dotenv can call _sanitize_env_file_if_needed multiple times
# for the same file (user env + project env + hot-reload); once per path
# is enough.
_WARNED_UTF32_PATHS: set[str] = set()
# Map of env-var name → source label ("bitwarden", etc.) for credentials
# that were injected by an external secret source during load_hermes_dotenv().
# Used by setup / `hermes model` flows to label detected credentials so
@ -176,6 +184,12 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
with ``ValueError: embedded null byte`` typically introduced by
copy-pasting API keys from terminals or rich-text editors.
Encoding: sniffs a leading BOM *before* any text decode. UTF-16
(Notepad "Unicode") is decoded correctly and rewritten as clean
UTF-8. UTF-32 is refused (left untouched) so we never fall through
to the errors=replace corruption path. Order of BOM checks matters:
UTF-32-LE's BOM starts with UTF-16-LE's FF FE.
We delegate to ``hermes_cli.config._sanitize_env_lines`` which
already knows all valid Hermes env-var names and can split
concatenated lines correctly.
@ -187,16 +201,70 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
except ImportError:
return # early bootstrap — config module not available yet
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
try:
with open(path, **read_kw) as f:
original = f.readlines()
raw = path.read_bytes()
except Exception:
return
# Sniff leading BOM bytes BEFORE decoding. ORDER MATTERS:
# codecs.BOM_UTF32_LE is FF FE 00 00, which startswith
# codecs.BOM_UTF16_LE (FF FE). Checking UTF-16 first would
# misdetect UTF-32-LE as UTF-16-LE and mangle the file.
force_utf8_rewrite = False
if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE):
# Lazy import keeps the module import block identical to #65124's
# codecs/io additions so the two PRs auto-merge either order.
path_key = str(path.resolve())
if path_key not in _WARNED_UTF32_PATHS:
_WARNED_UTF32_PATHS.add(path_key)
import logging
logging.getLogger(__name__).warning(
"Skipping .env sanitize for %s: UTF-32 BOM detected; "
"leaving file untouched to avoid corruption",
path,
)
return
if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE):
# "utf-16" uses the BOM to select endianness and strips it.
# TextIOWrapper + newline=None matches open()'s universal-newlines
# line splitting (\\n/\\r\\n/\\r only — not splitlines()'s extra
# Unicode boundaries like U+2028), so sanitize sees the same lines
# as the UTF-8 path.
try:
with io.TextIOWrapper(
io.BytesIO(raw), encoding="utf-16", newline=None
) as f:
original = f.readlines()
except UnicodeDecodeError:
return
# Source is UTF-16 on disk; always rewrite as clean UTF-8 so
# the subsequent utf-8 dotenv load sees a canonical file.
force_utf8_rewrite = True
else:
# Default path: utf-8-sig (strips UTF-8 BOM if present) with
# errors=replace so embedded NULs can be stripped below.
try:
with open(path, encoding="utf-8-sig", errors="replace") as f:
original = f.readlines()
except Exception:
return
# Defense-in-depth: errors=replace turns undecodable leading
# bytes into U+FFFD. Persisting that glues replacement chars
# onto the first key name and rewrites the file permanently
# (the UTF-16-with-BOM corruption path before BOM sniffing).
# Leave the file untouched rather than write the mangling.
if original and original[0].startswith("\ufffd"):
return
try:
# Strip null bytes before _sanitize_env_lines so they never
# reach python-dotenv (which passes them to os.environ and
# crashes with ValueError).
# crashes with ValueError). Also intentionally repairs
# BOM-less UTF-16 (NUL-padded ASCII) into clean UTF-8.
stripped = [line.replace("\x00", "") for line in original]
sanitized = _sanitize_env_lines(stripped)
if sanitized != original:
if sanitized != original or force_utf8_rewrite:
import tempfile
fd, tmp = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp", prefix=".env_"

View file

@ -443,7 +443,7 @@ function Get-PowerShellHostExe {
}
function Install-Uv {
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there --
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.ps1 and `hermes update` stay in sync.
@ -528,7 +528,7 @@ function Ensure-NodeExeOnPath {
# prior process is not visible here. Later stages (Test-Python,
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
# at the top to populate $script:UvCmd from the managed location.
# Throws if uv is not findable the caller's stage then surfaces a
# Throws if uv is not findable -- the caller's stage then surfaces a
# clean error via the stage-driver's try/catch.
function Resolve-UvCmd {
# Already resolved (default invocation path: Install-Uv ran earlier
@ -544,7 +544,7 @@ function Resolve-UvCmd {
# Stale; fall through to re-discover.
}
# Check the managed location first this is where Install-Uv puts it.
# Check the managed location first -- this is where Install-Uv puts it.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
@ -1516,7 +1516,7 @@ function Install-Repository {
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
# Managed installs should follow origin/$Branch exactly. If
# the checkout has diverged (or has local-only commits),
# ff-only pull cannot succeed mirror ``hermes update`` and
# ff-only pull cannot succeed -- mirror ``hermes update`` and
# reset to the fetched remote so bootstrap/install can recover.
git -c windows.appendAtomically=false pull --ff-only origin $Branch
if ($LASTEXITCODE -ne 0) {
@ -1573,11 +1573,11 @@ function Install-Repository {
Write-Host ""
Write-Host "Conflicted files:"
foreach ($file in $conflictedFiles) {
Write-Host " $file"
Write-Host " - $file"
}
}
Write-Host ""
Write-Info "Your stashed changes are preserved nothing is lost."
Write-Info "Your stashed changes are preserved -- nothing is lost."
Write-Info " Stash ref: $autostashRef"
git -c windows.appendAtomically=false reset --hard HEAD 2>$null | Out-Null
Write-Info "Working tree reset to clean state."
@ -1798,8 +1798,8 @@ function Install-Venv {
# /End stops a running task instance; /Change /DISABLE stops it
# from re-firing mid-install. (The Startup-folder .vbs fallback is
# NOT touched: it only fires at logon, so it cannot respawn a
# gateway mid-install.) Re-enabled in the finally below including
# on failure but only for tasks that were enabled to begin with.
# gateway mid-install.) Re-enabled in the finally below -- including
# on failure -- but only for tasks that were enabled to begin with.
# Best-effort: a missing task just errors quietly.
try {
schtasks /Query /FO CSV 2>$null | ConvertFrom-Csv | Where-Object { $_.TaskName -like '*Hermes_Gateway*' } | ForEach-Object {
@ -1893,7 +1893,7 @@ function Install-Venv {
}
# Clean up parked venvs from previous installs whose handles have since
# been released. Best-effort a still-held tree just stays for next time.
# been released. Best-effort -- a still-held tree just stays for next time.
Get-ChildItem -Directory -Filter "venv.stale.*" -ErrorAction SilentlyContinue | ForEach-Object {
Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue
}
@ -1926,10 +1926,10 @@ function Install-Venv {
} finally {
Pop-Location
# Re-arm the gateway autostart tasks disabled during the venv teardown
# in a finally so a failed teardown/creation can never strand the
# -- in a finally so a failed teardown/creation can never strand the
# user's gateway autostart in the disabled state. Same function scope,
# so the list survives even under the stage-per-process bootstrap.
# Deliberately NOT started here dependencies aren't installed yet;
# Deliberately NOT started here -- dependencies aren't installed yet;
# the task fires normally on next logon and `hermes update` / the
# gateway resume path handles the immediate restart.
if ($gatewayTasksDisabled -and $gatewayTasksDisabled.Count -gt 0) {
@ -2247,7 +2247,7 @@ function Set-PathVariable {
function Write-BootstrapMarker {
# Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes
# desktop app (apps/desktop/electron/main.ts) "install.ps1 ran
# successfully DON'T trigger the legacy first-launch bootstrap
# successfully -- DON'T trigger the legacy first-launch bootstrap
# runner."
#
# Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap
@ -2268,7 +2268,7 @@ function Write-BootstrapMarker {
# Resolve the pinned commit: explicit -Commit wins, otherwise read
# the checkout's HEAD via git. If git can't run, leave commit empty
# and the marker will fail desktop validation (pinnedCommit.length
# >= 7) better to be invalid than wrong.
# >= 7) -- better to be invalid than wrong.
$pinnedCommit = $Commit
if (-not $pinnedCommit) {
# PS 5.1 doesn't support the ?. null-conditional operator, so
@ -2283,7 +2283,7 @@ function Write-BootstrapMarker {
$pinnedCommit = $resolved.Trim()
}
} catch {
# Ignore pinnedCommit stays empty, marker stays invalid,
# Ignore -- pinnedCommit stays empty, marker stays invalid,
# desktop falls through to its legacy bootstrap path.
} finally {
Pop-Location
@ -2302,7 +2302,7 @@ function Write-BootstrapMarker {
pinnedCommit = $pinnedCommit
pinnedBranch = $pinnedBranch
completedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
# desktopVersion field intentionally omitted only the desktop
# desktopVersion field intentionally omitted -- only the desktop
# app knows its own version, and the marker validator doesn't
# require it. The desktop fills it in if/when it writes its
# own marker (e.g. after a future in-app upgrade).
@ -2311,7 +2311,7 @@ function Write-BootstrapMarker {
# Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8`
# always emits a BOM, and Node's plain JSON.parse rejects the BOM as an
# unexpected character so a BOM'd marker would silently fail the
# unexpected character -- so a BOM'd marker would silently fail the
# desktop's readJson(), make isBootstrapComplete() return null, and the
# desktop would re-run the legacy bootstrap runner anyway. Defeats the
# whole point. Use the .NET API directly for BOM-less UTF-8.
@ -2411,7 +2411,7 @@ function Install-NodeDeps {
# Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME
# in a fresh powershell.exe) means $script:HasNode set by Stage-Node
# in the previous process isn't visible here. Re-probe rather than
# trust the stale global Stage-Node already ran successfully or
# trust the stale global -- Stage-Node already ran successfully or
# the bootstrap would've aborted, so npm is reachable.
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Info "Skipping Node.js dependencies (Node not installed)"
@ -2688,7 +2688,7 @@ function Clear-ElectronBuildCache {
# Last-resort Electron mirror after GitHub download fails (#47266).
$script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/"
# Electron package dir workspace-local nest first, then root hoist.
# Electron package dir -- workspace-local nest first, then root hoist.
function Get-ElectronDir {
param([string]$InstallDir)
$desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron'
@ -2769,7 +2769,7 @@ function Install-Desktop {
#
# The Tauri bootstrap installer's launch_hermes_desktop command
# resolves apps/desktop/release/win-unpacked/Hermes.exe directly,
# so an "unpacked" build (electron-builder --dir) is enough we
# so an "unpacked" build (electron-builder --dir) is enough -- we
# don't need to produce an NSIS/MSI artifact here.
# Always re-resolve Node here. Stages run in separate PowerShell processes,
@ -2823,7 +2823,7 @@ function Install-Desktop {
#
# The streaming sink in bootstrap.rs's run_install_script
# captures every stdout/stderr line as it's emitted, so we don't
# need a side TEMP log file the installer's bootstrap log
# need a side TEMP log file -- the installer's bootstrap log
# IS the artifact a support engineer reads.
#
# Prefer `npm ci`: it wipes node_modules and reinstalls from the
@ -2878,7 +2878,7 @@ function Install-Desktop {
# NOT signing the output. Combined with signAndEditExecutable=false in
# apps/desktop/package.json's build.win block, electron-builder never
# invokes signtool and therefore never fetches/extracts winCodeSign
# (whose macOS symlinks crash 7-Zip on non-admin Windows a dead end we
# (whose macOS symlinks crash 7-Zip on non-admin Windows -- a dead end we
# are NOT trying to work around). The Hermes icon + product name are
# stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)
# AFTER this build, completely decoupled from electron-builder signing.
@ -2949,7 +2949,7 @@ function Install-Desktop {
Pop-Location
throw
} finally {
# Restore env to whatever the caller had don't leak our
# Restore env to whatever the caller had -- don't leak our
# signing-off override into anything install.ps1 invokes later
# (Stage-PlatformSdks, etc.).
$env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto
@ -2980,7 +2980,7 @@ function Install-Desktop {
# 3b. The Hermes icon + identity are stamped onto Hermes.exe by the
# electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs)
# during `npm run pack` above for every build, so the installer's
# during `npm run pack` above -- for every build, so the installer's
# --update rebuild stays branded too. No separate stamp step needed here.
# electron-builder's own rcedit step stays disabled (signAndEditExecutable
# =false) because enabling it drags in signtool -> winCodeSign -> the
@ -2990,7 +2990,7 @@ function Install-Desktop {
# directory. Chromium's GPU/renderer sandboxes CHECK-fail with
# 0x80000003 when this ACE is missing alongside orphan AppContainer
# SIDs under %LOCALAPPDATA% (electron/electron#51761, hermes-agent#38216).
# Best-effort never fail an otherwise-good install over ACL repair.
# Best-effort -- never fail an otherwise-good install over ACL repair.
try {
$appDir = Split-Path -Parent $desktopExe
& icacls $appDir /grant "*S-1-15-2-2:(OI)(CI)(RX)" /T /C /Q | Out-Null
@ -3006,7 +3006,7 @@ function Install-Desktop {
# 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
# Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
# command rebuilds (npm install + electron-builder) on every launch,
# which would cost minutes each time. The packed exe is the consumer
# which would cost minutes each time. The packed exe is the consumer --
# launching it directly is instant, and updates flow through the
# installer's --update path (which rebuilds once, then relaunches).
New-DesktopShortcuts -TargetExe $desktopExe
@ -3062,11 +3062,11 @@ function New-DesktopShortcuts {
# cached bitmap. Critical on the --update path: the exe was re-stamped
# with the Hermes icon, but without this the shortcut can keep drawing
# the old Electron icon until the user manually refreshes / reboots.
# Best-effort and silent never fail the install over a cosmetic cache.
# Best-effort and silent -- never fail the install over a cosmetic cache.
try {
& ie4uinit.exe -show 2>$null
} catch {
# ie4uinit may be absent/renamed on some SKUs ignore.
# ie4uinit may be absent/renamed on some SKUs -- ignore.
}
} catch {
Write-Warn "Skipping shortcut creation: $($_.Exception.Message)"

View file

@ -607,6 +607,210 @@ class TestSaveEnvValueSecure:
assert parsed["ANTHROPIC_TOKEN"] == token
assert load_env()["ANTHROPIC_TOKEN"] == token
def test_save_env_value_quotes_values_with_internal_spaces(self, tmp_path):
"""Internal spaces must be quoted so shell-sourcing does not word-split.
Sibling of installer #57247: core writer left
TERMINAL_SSH_KEY=/Users/.../Application Support/... unquoted.
python-dotenv still parsed it; ``set -a; . file`` failed.
"""
import subprocess
from dotenv import dotenv_values
path = "/Users/paulo/Library/Application Support/hermes/keys/id_ed25519"
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("TERMINAL_SSH_KEY", None)
save_env_value("TERMINAL_SSH_KEY", path)
env_path = tmp_path / ".env"
content = env_path.read_text(encoding="utf-8")
assert f'TERMINAL_SSH_KEY="{path}"' in content
parsed = dotenv_values(str(env_path))
assert parsed["TERMINAL_SSH_KEY"] == path
assert load_env()["TERMINAL_SSH_KEY"] == path
# Shell source must round-trip (this is what the bug broke).
r = subprocess.run(
[
"env",
"-i",
"sh",
"-c",
f"set -a; . '{env_path}'; set +a; "
f'printf "%s" "$TERMINAL_SSH_KEY"',
],
capture_output=True,
text=True,
)
assert r.returncode == 0, r.stderr
assert r.stderr == ""
assert r.stdout == path
def test_save_env_value_quotes_values_with_tabs(self, tmp_path):
"""Tabs trigger quoting; round-trip via dotenv and shell source."""
import subprocess
from dotenv import dotenv_values
value = "left\tright"
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("TABBY_KEY", None)
save_env_value("TABBY_KEY", value)
env_path = tmp_path / ".env"
content = env_path.read_text(encoding="utf-8")
assert f'TABBY_KEY="{value}"' in content
parsed = dotenv_values(str(env_path))
assert parsed["TABBY_KEY"] == value
assert load_env()["TABBY_KEY"] == value
r = subprocess.run(
[
"env",
"-i",
"sh",
"-c",
f"set -a; . '{env_path}'; set +a; "
f'printf "%s" "$TABBY_KEY"',
],
capture_output=True,
text=True,
)
assert r.returncode == 0, r.stderr
assert r.stderr == ""
assert r.stdout == value
def test_save_env_value_spaced_path_is_idempotent(self, tmp_path):
"""Saving the same spaced value twice must not grow quotes."""
path = "/Users/me/Application Support/key"
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("TERMINAL_SSH_KEY", None)
save_env_value("TERMINAL_SSH_KEY", path)
first = (tmp_path / ".env").read_text(encoding="utf-8")
save_env_value("TERMINAL_SSH_KEY", path)
second = (tmp_path / ".env").read_text(encoding="utf-8")
assert first == second
assert first.count('TERMINAL_SSH_KEY="') == 1
assert '""' not in first
assert f'TERMINAL_SSH_KEY="{path}"' in first
def test_save_env_value_readback_resave_is_idempotent(self, tmp_path):
"""hermes setup path: dotenv unquotes, then re-save must not grow quotes."""
from dotenv import dotenv_values
path = "/Users/me/Application Support/key"
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("TERMINAL_SSH_KEY", None)
save_env_value("TERMINAL_SSH_KEY", path)
first = (tmp_path / ".env").read_text(encoding="utf-8")
# Real read-back boundary (what setup uses via get_env_value/dotenv).
read_back = dotenv_values(str(tmp_path / ".env"))["TERMINAL_SSH_KEY"]
assert read_back == path
save_env_value("TERMINAL_SSH_KEY", read_back)
second = (tmp_path / ".env").read_text(encoding="utf-8")
assert first == second
assert f'TERMINAL_SSH_KEY="{path}"' in second
def test_save_env_value_strips_newlines_before_quoting(self, tmp_path):
"""save_env_value strips \\n/\\r before _quote_env_value; result is one line.
Pins the boundary so any(c.isspace()) never quotes multi-line dotenv
values through this writer (newlines never reach the quoter).
"""
from dotenv import dotenv_values
raw = "line1\nline2\rline3"
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("MULTI_KEY", None)
save_env_value("MULTI_KEY", raw)
content = (tmp_path / ".env").read_text(encoding="utf-8")
# Single KEY= line, no embedded raw newlines in the value payload.
lines = [ln for ln in content.splitlines() if ln.startswith("MULTI_KEY=")]
assert len(lines) == 1
assert "\n" not in lines[0]
assert "\r" not in lines[0]
# Newlines stripped -> "line1line2line3" has no whitespace -> unquoted.
assert lines[0] == "MULTI_KEY=line1line2line3"
parsed = dotenv_values(str(tmp_path / ".env"))
assert parsed["MULTI_KEY"] == "line1line2line3"
def test_save_env_value_simple_values_stay_unquoted(self, tmp_path):
"""No quoting churn: plain values remain bare; untouched lines unchanged."""
env_path = tmp_path / ".env"
# Pre-existing lines: one simple, one already correctly bare.
env_path.write_text(
"KEEP_SIMPLE=plainvalue\n"
"OTHER_KEY=foo123\n",
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("NEW_KEY", None)
os.environ.pop("KEEP_SIMPLE", None)
save_env_value("NEW_KEY", "bar-simple")
content = env_path.read_text(encoding="utf-8")
# Newly written simple value is unquoted.
assert "NEW_KEY=bar-simple\n" in content
assert 'NEW_KEY="' not in content
# Untouched pre-existing simple lines are not re-quoted.
assert "KEEP_SIMPLE=plainvalue\n" in content
assert "OTHER_KEY=foo123\n" in content
assert 'KEEP_SIMPLE="' not in content
assert 'OTHER_KEY="' not in content
def test_save_env_value_does_not_requote_untouched_spaced_lines(self, tmp_path):
"""Mass-requote guard: rewriting another key leaves legacy spaced
lines as-is (fix only applies when that key is saved again).
"""
env_path = tmp_path / ".env"
legacy = (
"TERMINAL_SSH_KEY=/Users/me/Application Support/key\n"
"PLAIN=ok\n"
)
env_path.write_text(legacy, encoding="utf-8")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("PLAIN", None)
save_env_value("PLAIN", "ok2")
content = env_path.read_text(encoding="utf-8")
# Legacy spaced line not re-serialized by this write.
assert (
"TERMINAL_SSH_KEY=/Users/me/Application Support/key\n" in content
)
assert 'TERMINAL_SSH_KEY="' not in content
assert "PLAIN=ok2\n" in content
def test_save_env_value_already_quoted_input_is_not_double_wrapped_idempotently(
self, tmp_path
):
"""Callers pass raw values; if a value literally contains quote
characters, escaping+wrap is the dialect (#57249). Re-saving the
same raw value is stable (no quote growth). load_env round-trips.
"""
# User-typed value that already includes surrounding quotes as data.
raw = '"/Users/me/Application Support/key"'
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("TERMINAL_SSH_KEY", None)
save_env_value("TERMINAL_SSH_KEY", raw)
first = (tmp_path / ".env").read_text(encoding="utf-8")
save_env_value("TERMINAL_SSH_KEY", raw)
second = (tmp_path / ".env").read_text(encoding="utf-8")
assert first == second
# One outer wrap layer only (escaped inner quotes, not nested wraps).
line = [
ln for ln in first.splitlines() if ln.startswith("TERMINAL_SSH_KEY=")
][0]
assert line.startswith('TERMINAL_SSH_KEY="')
assert line.endswith('"')
assert line.count('TERMINAL_SSH_KEY="') == 1
# Escaping dialect end-to-end: load sees the raw input, not stripped quotes.
assert load_env()["TERMINAL_SSH_KEY"] == raw
class TestRemoveEnvValue:
def test_removes_key_from_env_file(self, tmp_path):
@ -726,7 +930,7 @@ class TestSaveConfigAtomicity:
# Read raw YAML to verify it's valid and correct
config_path = tmp_path / "config.yaml"
with open(config_path) as f:
with open(config_path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
assert raw["model"] == "test/atomic-model"
assert raw["agent"]["max_turns"] == 77

View file

@ -1,3 +1,4 @@
import codecs
import importlib
import os
import sys
@ -103,3 +104,268 @@ def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
assert os.getenv("HERMES_INFERENCE_PROVIDER") == "custom"
# ---------------------------------------------------------------------------
# UTF-16 / UTF-32 .env sanitizer coverage
#
# Scope note: intentionally NO UTF-8-BOM assertions here. UTF-8 BOM handling
# for _load_dotenv_with_fallback is #65124's un-merged fix; a test here would
# couple the PRs. This suite covers only the sanitizer rewrite path for
# UTF-16/32 (and UTF-8 / cp1252 regression guards for that path).
# ---------------------------------------------------------------------------
def _assert_clean_utf8_env_on_disk(env_file, *, first_key: str) -> None:
"""On-disk file must be clean UTF-8: no BOM, no U+FFFD, canonical key."""
after = env_file.read_bytes()
assert not after.startswith(codecs.BOM_UTF8)
assert not after.startswith(codecs.BOM_UTF16_LE)
assert not after.startswith(codecs.BOM_UTF16_BE)
text = after.decode("utf-8") # strict — raises if not clean UTF-8
assert "\ufffd" not in text
assert text.startswith(f"{first_key}=") or f"\n{first_key}=" in text
assert first_key.encode("ascii") in after
def test_utf16_le_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):
"""Notepad 'Unicode' (UTF-16-LE + BOM): first key loads; file rewritten UTF-8."""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
monkeypatch.delenv("SECOND_KEY", raising=False)
monkeypatch.delenv("\ufffd\ufffdHERMES_TEST_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
assert os.getenv("SECOND_KEY") == "world"
assert os.environ.get("\ufffd\ufffdHERMES_TEST_KEY") is None
_assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
def test_utf16_be_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):
"""UTF-16-BE + BOM: first key loads; file rewritten as clean UTF-8."""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
env_file.write_bytes(codecs.BOM_UTF16_BE + content.encode("utf-16-be"))
monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
monkeypatch.delenv("SECOND_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
assert os.getenv("SECOND_KEY") == "world"
_assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
def test_utf16_le_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):
"""BOM-less UTF-16-LE: NUL-strip repair is now intentional; rewrites UTF-8."""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
env_file.write_bytes(content.encode("utf-16-le")) # no BOM
monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
monkeypatch.delenv("SECOND_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
assert os.getenv("SECOND_KEY") == "world"
_assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
def test_utf16_be_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):
"""BOM-less UTF-16-BE: NULs are on the opposite side; still repairs."""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
env_file.write_bytes(content.encode("utf-16-be")) # no BOM
monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
monkeypatch.delenv("SECOND_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
assert os.getenv("SECOND_KEY") == "world"
_assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
def test_utf16_le_bom_preserves_non_ascii_values(tmp_path, monkeypatch):
"""UTF-16-LE+BOM rewrite must preserve non-ASCII values (not just ASCII keys).
Uses non-credential var names so _sanitize_loaded_credentials does not
strip non-ASCII from values (that path only targets *_KEY/*_TOKEN/etc.).
"""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
content = "GREETING=café\nCJK_LABEL=日本語\n"
env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
monkeypatch.delenv("GREETING", raising=False)
monkeypatch.delenv("CJK_LABEL", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("GREETING") == "café"
assert os.getenv("CJK_LABEL") == "日本語"
after = env_file.read_bytes()
assert after.decode("utf-8") # strict
assert "café".encode("utf-8") in after
assert "日本語".encode("utf-8") in after
assert b"\xef\xbf\xbd" not in after
def test_utf32_le_bom_leaves_file_untouched(tmp_path, caplog):
"""UTF-32-LE BOM: refuse-to-mangle (leave bytes untouched + warning).
UTF-32-LE's BOM starts with UTF-16-LE's FF FE; sniff order must check
UTF-32 first so we never misdetect and corrupt.
Exercises ``_sanitize_env_file_if_needed`` only: the dotenv load path
is out of scope here (#65124's surface) and still cannot ingest UTF-32.
"""
import logging
from hermes_cli.env_loader import _sanitize_env_file_if_needed
env_file = tmp_path / ".env"
content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
env_file.write_bytes(raw)
with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
_sanitize_env_file_if_needed(env_file)
assert env_file.read_bytes() == raw # untouched
assert any("UTF-32" in r.message for r in caplog.records)
def test_utf32_be_bom_leaves_file_untouched(tmp_path, caplog):
"""UTF-32-BE BOM: same refuse-to-mangle path as LE (ordering independence)."""
import logging
from hermes_cli.env_loader import _sanitize_env_file_if_needed
env_file = tmp_path / ".env"
content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
raw = codecs.BOM_UTF32_BE + content.encode("utf-32-be")
env_file.write_bytes(raw)
with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
_sanitize_env_file_if_needed(env_file)
assert env_file.read_bytes() == raw
assert any("UTF-32" in r.message for r in caplog.records)
def test_utf32_warning_fires_once_per_path(tmp_path, caplog, monkeypatch):
"""Three sanitize calls on the same UTF-32 file → exactly one warning.
Matches house style for warn-once (module-level seen-set, same class as
``_WARNED_KEYS``): hot-reload / multi-entry load must not spam logs.
"""
import logging
import hermes_cli.env_loader as env_loader
from hermes_cli.env_loader import _sanitize_env_file_if_needed
# Isolate process-level seen-set so other tests' paths don't leak in.
monkeypatch.setattr(env_loader, "_WARNED_UTF32_PATHS", set())
env_file = tmp_path / ".env"
content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
env_file.write_bytes(raw)
with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
_sanitize_env_file_if_needed(env_file)
_sanitize_env_file_if_needed(env_file)
_sanitize_env_file_if_needed(env_file)
utf32_warnings = [r for r in caplog.records if "UTF-32" in r.message]
assert len(utf32_warnings) == 1
assert env_file.read_bytes() == raw
def test_leading_replacement_char_does_not_rewrite(tmp_path):
"""errors=replace FFFD-on-first-line guard: do not persist mangling.
Leading 0xFF is not a UTF-16/32 BOM (those need the second BOM byte) but
is undecodable as UTF-8, so the replace path would glue U+FFFD onto the
key. The guard must leave the on-disk bytes untouched.
"""
from hermes_cli.env_loader import _sanitize_env_file_if_needed
env_file = tmp_path / ".env"
raw = b"\xffHERMES_TEST_KEY=should-not-rewrite\nSECOND_KEY=ok\n"
env_file.write_bytes(raw)
_sanitize_env_file_if_needed(env_file)
assert env_file.read_bytes() == raw
def test_plain_utf8_env_regression(tmp_path, monkeypatch):
"""Plain UTF-8 .env must keep loading after the UTF-16 sanitize changes."""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
before = b"OPENAI_API_KEY=sk-plain\nSECOND_KEY=ok\n"
env_file.write_bytes(before)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("SECOND_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("OPENAI_API_KEY") == "sk-plain"
assert os.getenv("SECOND_KEY") == "ok"
# No spurious rewrite of an already-clean file.
assert env_file.read_bytes() == before
def test_cp1252_env_regression_does_not_crash(tmp_path, monkeypatch):
"""cp1252/latin-1 body must not crash sanitize; ASCII keys still usable.
0xE9 is 'é' in cp1252 and incomplete as UTF-8. First line does not begin
with U+FFFD, so the FFFD guard must not refuse the whole file.
Sanitize leaves the file bytes alone when the only "change" is
errors=replace on values (original already replace-decoded equals
sanitized), so _load_dotenv_with_fallback's latin-1 path recovers café.
"""
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
before = b"ASCII_KEY=ok\nLATIN1_VALUE=caf\xe9\n"
env_file.write_bytes(before)
monkeypatch.delenv("ASCII_KEY", raising=False)
monkeypatch.delenv("LATIN1_VALUE", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("ASCII_KEY") == "ok"
assert os.getenv("LATIN1_VALUE") == "café"
# Sanitize must not have rewritten (would have persisted U+FFFD).
assert env_file.read_bytes() == before

View file

@ -0,0 +1,55 @@
"""Regression: install.ps1 must stay pure ASCII.
Issues #66994 / #67000 reported the Windows GUI installer (Hermes-Setup.exe)
crashing before it did anything, with a cascade of PowerShell parser errors at
lines 1619 / 1770 ("Missing argument in parameter list", "A 'using' statement
must appear before any other statements in a script").
Root cause: ``scripts/install.ps1`` has no UTF-8 BOM, and a commit added two
non-ASCII characters *inside double-quoted string literals* (a bullet and an
em-dash). Windows PowerShell 5.1 -- which the bootstrap runs the cached script
under -- reads a BOM-less ``.ps1`` in the system ANSI code page (e.g. CP1252),
not UTF-8. The em-dash's UTF-8 tail byte (0x94) decodes to a "smart" close-quote
(U+201D), which the PowerShell tokenizer treats as a string delimiter. That
prematurely closes the string and desyncs the parser for the rest of the file,
surfacing as unrelated syntax errors far downstream.
Non-ASCII bytes inside ``#`` comments are harmless (the tokenizer skips a
comment to end-of-line regardless of what it contains), which is why the file
carried em-dashes in comments for months without breaking -- only a non-ASCII
byte in *code* (a string literal) triggers the desync.
This test is source-level because Linux CI cannot execute the PowerShell
installer. Keeping the whole file ASCII-only is the transport-independent
invariant: pure ASCII cannot be misdecoded under any code page, BOM or not, so
the bug class cannot recur -- in a comment or a string.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
def test_install_ps1_is_pure_ascii() -> None:
raw = INSTALL_PS1.read_bytes()
offenders = []
line_no = 1
for byte in raw:
if byte == 0x0A:
line_no += 1
elif byte >= 0x80:
offenders.append(line_no)
assert not offenders, (
"scripts/install.ps1 must be pure ASCII so Windows PowerShell 5.1 "
"(which reads a BOM-less .ps1 in the system ANSI code page, not UTF-8) "
"cannot misdecode a byte into a stray quote and desync the parser "
"(issues #66994 / #67000). Non-ASCII bytes found on line(s): "
f"{sorted(set(offenders))}. Use ASCII equivalents (em-dash -> '--', "
"bullet -> '-')."
)

View file

@ -0,0 +1,68 @@
"""End-to-end coverage for the MCP poll-loop OOM spin (#63892).
The unit tests in ``test_mcp_tool.py`` hand-construct completed futures to lock
the ``_run_on_mcp_loop`` contract deterministically. This complements them by
exercising the actual field trigger through the *live* MCP loop: an inner
``asyncio.wait_for`` expiry produces a real ``TimeoutError`` stored on a real
future scheduled via ``run_coroutine_threadsafe``. On Python >= 3.8 that class
is the builtin ``TimeoutError``, so before the fix the poll loop swallowed the
completed future's stored exception and spun with no sleep -- burning CPU,
growing the exception traceback ~108 MB/s until the gateway OOM'd, and finally
masking the real error behind the generic "MCP call timed out after <full
timeout>" wrapper.
The fixed loop must surface the real exception once, promptly -- long before
the outer deadline.
"""
from __future__ import annotations
import asyncio
import time
import pytest
@pytest.fixture
def mcp_loop():
import tools.mcp_tool as mcp_tool
mcp_tool._ensure_mcp_loop()
yield mcp_tool
mcp_tool._stop_mcp_loop()
def test_inner_wait_for_timeout_surfaces_promptly_without_spinning(mcp_loop):
async def inner():
# Real TimeoutError from wait_for, completing far before the outer
# deadline below -- the exact shape of an MCP call_tool wrapped in the
# server's configured mcp_servers.<srv>.timeout.
await asyncio.wait_for(asyncio.sleep(60), timeout=0.05)
start = time.monotonic()
with pytest.raises(TimeoutError) as exc:
mcp_loop._run_on_mcp_loop(inner, timeout=10)
elapsed = time.monotonic() - start
# Fixed: the real inner TimeoutError surfaces within a couple poll ticks.
# Broken: the loop swallows it and spins with no sleep until the 10s
# deadline, then raises the generic wrapper message instead. A generous
# (>= 2s) bound keeps this stable on a slow CI runner while still failing
# hard on a spin-to-deadline regression.
assert elapsed < 5.0, (
f"poll loop spun for {elapsed:.1f}s instead of resolving the completed "
"future once (#63892 regression)"
)
assert "MCP call timed out after" not in str(exc.value), (
"the real inner TimeoutError must surface, not the outer poll deadline"
)
def test_successful_call_still_returns_through_real_loop(mcp_loop):
"""Guard the done-future path against regressing normal success returns."""
async def inner():
await asyncio.sleep(0.25) # first polls time out while still pending
return {"ok": True}
assert mcp_loop._run_on_mcp_loop(inner, timeout=10) == {"ok": True}

View file

@ -4,6 +4,7 @@ All tests use mocks -- no real MCP servers or subprocesses are started.
"""
import asyncio
import concurrent.futures
import json
import threading
import time
@ -837,6 +838,23 @@ class TestToolHandler:
class TestRunOnMCPLoopInterrupts:
@staticmethod
def _run_with_future(mcp_mod, future):
loop = MagicMock()
loop.is_running.return_value = True
async def _unused_call():
return "unused"
def _schedule(coro, scheduled_loop, **_kwargs):
assert scheduled_loop is loop
coro.close()
return future
with patch.object(mcp_mod, "_mcp_loop", loop):
with patch("agent.async_utils.safe_schedule_threadsafe", side_effect=_schedule):
return mcp_mod._run_on_mcp_loop(_unused_call(), timeout=1)
def test_interrupt_cancels_waiting_mcp_call(self):
import tools.mcp_tool as mcp_mod
from tools.interrupt import set_interrupt
@ -922,6 +940,54 @@ class TestRunOnMCPLoopInterrupts:
mcp_mod._mcp_loop = old_loop
mcp_mod._mcp_thread = old_thread
def test_completed_future_timeout_is_propagated_once(self):
import tools.mcp_tool as mcp_mod
inner_error = TimeoutError("inner MCP timeout")
class CompletedWithTimeout(concurrent.futures.Future):
def __init__(self):
super().__init__()
self.result_timeouts = []
self.set_exception(inner_error)
def result(self, timeout=None):
self.result_timeouts.append(timeout)
return super().result(timeout=timeout)
future = CompletedWithTimeout()
with pytest.raises(TimeoutError, match="inner MCP timeout") as exc_info:
self._run_with_future(mcp_mod, future)
assert exc_info.value is inner_error
assert len(future.result_timeouts) == 2
assert future.result_timeouts[0] is not None
assert future.result_timeouts[1] is None
def test_poll_timeout_racing_success_returns_completed_result(self):
import tools.mcp_tool as mcp_mod
class PollThenSuccess(concurrent.futures.Future):
def __init__(self):
super().__init__()
self.result_timeouts = []
def result(self, timeout=None):
self.result_timeouts.append(timeout)
if len(self.result_timeouts) == 1:
self.set_result("completed")
raise concurrent.futures.TimeoutError
return super().result(timeout=timeout)
future = PollThenSuccess()
assert self._run_with_future(mcp_mod, future) == "completed"
assert len(future.result_timeouts) == 2
assert future.result_timeouts[0] is not None
assert future.result_timeouts[1] is None
# ---------------------------------------------------------------------------
# Tool registration (discovery + register)

View file

@ -3909,6 +3909,13 @@ def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
try:
return future.result(timeout=wait_timeout)
except concurrent.futures.TimeoutError:
# On supported Python versions, concurrent.futures.TimeoutError
# aliases the built-in TimeoutError, so result(timeout=...) also
# raises it for a coroutine's own timeout.
# Resolve a done future without a timeout to propagate its stored
# outcome, including completion racing with this polling timeout.
if future.done():
return future.result()
continue