From a0ec4f52b948104cc91fb291153edb3a5bf6b52e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 23:37:53 -0500 Subject: [PATCH] feat(desktop): disconnect external (CLI-managed) providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External providers (Claude Code) store creds outside Hermes, so the disconnect API refuses them. The backend now hands the GUI a per-OS `disconnect_command` that clears the credential the same way the CLI's logout does (macOS Keychain entry + ~/.claude/.credentials.json), and the misleading "use claude setup-token" hint is corrected. Settings → Providers offers a Disconnect button for these: it confirms, leaves Settings, and runs the removal command in the embedded terminal via a new runInTerminal() (queues onto $terminalInjection; the terminal pane flushes and clears it once its session is live). The expanded list also gets its own "Other providers" header so it no longer reads as grouped under "Connected". API-managed providers keep the one-click (trash) disconnect. --- apps/desktop/src/app/right-sidebar/store.ts | 19 +++++ .../terminal/use-terminal-session.ts | 24 ++++++ apps/desktop/src/app/settings/index.tsx | 2 +- .../app/settings/providers-settings.test.tsx | 4 +- .../src/app/settings/providers-settings.tsx | 82 ++++++++++++++++--- apps/desktop/src/i18n/en.ts | 7 +- apps/desktop/src/i18n/ja.ts | 1 - apps/desktop/src/i18n/types.ts | 6 +- apps/desktop/src/i18n/zh-hant.ts | 1 - apps/desktop/src/i18n/zh.ts | 6 +- apps/desktop/src/types/hermes.ts | 3 + hermes_cli/web_server.py | 34 +++++++- tests/hermes_cli/test_web_oauth_dispatch.py | 10 ++- 13 files changed, 178 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/store.ts b/apps/desktop/src/app/right-sidebar/store.ts index 8c07f082450..b0e26f03886 100644 --- a/apps/desktop/src/app/right-sidebar/store.ts +++ b/apps/desktop/src/app/right-sidebar/store.ts @@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false)) $terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active)) export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active) + +/** A command queued to run in the embedded terminal. The terminal pane flushes + * (and clears) it once its session is live, so a value set before the pane + * mounts still runs. Cleared after flush so a later remount can't replay it. */ +export const $terminalInjection = atom(null) + +/** Open the terminal pane and run a command in it. Used to disconnect external + * (CLI-managed) providers, which Hermes can't clear via the API — the user + * sees exactly what runs instead of Hermes silently deleting their creds. */ +export const runInTerminal = (command: string) => { + const trimmed = command.trim() + + if (!trimmed) { + return + } + + setTerminalTakeover(true) + $terminalInjection.set(trimmed) +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 1e5d4d275b7..3479ed6db2f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { useTheme } from '@/themes/context' +import { $terminalInjection } from '../store' + import { makeTerminalReader, setActiveTerminalReader } from './buffer' import { isAddSelectionShortcut, @@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes return () => cancelAnimationFrame(raf) }, [activeTheme, themeName]) + // Flush a queued command (e.g. a provider-disconnect) into the live session. + // Only active while open; the subscribe fires immediately, so a command set + // before this pane mounted runs as soon as the session is ready. Clearing the + // atom after writing stops a later remount from replaying a stale command. + useEffect(() => { + if (status !== 'open') { + return + } + + return $terminalInjection.subscribe(command => { + const id = sessionIdRef.current + + if (!command || !id) { + return + } + + void window.hermesDesktop?.terminal?.write(id, `${command}\r`) + $terminalInjection.set(null) + termRef.current?.focus() + }) + }, [status]) + return { addSelectionToChat, hostRef, diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 6c832799eb2..ecf0f29377d 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang onMainModelChanged={onMainModelChanged} /> ) : activeView === 'providers' ? ( - + ) : activeView === 'keys' ? ( ) : activeView === 'mcp' ? ( diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 8379d203f6c..27c029b442c 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -55,7 +55,7 @@ afterEach(() => { async function renderProvidersSettings() { const { ProvidersSettings } = await import('./providers-settings') - return render() + return render() } describe('ProvidersSettings', () => { @@ -95,6 +95,6 @@ describe('ProvidersSettings', () => { expect(await screen.findByText('Qwen Code')).toBeTruthy() expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull() - expect(screen.getByText(/managed outside Hermes/)).toBeTruthy() + expect(screen.getByText(/managed by its own CLI/)).toBeTruthy() }) }) diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index f1132e6c33d..2585e13995d 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' +import { runInTerminal } from '@/app/right-sidebar/store' import { FEATURED_ID, FeaturedProviderRow, @@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' +// The embedded terminal (and thus the "run disconnect command" path) only +// exists in the Electron desktop shell, not the web dashboard. +const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal) + +// Parallel group headers ("Connected", "Other providers") so the expanded list +// reads as its own section instead of bleeding into the connected group. +function GroupLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ) +} + // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const @@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr function OAuthPicker({ disconnecting, onDisconnect, + onTerminalDisconnect, onWantApiKey, providers }: { disconnecting: null | string onDisconnect: (provider: OAuthProvider) => void + onTerminalDisconnect: (provider: OAuthProvider) => void onWantApiKey: () => void providers: OAuthProvider[] }) { @@ -138,15 +156,14 @@ function OAuthPicker({ {featured && } {connected.length > 0 && ( <> -

- {p.connected} -

+ {p.connected} {connected.map(p => ( ))} @@ -154,6 +171,7 @@ function OAuthPicker({ )} {showOthers && ( <> + {connected.length > 0 && {p.otherProviders}} {others.map(p => ( ))} @@ -180,21 +198,26 @@ function ConnectedProviderRow({ disconnecting, onDisconnect, onSelect, + onTerminalDisconnect, provider }: { disconnecting: boolean onDisconnect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void + onTerminalDisconnect: (provider: OAuthProvider) => void provider: OAuthProvider }) { const { t } = useI18n() + const copy = t.settings.providers const title = providerTitle(provider) const Trail = provider.flow === 'external' ? Terminal : ChevronRight + // Hermes can clear this provider's creds via the API. const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' - - const disconnectHint = provider.flow === 'external' - ? t.settings.providers.removeExternal(title, provider.cli_command) - : t.settings.providers.removeKeyManaged(title) + // External (CLI-managed) provider Hermes can't clear via the API, but ships a + // command we can run in the embedded terminal (Electron shell only). + const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal() + // Only fall back to a static "remove it elsewhere" hint when we offer no button. + const showHint = !canDisconnect && !terminalDisconnect return (
@@ -203,13 +226,13 @@ function ConnectedProviderRow({ {title} - {t.settings.providers.connected} + {copy.connected}

{t.onboarding.flowSubtitles[provider.flow]}

- {!canDisconnect && ( + {showHint && (

- {disconnectHint} + {provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}

)} @@ -228,6 +251,18 @@ function ConnectedProviderRow({ {disconnecting ? : } )} + {terminalDisconnect && ( + + )} ) @@ -243,7 +278,7 @@ function NoProviderKeys() { ) } -export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { +export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) { const { t } = useI18n() const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) @@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return () => void (cancelled = true) }, [onboardingActive]) + // External (CLI-managed) providers can't be cleared via the API by design — + // Hermes never deletes creds another tool owns behind a silent API call. + // Instead we run the documented removal command in the embedded terminal so + // the user sees exactly what executes, then return them to chat to watch it. + function handleTerminalDisconnect(provider: OAuthProvider) { + const command = provider.disconnect_command + + if (!command) { + return + } + + const name = providerTitle(provider) + + if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) { + return + } + + // Leave the settings overlay so the terminal pane (chat-only) is visible. + onClose() + runInTerminal(command) + notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) }) + } + async function handleDisconnect(provider: OAuthProvider) { const name = providerTitle(provider) @@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps void handleDisconnect(provider)} + onTerminalDisconnect={handleTerminalDisconnect} onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} /> @@ -359,6 +418,7 @@ interface ProviderKeyGroup { } interface ProvidersSettingsProps { + onClose: () => void onViewChange: (view: ProviderView) => void view: ProviderView } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 44c738da1b3..2710f8273f6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -565,9 +565,14 @@ export const en: Translations = { collapse: 'Collapse', connectAnother: 'Connect another provider', otherProviders: 'Other providers', + disconnect: 'Disconnect', + disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)', removeConfirm: provider => `Remove ${provider}?`, - removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`, + removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`, removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`, + removeTerminalConfirm: (provider, command) => + `Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`, + removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`, removedTitle: 'Account removed', removedMessage: provider => `${provider} was removed.`, failedRemove: provider => `Could not remove ${provider}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index b3719272a99..4f56ed46b65 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -695,7 +695,6 @@ export const ja = defineLocale({ connectAnother: '別のプロバイダーを接続', otherProviders: 'その他のプロバイダー', removeConfirm: provider => `${provider} を削除しますか?`, - removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`, removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`, removedTitle: 'アカウントを削除しました', removedMessage: provider => `${provider} を削除しました。`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d93769bbc59..58d78d4a384 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -447,9 +447,13 @@ export interface Translations { collapse: string connectAnother: string otherProviders: string + disconnect: string + disconnectInTerminal: string removeConfirm: (provider: string) => string - removeExternal: (provider: string, command: string) => string + removeExternalGeneric: (provider: string) => string removeKeyManaged: (provider: string) => string + removeTerminalConfirm: (provider: string, command: string) => string + removeTerminalRunning: (provider: string) => string removedTitle: string removedMessage: (provider: string) => string failedRemove: (provider: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a6607c53416..f01c94de738 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -672,7 +672,6 @@ export const zhHant = defineLocale({ connectAnother: '連結其他提供方', otherProviders: '其他提供方', removeConfirm: provider => `移除 ${provider}?`, - removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`, removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`, removedTitle: '帳號已移除', removedMessage: provider => `${provider} 已移除。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 2f3d22230a2..ea24026a5b1 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -759,9 +759,13 @@ export const zh: Translations = { collapse: '收起', connectAnother: '连接其他提供方', otherProviders: '其他提供方', + disconnect: '断开连接', + disconnectInTerminal: '断开连接(在终端中运行移除命令)', removeConfirm: provider => `移除 ${provider}?`, - removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`, + removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`, removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`, + removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`, + removeTerminalRunning: provider => `正在终端中断开 ${provider}…`, removedTitle: '账号已移除', removedMessage: provider => `${provider} 已移除。`, failedRemove: provider => `无法移除 ${provider}`, diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 627fe5e53e1..55019fb0827 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -47,6 +47,9 @@ export interface OAuthProviderStatus { export interface OAuthProvider { cli_command: string + /** Shell command that clears an external provider's credentials, run in the + * embedded terminal. Null when Hermes doesn't know how to remove it. */ + disconnect_command?: null | string disconnect_hint?: null | string disconnectable?: boolean docs_url: string diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a75a6468352..14e2a8a5ecc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5228,10 +5228,39 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: return {"logged_in": False} +def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]: + """Shell command that clears an external provider's credentials. + + External providers store their credentials outside Hermes, so the disconnect + API deliberately refuses them (we never delete files another CLI owns on the + user's behalf via a silent API call). For the ones we know how to clear we + instead hand the GUI a command it can *run in the embedded terminal* — the + user sees exactly what executes, and Hermes then stops resolving the token. + + Claude Code has no scriptable logout (only the interactive ``/logout``), so + we remove the credential the same way logout does: the macOS Keychain entry + (``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json`` + file — the two sources ``read_claude_code_credentials()`` consults. Returns + None for providers we can't safely clear (the GUI shows a manual hint). + """ + if provider.get("flow") != "external": + return None + if provider.get("id") == "claude-code": + rm_file = "rm -f ~/.claude/.credentials.json" + if sys.platform == "darwin": + return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}' + return rm_file + return None + + def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]: """Return the manual disconnect path when the API cannot clear this provider.""" if provider.get("flow") == "external": - return f"Use `{provider['cli_command']}` or that provider's CLI to remove it." + if _oauth_provider_disconnect_command(provider): + # The GUI offers a one-click "run in terminal" path; this hint is the + # fallback wording for surfaces that only show text. + return "Managed outside Hermes — run the disconnect command to remove it." + return "Managed by that provider's CLI; remove it there." if status.get("source") == "env_var": return "Remove the API key from Settings → Keys instead." return None @@ -5246,6 +5275,8 @@ async def list_oauth_providers(profile: Optional[str] = None): name human label flow "pkce" | "device_code" | "external" | "loopback" cli_command fallback CLI command for users to run manually + disconnect_command shell command that clears an external provider's + creds (run in the embedded terminal), else null docs_url external docs/portal link for the "Learn more" link status: logged_in bool — currently has usable creds @@ -5267,6 +5298,7 @@ async def list_oauth_providers(profile: Optional[str] = None): "cli_command": p["cli_command"], "docs_url": p["docs_url"], "disconnect_hint": disconnect_hint, + "disconnect_command": _oauth_provider_disconnect_command(p), "disconnectable": disconnect_hint is None, "status": status, }) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 9b1b853c93c..1d87573fe58 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -476,13 +476,21 @@ def test_oauth_catalog_marks_external_providers_not_disconnectable(): assert resp.status_code == 200, resp.text providers = {p["id"]: p for p in resp.json()["providers"]} + # Qwen: external and not auto-removable, and we don't know a clear command, + # so it stays a manual hint with no runnable disconnect command. assert providers["qwen-oauth"]["flow"] == "external" assert providers["qwen-oauth"]["disconnectable"] is False assert "provider's CLI" in providers["qwen-oauth"]["disconnect_hint"] + assert providers["qwen-oauth"]["disconnect_command"] is None + # Claude Code: still not API-disconnectable, but we hand the GUI a runnable + # command (clears the keychain entry / credentials file) so it can offer a + # one-click "run in terminal" disconnect. assert providers["claude-code"]["flow"] == "external" assert providers["claude-code"]["disconnectable"] is False - assert "provider's CLI" in providers["claude-code"]["disconnect_hint"] + assert providers["claude-code"]["disconnect_hint"] + cmd = providers["claude-code"]["disconnect_command"] + assert cmd and ".claude/.credentials.json" in cmd def test_external_oauth_disconnect_rejected_before_auth_mutation(monkeypatch):