Skip to content
Open
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
55 changes: 55 additions & 0 deletions apps/desktop/electron/connection-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ import {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
effectiveRemoteToken,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveEnvRemoteAuth,
resolveInitialDisplayedRemoteAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
Expand Down Expand Up @@ -259,6 +262,58 @@ test('authModeFromStatus returns token when auth_required is false/missing', ()
assert.equal(authModeFromStatus(undefined), 'token')
})

// --- resolveEnvRemoteAuth ---

test('resolveEnvRemoteAuth keeps an explicit env token on the legacy token path', () => {
assert.deepEqual(resolveEnvRemoteAuth(' static-token '), {
authMode: 'token',
token: 'static-token'
})
})

test('effectiveRemoteToken never inherits a saved fallback token for an env-controlled URL', () => {
assert.equal(effectiveRemoteToken(true, '', 'saved-fallback-token'), '')
assert.equal(effectiveRemoteToken(true, ' env-token ', 'saved-fallback-token'), 'env-token')
assert.equal(effectiveRemoteToken(false, '', 'saved-fallback-token'), 'saved-fallback-token')
})

test('resolveInitialDisplayedRemoteAuthMode does not wait for a probe or inherit saved auth', () => {
assert.equal(resolveInitialDisplayedRemoteAuthMode(true, '', 'token'), 'oauth')
})

test('resolveInitialDisplayedRemoteAuthMode prefers an explicit env token', () => {
assert.equal(resolveInitialDisplayedRemoteAuthMode(true, ' env-token ', 'oauth'), 'token')
})

test('resolveInitialDisplayedRemoteAuthMode preserves saved auth without an env override', () => {
assert.equal(resolveInitialDisplayedRemoteAuthMode(false, '', 'oauth'), 'oauth')
})

test('resolveEnvRemoteAuth uses the advertised session flow when URL is set without a token', () => {
assert.deepEqual(resolveEnvRemoteAuth(null, { reachable: true, authMode: 'oauth' }), {
authMode: 'oauth',
token: null
})
assert.deepEqual(resolveEnvRemoteAuth(' ', { reachable: true, authMode: 'oauth' }), {
authMode: 'oauth',
token: null
})
})

test('resolveEnvRemoteAuth requires a token when the gateway does not advertise session auth', () => {
assert.throws(
() => resolveEnvRemoteAuth(null, { reachable: true, authMode: 'token' }),
/HERMES_DESKTOP_REMOTE_TOKEN.*does not advertise session authentication/i
)
})

test('resolveEnvRemoteAuth preserves probe failure context when auth mode cannot be determined', () => {
assert.throws(
() => resolveEnvRemoteAuth(null, { reachable: false, authMode: 'unknown', error: 'connect ECONNREFUSED' }),
/could not determine.*connect ECONNREFUSED/i
)
})

// --- resolveAuthMode ---

test('resolveAuthMode: explicit input wins over existing', () => {
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/electron/connection-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,57 @@ function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}

export function effectiveRemoteToken(envOverride, rawEnvToken, savedToken) {
const envToken = String(rawEnvToken || '').trim()

return envOverride ? envToken : savedToken
}

export function resolveInitialDisplayedRemoteAuthMode(
envOverride,
rawEnvToken,
savedAuthMode
): 'oauth' | 'token' {
if (!envOverride) {
return normAuthMode(savedAuthMode)
}

if (String(rawEnvToken || '').trim()) {
return 'token'
}

// Return immediately with the only tokenless mode that can connect. The
// renderer's existing async probe may switch the control to token auth, and
// the connect path independently probes again to enforce the contract.
return 'oauth'
}

export function resolveEnvRemoteAuth(rawToken, probe: any = null) {
const token = String(rawToken || '').trim()

if (token) {
return { authMode: 'token', token }
}

if (!probe || probe.reachable !== true) {
const detail = probe?.error ? `: ${probe.error}` : ''

throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set without HERMES_DESKTOP_REMOTE_TOKEN, ' +
`but Desktop could not determine the gateway authentication mode${detail}`
)
}

if (probe.authMode === 'oauth') {
return { authMode: 'oauth', token: null }
}

throw new Error(
'HERMES_DESKTOP_REMOTE_TOKEN is required because the configured gateway ' +
'does not advertise session authentication.'
)
}

/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
Expand Down
29 changes: 19 additions & 10 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ import {
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
effectiveRemoteToken,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveEnvRemoteAuth,
resolveInitialDisplayedRemoteAuthMode,
resolveTestWsUrl,
tokenPreview
} from './connection-config'
Expand Down Expand Up @@ -5896,9 +5899,14 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon

const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)

const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const envToken = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_TOKEN || '').trim() : ''
// An env-controlled URL must never inherit the saved fallback's token. A
// stale token would make Settings treat an unreachable URL-only override as
// resolved token auth and hide the session sign-in path.
const remoteToken = effectiveRemoteToken(envOverride, envToken, decryptDesktopSecret(block.token))
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const authMode = resolveInitialDisplayedRemoteAuthMode(envOverride, envToken, block.authMode)

// The env override forces a plain remote connection. Otherwise reflect the
// saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI
// reopens into the cloud picker; any non-remote-like value collapses to local.
Expand Down Expand Up @@ -6115,19 +6123,20 @@ async function resolveRemoteBackend(profile) {
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
}

// 2. Env override (global, token-auth only).
// 2. Env override (global). An explicit token preserves the legacy static-
// token path. URL-only overrides probe the public status endpoint so
// session-gated gateways can use the same sign-in flow as Settings.
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN

if (rawEnvUrl) {
if (!rawEnvToken) {
throw new Error(
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
'Both must be provided to connect to a remote Hermes backend.'
)
}
const envToken = String(rawEnvToken || '').trim()

const envAuth = envToken
? resolveEnvRemoteAuth(envToken)
: resolveEnvRemoteAuth(null, await probeRemoteAuthMode(rawEnvUrl))

return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
return buildRemoteConnection(rawEnvUrl, envAuth.authMode, envAuth.token, 'env')
}

// 3. Global remote (or cloud — cloud resolves to a remote backend, Q6).
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/app/settings/gateway-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { savedCloudConnectionUrl } from './gateway-settings'
import { remoteAuthControlsVisible, savedCloudConnectionUrl, shouldPersistRemoteBeforeSignIn } from './gateway-settings'

describe('savedCloudConnectionUrl', () => {
it('normalizes the URL of a persisted cloud connection', () => {
Expand All @@ -17,3 +17,14 @@ describe('savedCloudConnectionUrl', () => {
expect(savedCloudConnectionUrl({ mode: 'remote', remoteUrl: 'https://agent.example' })).toBe('')
})
})

describe('environment-controlled remote gateways', () => {
it('keeps sign-in controls visible while URL and token fields remain environment-controlled', () => {
expect(remoteAuthControlsVisible({ mode: 'remote', envOverride: true })).toBe(true)
})

it('does not overwrite saved connection settings before signing in', () => {
expect(shouldPersistRemoteBeforeSignIn(true)).toBe(false)
expect(shouldPersistRemoteBeforeSignIn(false)).toBe(true)
})
})
38 changes: 26 additions & 12 deletions apps/desktop/src/app/settings/gateway-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ export function savedCloudConnectionUrl(config: Pick<GatewaySettingsState, 'mode
return config.mode === 'cloud' ? config.remoteUrl.trim().replace(/\/+$/, '').toLowerCase() : ''
}

export function remoteAuthControlsVisible(config: Pick<GatewaySettingsState, 'mode' | 'envOverride'>): boolean {
// Environment overrides lock the URL/token fields but must not hide the
// session sign-in controls needed by tokenless remote URLs.
return config.mode === 'remote'
}

export function shouldPersistRemoteBeforeSignIn(envOverride: boolean): boolean {
return !envOverride
}

function ModeCard({
active,
description,
Expand Down Expand Up @@ -404,16 +414,20 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setSigningIn(true)

try {
// Save (don't apply/restart) so the login window has a URL to use and the
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
if (shouldPersistRemoteBeforeSignIn(state.envOverride)) {
// Save (don't apply/restart) so the login window has a URL to use and
// the OAuth mode is persisted, without yet flipping the live
// connection. Env-controlled connections already have an authoritative
// URL and must not overwrite the saved fallback configuration.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})

acceptSavedConfig(saved)
acceptSavedConfig(saved)
}

const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)

Expand Down Expand Up @@ -944,7 +958,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
</div>
) : null}

{state.mode === 'remote' && !state.envOverride ? (
{remoteAuthControlsVisible(state) ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
Expand Down Expand Up @@ -983,13 +997,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
<Pill tone="primary">
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
<Button disabled={signingIn} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
<Button disabled={signingIn || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const en: Translations = {
"The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
remoteTitle: 'Remote gateway sign-in required',
remoteDescription:
'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.',
'Hermes Desktop is not signed in to this remote gateway, or the previous session expired. Sign in to reconnect. Nothing here deletes your chats or settings.',
retry: 'Retry',
repairInstall: 'Repair install',
useLocalGateway: 'Use local gateway',
Expand Down Expand Up @@ -557,7 +557,7 @@ export const en: Translations = {
`Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`,
envOverrideTitle: 'Environment variables are controlling this desktop session.',
envOverrideDesc:
'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
'The gateway URL and optional token come from HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN. Session-based gateways can still be signed in or out below.',
modeTitle: 'Connection mode',
localTitle: 'Local gateway',
localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const ja = defineLocale({
'バックグラウンドゲートウェイが起動しませんでした。以下の回復手順をお試しください。チャットや設定は削除されません。',
remoteTitle: 'リモートゲートウェイへのサインインが必要です',
remoteDescription:
'リモートゲートウェイのセッションが期限切れです。再接続するにはもう一度サインインしてください。チャットや設定は削除されません。',
'Hermes Desktop がリモートゲートウェイに未サインインか、以前のセッションが期限切れです。再接続するにはサインインしてください。チャットや設定は削除されません。',
retry: '再試行',
repairInstall: 'インストールを修復',
useLocalGateway: 'ローカルゲートウェイを使用',
Expand Down Expand Up @@ -650,7 +650,7 @@ export const ja = defineLocale({
`"${profile}" がアクティブプロファイルのときのみ使用される接続。ローカルに設定するとデフォルトを継承します。`,
envOverrideTitle: '環境変数がこのデスクトップセッションを制御しています。',
envOverrideDesc:
'保存された設定を使用するには HERMES_DESKTOP_REMOTE_URL と HERMES_DESKTOP_REMOTE_TOKEN の設定を解除してください。',
'ゲートウェイURLと任意のトークンは HERMES_DESKTOP_REMOTE_URL と HERMES_DESKTOP_REMOTE_TOKEN から読み込まれます。セッション認証のゲートウェイは以下からサインイン・サインアウトできます。',
localTitle: 'ローカルゲートウェイ',
localDesc:
'ローカルホストでプライベートな Hermes バックエンドを起動します。これがデフォルトで、オフラインでも動作します。',
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ export const zhHant = defineLocale({
failure: {
title: 'Hermes 無法啟動',
description: '背景閘道未啟動。請嘗試下面的復原步驟。這裡的操作不會刪除您的聊天或設定。',
remoteTitle: '需要重新登入遠端閘道',
remoteDescription: '您的遠端閘道工作階段已過期。請重新登入以重新連線。這裡的操作不會刪除您的聊天或設定。',
remoteTitle: '需要登入遠端閘道',
remoteDescription:
'Hermes Desktop 尚未登入此遠端閘道,或先前的工作階段已過期。請登入以重新連線。這裡的操作不會刪除您的聊天或設定。',
retry: '重試',
repairInstall: '修復安裝',
useLocalGateway: '使用本機閘道',
Expand Down Expand Up @@ -636,7 +637,8 @@ export const zhHant = defineLocale({
defaultConnection: '預設連線適用於所有沒有自訂覆寫的設定檔。',
profileConnection: profile => `僅當「${profile}」為作用中設定檔時使用此連線。設為本機可繼承預設連線。`,
envOverrideTitle: '環境變數正在控制此桌面工作階段。',
envOverrideDesc: '取消設定 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 後才會使用下方儲存的設定。',
envOverrideDesc:
'閘道 URL 和選用權杖來自 HERMES_DESKTOP_REMOTE_URL 與 HERMES_DESKTOP_REMOTE_TOKEN。使用工作階段驗證的閘道仍可在下方登入或登出。',
localTitle: '本機閘道',
localDesc: '在 localhost 啟動私有 Hermes 後端。這是預設方式,可離線使用。',
remoteTitle: '遠端閘道',
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ export const zh: Translations = {
failure: {
title: 'Hermes 无法启动',
description: '后台网关没有启动。请尝试下面的恢复步骤;这里不会删除你的对话或设置。',
remoteTitle: '需要重新登录远程网关',
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
remoteTitle: '需要登录远程网关',
remoteDescription:
'Hermes Desktop 尚未登录此远程网关,或之前的会话已过期。请登录以恢复连接。这些操作不会删除你的对话或设置。',
retry: '重试',
repairInstall: '修复安装',
useLocalGateway: '使用本地网关',
Expand Down Expand Up @@ -747,7 +748,8 @@ export const zh: Translations = {
defaultConnection: '默认连接会用于所有没有自定义覆盖的 profile。',
profileConnection: profile => `仅当“${profile}”是当前 profile 时使用此连接。设为本地即可继承默认连接。`,
envOverrideTitle: '环境变量正在控制此桌面会话。',
envOverrideDesc: '取消设置 HERMES_DESKTOP_REMOTE_URL 和 HERMES_DESKTOP_REMOTE_TOKEN 后才会使用下面保存的设置。',
envOverrideDesc:
'网关 URL 和可选令牌来自 HERMES_DESKTOP_REMOTE_URL 与 HERMES_DESKTOP_REMOTE_TOKEN。使用会话认证的网关仍可在下方登录或退出。',
modeTitle: '连接模式',
localTitle: '本地网关',
localDesc: '在 localhost 启动私有 Hermes 后端。这是默认方式,并且可离线工作。',
Expand Down
Loading