diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 425e63b15f21..1a2fa4f6f93f 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -23,12 +23,15 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + effectiveRemoteToken, modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, + resolveEnvRemoteAuth, + resolveInitialDisplayedRemoteAuthMode, resolveTestWsUrl, RT_COOKIE_VARIANTS, tokenPreview @@ -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', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 569f9cc07261..17ada09d9750 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -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'. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index af25ae7712ab..ea43578537d0 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -45,12 +45,15 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + effectiveRemoteToken, modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, + resolveEnvRemoteAuth, + resolveInitialDisplayedRemoteAuthMode, resolveTestWsUrl, tokenPreview } from './connection-config' @@ -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. @@ -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). diff --git a/apps/desktop/src/app/settings/gateway-settings.test.ts b/apps/desktop/src/app/settings/gateway-settings.test.ts index a221a4c5acee..5ea021ff5b1c 100644 --- a/apps/desktop/src/app/settings/gateway-settings.test.ts +++ b/apps/desktop/src/app/settings/gateway-settings.test.ts @@ -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', () => { @@ -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) + }) +}) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index ca7ce2384f9c..f2e725d0c5ff 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -48,6 +48,16 @@ export function savedCloudConnectionUrl(config: Pick): 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, @@ -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) @@ -944,7 +958,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { ) : null} - {state.mode === 'remote' && !state.envOverride ? ( + {remoteAuthControlsVisible(state) ? (
{g.signedIn} -
) : ( - diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 49517fc8902a..82f1c87af803 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', @@ -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.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4d6d1e67ec94..999eb135adb5 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -86,7 +86,7 @@ export const ja = defineLocale({ 'バックグラウンドゲートウェイが起動しませんでした。以下の回復手順をお試しください。チャットや設定は削除されません。', remoteTitle: 'リモートゲートウェイへのサインインが必要です', remoteDescription: - 'リモートゲートウェイのセッションが期限切れです。再接続するにはもう一度サインインしてください。チャットや設定は削除されません。', + 'Hermes Desktop がリモートゲートウェイに未サインインか、以前のセッションが期限切れです。再接続するにはサインインしてください。チャットや設定は削除されません。', retry: '再試行', repairInstall: 'インストールを修復', useLocalGateway: 'ローカルゲートウェイを使用', @@ -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 バックエンドを起動します。これがデフォルトで、オフラインでも動作します。', diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index d8e1f420d001..ac94c9d2ccac 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -83,8 +83,9 @@ export const zhHant = defineLocale({ failure: { title: 'Hermes 無法啟動', description: '背景閘道未啟動。請嘗試下面的復原步驟。這裡的操作不會刪除您的聊天或設定。', - remoteTitle: '需要重新登入遠端閘道', - remoteDescription: '您的遠端閘道工作階段已過期。請重新登入以重新連線。這裡的操作不會刪除您的聊天或設定。', + remoteTitle: '需要登入遠端閘道', + remoteDescription: + 'Hermes Desktop 尚未登入此遠端閘道,或先前的工作階段已過期。請登入以重新連線。這裡的操作不會刪除您的聊天或設定。', retry: '重試', repairInstall: '修復安裝', useLocalGateway: '使用本機閘道', @@ -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: '遠端閘道', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 18d1448e3c3b..f904e65328bd 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -83,8 +83,9 @@ export const zh: Translations = { failure: { title: 'Hermes 无法启动', description: '后台网关没有启动。请尝试下面的恢复步骤;这里不会删除你的对话或设置。', - remoteTitle: '需要重新登录远程网关', - remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。', + remoteTitle: '需要登录远程网关', + remoteDescription: + 'Hermes Desktop 尚未登录此远程网关,或之前的会话已过期。请登录以恢复连接。这些操作不会删除你的对话或设置。', retry: '重试', repairInstall: '修复安装', useLocalGateway: '使用本地网关', @@ -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 后端。这是默认方式,并且可离线工作。', diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 0e2811bcae29..35740fbbca78 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -505,7 +505,8 @@ Three dashboard-auth providers ship in the box. For a remote Hermes Desktop conn | `HERMES_DASHBOARD_OIDC_ISSUER` | OIDC issuer URL for the bundled self-hosted OIDC provider (`plugins/dashboard_auth/self_hosted`). Required to activate it. Overrides `dashboard.oauth.self_hosted.issuer`. | | `HERMES_DASHBOARD_OIDC_CLIENT_ID` | Public OIDC client id (authorization-code + PKCE) for the self-hosted OIDC provider. Required to activate it. Overrides `dashboard.oauth.self_hosted.client_id`. | | `HERMES_DASHBOARD_OIDC_SCOPES` | Requested OIDC scopes for the self-hosted OIDC provider (default `openid profile email`). Overrides `dashboard.oauth.self_hosted.scopes`. | -| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL; you still sign in from the Gateway settings panel (OAuth redirect or username/password, whichever the backend advertises). | +| `HERMES_DESKTOP_REMOTE_URL` | (Desktop side) Base URL of the remote backend, e.g. `http://host:9119`. When set, overrides the in-app Gateway URL. Without `HERMES_DESKTOP_REMOTE_TOKEN`, Desktop probes the gateway's advertised session provider and lets you sign in from Gateway settings (OAuth redirect or username/password). | +| `HERMES_DESKTOP_REMOTE_TOKEN` | Static token paired with `HERMES_DESKTOP_REMOTE_URL`. It is optional when the gateway advertises session authentication; otherwise it remains required. When set, Desktop uses legacy token authentication instead of the session sign-in flow. | | `HERMES_DESKTOP_HERMES` | Desktop backend command override. Used by packagers/Nix or troubleshooting to point Electron at a specific `hermes` executable after backend probing. | | `HERMES_DESKTOP_HERMES_ROOT` | Desktop source-checkout override used by `hermes desktop --hermes-root`; checked before the packaged first-launch install or an existing `hermes` on `PATH`. | | `HERMES_DESKTOP_IGNORE_EXISTING` | Set to `1` to make Desktop ignore an existing `hermes` on `PATH` during backend resolution. Equivalent to `hermes desktop --ignore-existing`. |