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):