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
34 changes: 34 additions & 0 deletions apps/desktop/electron/connection-config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}

/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}

const rawPath = String(path || '')
if (!rawPath) {
return path
}

let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
return path
}

if (parsed.searchParams.has('profile')) {
return path
}

parsed.searchParams.set('profile', scopedProfile)

return `${parsed.pathname}${parsed.search}${parsed.hash}`
}

function tokenPreview(value) {
const raw = String(value || '')

Expand Down Expand Up @@ -247,6 +280,7 @@ module.exports = {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/electron/connection-config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
Expand Down Expand Up @@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride(null, 'coder'), null)
})

// --- pathWithGlobalRemoteProfile ---

test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=iris'
)
})

test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/options?force=1&profile=iris'
)
})

test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info?profile=default'
)
})

test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: false,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
globalRemote: true,
profileRemoteOverride: true
}),
'/api/model/info'
)
})

test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', '', {
globalRemote: true,
profileRemoteOverride: false
}),
'/api/model/info'
)
assert.equal(
pathWithGlobalRemoteProfile('', 'iris', {
globalRemote: true,
profileRemoteOverride: false
}),
''
)
})

// --- normalizeRemoteBaseUrl ---

test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const {
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
Expand Down Expand Up @@ -5612,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {

await prepareProfileDeleteRequest(request)

const connection = await ensureBackend(request?.profile)
const profile = request?.profile
const connection = await ensureBackend(profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
globalRemote: globalRemoteActive(),
profileRemoteOverride: profileHasRemoteOverride(profile)
})
const url = `${connection.baseUrl}${requestPath}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition β€” route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
Expand Down
27 changes: 17 additions & 10 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5763,18 +5763,24 @@ def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
# subscription-feature checks) call it many times per render β€” `hermes tools` β†’ "All Platforms"
# was firing the refresh ~31Γ— during one menu paint, racking up >13s of HTTP and burning
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
# path + mtime so that profile switches do not share a process memo and
# `hermes auth login/logout/add/remove` invalidate naturally on the next call.
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None


def _auth_file_mtime() -> Optional[float]:
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
auth_file = _auth_file_path()
try:
return _auth_file_path().stat().st_mtime
auth_file_key = str(auth_file.resolve(strict=False))
except Exception:
auth_file_key = str(auth_file)
try:
return auth_file_key, auth_file.stat().st_mtime
except FileNotFoundError:
return None
return auth_file_key, None
except Exception:
return None
return auth_file_key, None


def invalidate_nous_auth_status_cache() -> None:
Expand Down Expand Up @@ -5806,18 +5812,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
"""
global _nous_auth_status_cache
now = time.monotonic()
mtime = _auth_file_mtime()
auth_file_key, mtime = _auth_file_cache_key()
cached = _nous_auth_status_cache
if cached is not None:
cached_at, cached_mtime, cached_status = cached
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
if (
cached_mtime == mtime
cached_auth_file_key == auth_file_key
and cached_mtime == mtime
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
):
return dict(cached_status)

status = _compute_nous_auth_status()
_nous_auth_status_cache = (now, mtime, dict(status))
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
return status


Expand Down
Loading
Loading