Skip to content
Merged
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
27 changes: 24 additions & 3 deletions .github/workflows/contributor-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,33 @@ jobs:

- name: Check for unmapped contributor emails
id: check-emails
env:
BASE_REF: ${{ github.base_ref }}
EVENT_NAME: ${{ github.event_name }}
run: |
# Get the merge base between this PR and main
MERGE_BASE=$(git merge-base origin/main HEAD)
# PRs may target a custom base branch, including in a fork whose main
# has diverged. Non-PR callers retain the existing origin/main fallback.
if [ "$EVENT_NAME" = "pull_request" ]; then
if [ -z "$BASE_REF" ]; then
echo "::error::pull_request event did not provide a base ref"
exit 1
fi
REMOTE_BASE_REF="refs/remotes/origin/${BASE_REF}"
if ! git check-ref-format "$REMOTE_BASE_REF" >/dev/null; then
echo "::error::pull_request event provided an invalid base ref"
exit 1
fi
if ! BASE_COMMIT=$(git rev-parse --verify "${REMOTE_BASE_REF}^{commit}"); then
echo "::error::pull_request base ref was not fetched"
exit 1
fi
MERGE_BASE=$(git merge-base "$BASE_COMMIT" HEAD)
else
MERGE_BASE=$(git merge-base origin/main HEAD)
fi

# Find any new author emails in this PR's commits
NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u)
NEW_EMAILS=$(git log "${MERGE_BASE}..HEAD" --format='%ae' --no-merges | sort -u)

if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
Expand Down
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 @@ -775,7 +775,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 @@ -1823,9 +1826,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 @@ -3924,7 +3924,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 (
_is_copilot_provider(agent)
Expand Down
86 changes: 64 additions & 22 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,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 @@ -1086,11 +1087,15 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
else:
return

global_root = _global_auth_file_path()
# Resolve through the authority module at call time. Besides
# keeping one authority source of truth, this avoids a stale
# function binding when tests or embedders replace the path
# resolver after credential_pool was imported.
global_root = auth_mod._global_auth_file_path()
is_from_root = bool(
source_path is not None
and global_root is not None
and _same_path(source_path, global_root)
and auth_mod._same_path(source_path, global_root)
)

if self.provider == "nous":
Expand Down Expand Up @@ -1141,16 +1146,19 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
# _load_provider_state has root fallback, so the
# profile can always read fresh tokens from root
# without needing its own providers block.
_write_through_provider_state_to_global_root(
_wt_provider_id, state
)
# Defer the distinct root write 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))
else:
# Profile genuinely owns this provider — write to
# the profile store as normal.
_store_provider_state(
auth_store, self.provider, state, set_active=False
)
_save_auth_store(auth_store)
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 @@ -1164,19 +1172,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 @@ -1367,7 +1377,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 @@ -1385,8 +1398,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 @@ -1437,7 +1454,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 @@ -1455,8 +1475,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 @@ -1498,7 +1522,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 @@ -1519,8 +1546,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 @@ -2855,7 +2886,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
55 changes: 55 additions & 0 deletions apps/desktop/src/lib/perf-launch-auth-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'

import { afterEach, describe, expect, it } from 'vitest'

import { defaultHermesSourceHome, resolveViteBin, seedConfigFrom } from '../../scripts/perf/lib/launch.mjs'

const roots: string[] = []

function tempRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'hermes-desktop-auth-isolation-'))
roots.push(root)

return root
}

afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})

describe('desktop isolated perf launch', () => {
it('resolves the installed Vite CLI entry', () => {
const viteBin = resolveViteBin()

expect(existsSync(viteBin)).toBe(true)
expect(basename(viteBin)).toBe('vite.js')
expect(basename(dirname(viteBin))).toBe('bin')
})

it('uses the current OS home for the default Hermes config source', () => {
expect(defaultHermesSourceHome()).toBe(join(homedir(), '.hermes'))
})

it('copies only non-secret config and leaves auth acquisition to the backend', () => {
const root = tempRoot()
const source = join(root, 'source')
const target = join(root, 'target')
mkdirSync(source)
mkdirSync(target)
writeFileSync(join(source, 'config.yaml'), 'model:\n provider: nous\n')
writeFileSync(join(source, '.env'), 'NOUS_API_KEY=secret\n')
writeFileSync(join(source, 'auth.json'), '{"access_token":"secret"}\n')

seedConfigFrom(source, target)

expect(readFileSync(join(target, 'config.yaml'), 'utf8')).toBe(
'model:\n provider: nous\n'
)
expect(existsSync(join(target, '.env'))).toBe(false)
expect(existsSync(join(target, 'auth.json'))).toBe(false)
})
})
Loading
Loading