Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,6 @@ jobs:

- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all

- name: Audit auth-store consumers
run: python scripts/check_auth_store_consumers.py
9 changes: 6 additions & 3 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,10 @@ def _nous_extra_body() -> dict:
_NOUS_MODEL = "google/gemini-3.6-flash"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
def _auth_json_path():
from hermes_cli.auth_authority import get_auth_store_path

return get_auth_store_path()

# Codex OAuth endpoint used when a caller explicitly requests
# provider="openai-codex". There is deliberately no hardcoded default
Expand Down Expand Up @@ -1814,9 +1817,9 @@ def _read_nous_auth() -> Optional[dict]:
}

try:
if not _AUTH_JSON_PATH.is_file():
if not _auth_json_path().is_file():
return None
data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8"))
data = json.loads(_auth_json_path().read_text(encoding="utf-8"))
if data.get("active_provider") != "nous":
return None
provider = data.get("providers", {}).get("nous", {})
Expand Down
6 changes: 5 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3856,7 +3856,11 @@ def _perform_api_call(next_api_kwargs):
print(f"{agent.log_prefix} Troubleshooting:")
print(f"{agent.log_prefix} • Re-authenticate: hermes auth add nous")
print(f"{agent.log_prefix} • Check credits / billing: https://portal.nousresearch.com")
print(f"{agent.log_prefix} • Verify stored credentials: {_dhh}/auth.json")
from hermes_cli.auth_authority import describe_auth_store
print(
f"{agent.log_prefix} • Verify stored credentials in the "
f"{describe_auth_store()}"
)
print(f"{agent.log_prefix} • Switch providers temporarily: /model <model> --provider openrouter")
if (
agent.provider == "copilot"
Expand Down
78 changes: 58 additions & 20 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
# device-code sources (nous, openai-codex, xAI) use ``device_code``.
if entry.source != "device_code":
return
write_through_state: Optional[Tuple[str, Dict[str, Any]]] = None
try:
with _auth_store_lock():
auth_store = _load_auth_store()
Expand Down Expand Up @@ -1117,9 +1118,12 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None

_save_auth_store(auth_store)
if write_through_to_root and _wt_provider_id:
_write_through_provider_state_to_global_root(
_wt_provider_id, state
)
# Defer the distinct root lock until the profile lock has
# been released. Authority-bound lock tracking correctly
# rejects nested lock acquisition for a different store.
write_through_state = (_wt_provider_id, dict(state))
if write_through_state is not None:
_write_through_provider_state_to_global_root(*write_through_state)
except Exception as exc:
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)

Expand All @@ -1133,19 +1137,21 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po
# sync→POST→write-back sequence below must run atomically across Hermes
# processes: otherwise two processes can both adopt the same on-disk
# token, both POST it, and the loser gets ``refresh_token_reused``.
# Serialize the whole sequence through the shared cross-process
# auth-store flock (the same lock and extended-timeout pattern used by
# resolve_codex_runtime_credentials()). When a waiter finally acquires
# the lock, the in-lock re-sync below picks up the rotated token the
# winner persisted and skips the POST.
# Serialize the whole sequence through the complete authority-bound
# auth-store lock set. In legacy profile->root fallback mode that means
# both stores: the token endpoint POST and write-back must own the root
# lock because root supplied the single-use token. When a waiter finally
# acquires the locks, the in-lock re-sync below picks up the rotated
# token the winner persisted and skips the POST.
if self.provider in ("openai-codex", "xai-oauth"):
sync_entry = (
self._sync_codex_entry_from_auth_store
if self.provider == "openai-codex"
else self._sync_xai_oauth_entry_from_pool_store
)
with _auth_store_lock(
timeout_seconds=self._single_use_refresh_lock_timeout()
with auth_mod._auth_store_locks(
include_legacy_fallback=True,
timeout_seconds=self._single_use_refresh_lock_timeout(),
):
synced = sync_entry(entry)
if self.provider == "openai-codex":
Expand Down Expand Up @@ -1336,7 +1342,10 @@ def _refresh_entry_impl(
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "xai-oauth") or {}
state, source_path = auth_mod._load_provider_state_with_source(
auth_store, "xai-oauth"
)
state = state or {}
if isinstance(state, dict):
tokens = state.get("tokens") or {}
if isinstance(tokens, dict):
Expand All @@ -1354,8 +1363,12 @@ def _refresh_entry_impl(
"relogin_required": True,
"at": datetime.now(timezone.utc).isoformat(),
}
_save_provider_state(auth_store, "xai-oauth", state)
_save_auth_store(auth_store)
auth_mod._save_provider_state_to_source(
auth_store,
"xai-oauth",
state,
source_path,
)
except Exception as clear_exc:
logger.debug(
"Failed to clear terminal xAI OAuth state: %s", clear_exc
Expand Down Expand Up @@ -1406,7 +1419,10 @@ def _refresh_entry_impl(
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
state, source_path = auth_mod._load_provider_state_with_source(
auth_store, "openai-codex"
)
state = state or {}
if isinstance(state, dict):
tokens = state.get("tokens") or {}
if isinstance(tokens, dict):
Expand All @@ -1424,8 +1440,12 @@ def _refresh_entry_impl(
"relogin_required": True,
"at": datetime.now(timezone.utc).isoformat(),
}
_save_provider_state(auth_store, "openai-codex", state)
_save_auth_store(auth_store)
auth_mod._save_provider_state_to_source(
auth_store,
"openai-codex",
state,
source_path,
)
except Exception as clear_exc:
logger.debug(
"Failed to clear terminal Codex OAuth state: %s", clear_exc
Expand Down Expand Up @@ -1467,7 +1487,10 @@ def _refresh_entry_impl(
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous") or {
state, source_path = auth_mod._load_provider_state_with_source(
auth_store, "nous"
)
state = state or {
"client_id": entry.client_id,
"portal_base_url": entry.portal_base_url,
"inference_base_url": entry.inference_base_url,
Expand All @@ -1488,8 +1511,12 @@ def _refresh_entry_impl(
exc,
reason="credential_pool_refresh_failure",
)
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
auth_mod._save_provider_state_to_source(
auth_store,
"nous",
state,
source_path,
)
except Exception as clear_exc:
logger.debug("Failed to clear terminal Nous OAuth state: %s", clear_exc)

Expand Down Expand Up @@ -2796,7 +2823,18 @@ def load_pool(provider: str) -> CredentialPool:
)
changed |= _normalize_pool_priorities(provider, entries)

if changed:
# A shared-authority profile reads the canonical root pool directly. Keep
# load-time healing in memory, but do not let a read rewrite shared bytes.
# Explicit mutations and OAuth refreshes still use the normal write paths.
try:
authority = auth_mod.resolve_auth_authority()
shared_profile_read = bool(
authority.profile_id and authority.effective_mode == "shared"
)
except Exception:
shared_profile_read = False

if changed and not shared_profile_read:
new_ids = {entry.id for entry in entries}
write_credential_pool(
provider,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/scripts/perf/lib/launch.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function defaultHermesSourceHome(): string
export function resolveViteBin(): string
export function seedConfigFrom(sourceHome: string, targetHome: string): void
31 changes: 15 additions & 16 deletions apps/desktop/scripts/perf/lib/launch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,26 @@ async function waitFor(fn, { timeoutMs, label }) {
// spawned instance reaches an empty chat view instead of the onboarding wizard.
// A separate HERMES_HOME dir means a separate gateway lock — no collision with
// the user's running app, which keeps its own sessions DB and state.
function seedConfigFrom(sourceHome, targetHome) {
if (!existsSync(sourceHome)) {
return
}

for (const name of ['config.yaml', '.env', 'auth.json']) {
const from = join(sourceHome, name)
export function seedConfigFrom(sourceHome, targetHome) {
if (!existsSync(sourceHome)) return

if (existsSync(from)) {
try {
copyFileSync(from, join(targetHome, name))
} catch {
// best-effort — a missing file just means onboarding may appear.
}
const from = join(sourceHome, 'config.yaml')
if (existsSync(from)) {
try {
copyFileSync(from, join(targetHome, 'config.yaml'))
} catch {
// best-effort — a missing file just means onboarding may appear.
}
}
}

export function defaultHermesSourceHome() {
return join(homedir(), '.hermes')
}

// Resolve the vite CLI entry via its package.json `bin` (Vite 8's `exports`
// blocks importing `vite/bin/vite.js` directly).
function resolveViteBin() {
export function resolveViteBin() {
const pkgPath = require.resolve('vite/package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite
Expand Down Expand Up @@ -188,7 +187,7 @@ export async function startIsolatedInstance({
const devUrl = prod ? null : `http://127.0.0.1:${devPort}`

if (seedConfig && !hermesHome) {
seedConfigFrom(join(homedir(), '.hermes'), home)
seedConfigFrom(defaultHermesSourceHome(), home)
}

const teardown = () => {
Expand Down Expand Up @@ -341,7 +340,7 @@ export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174,
// runs 1..N are the representative warm samples.
const home = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-home-'))
const userDataDir = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-ud-'))
seedConfigFrom(join(homedir(), '.hermes'), home)
seedConfigFrom(defaultHermesSourceHome(), home)

try {
for (let i = 0; i <= runs; i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,21 @@ import { type ComposerScope, ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '

import { useComposerDraft } from './use-composer-draft'

const mockComposerApi = { setText: vi.fn(), getState: () => ({ text: '' }) }
const mockComposerApi = { setText: vi.fn(), getState: vi.fn(() => ({ text: '' })) }
const mockComposerAccessor = vi.fn(() => mockComposerApi)
let mockAuiSubscriber: (() => void) | null = null

vi.mock('@assistant-ui/react', () => ({
useAui: () => ({ composer: () => mockComposerApi, subscribe: () => () => undefined }),
useAui: () => ({
composer: mockComposerAccessor,
subscribe: (subscriber: () => void) => {
mockAuiSubscriber = subscriber

return () => {
mockAuiSubscriber = null
}
}
}),
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } })
}))

Expand Down Expand Up @@ -44,6 +55,34 @@ function ProbeHarness({ activeQueueSessionKey, onLayoutSnapshot, sessionId }: Pr
return null
}

describe('useComposerDraft — assistant-ui client accessor contract', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
mockComposerAccessor.mockClear()
mockComposerApi.getState.mockClear()
mockComposerApi.setText.mockClear()
mockAuiSubscriber = null
})

it('resolves the composer accessor for both draft writes and subscription reads', () => {
render(
<ProbeHarness
activeQueueSessionKey="session-accessor"
onLayoutSnapshot={() => undefined}
sessionId="session-accessor"
/>
)

expect(mockComposerAccessor).toHaveBeenCalled()
expect(mockComposerApi.setText).toHaveBeenCalledWith('')

act(() => mockAuiSubscriber?.())

expect(mockComposerApi.getState).toHaveBeenCalled()
})
})

describe('useComposerDraft — attachment scope stays coherent with the committed session on switch (#59305)', () => {
afterEach(() => {
cleanup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
const setComposerText = useCallback(
(value: string) => {
try {
aui.composer.setText(value)
aui.composer().setText(value)
} catch {
// Composer core not bound yet — DOM/draftRef carry the text.
}
Expand Down Expand Up @@ -271,7 +271,7 @@
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
const sync = () => {
const text = aui.composer.getState().text
const text = aui.composer().getState().text
draftRef.current = text

const editor = editorRef.current
Expand Down Expand Up @@ -309,7 +309,7 @@
unsubscribe()
window.clearTimeout(draftPersistTimerRef.current)
}
}, [aui, queueEditRef])

Check warning on line 312 in apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

React Hook useEffect has a missing dependency: 'stashAt'. Either include it or remove the dependency array

const insertText = (text: string) => {
const base = draftRef.current
Expand Down Expand Up @@ -413,7 +413,7 @@
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [syncDraftFromEditor])

Check warning on line 416 in apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

React Hook useEffect has a missing dependency: 'stashAt'. Either include it or remove the dependency array

return {
activeQueueSessionKeyRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const AssistantMessage: FC<{
onDismissError?: (messageId: string) => void
}> = ({ onBranchInNewChat, onDismissError }) => {
const messageId = useAuiState(s => s.message.id)
const messageRuntime = useAui().message
const messageRuntime = useAui().message()
const { t } = useI18n()

// PERF: this component must NOT subscribe to the streaming text. Every
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function useTapbackDoubleClick(
role: ChatMessage['role']
): ((event: MouseEvent<HTMLElement>) => void) | undefined {
const enabled = useStore($reactionsEnabled)
const messageRuntime = useAui().message
const messageRuntime = useAui().message()

const onDoubleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
Expand Down
Loading
Loading