mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
feat(desktop): disconnect external (CLI-managed) providers
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.
This commit is contained in:
parent
0e81d2fb71
commit
a0ec4f52b9
13 changed files with 178 additions and 21 deletions
|
|
@ -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 | string>(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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
|||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ afterEach(() => {
|
|||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
||||
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string, EnvVarInfo>): 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 && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{p.connected}
|
||||
</p>
|
||||
<GroupLabel>{p.connected}</GroupLabel>
|
||||
{connected.map(p => (
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
onTerminalDisconnect={onTerminalDisconnect}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -154,6 +171,7 @@ function OAuthPicker({
|
|||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={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 (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
|
|
@ -203,13 +226,13 @@ function ConnectedProviderRow({
|
|||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.settings.providers.connected}
|
||||
{copy.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{!canDisconnect && (
|
||||
{showHint && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{disconnectHint}
|
||||
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -228,6 +251,18 @@ function ConnectedProviderRow({
|
|||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
{terminalDisconnect && (
|
||||
<Button
|
||||
aria-label={`${copy.disconnect} ${title}`}
|
||||
onClick={() => onTerminalDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={copy.disconnectInTerminal}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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<OAuthProvider[]>([])
|
||||
|
|
@ -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
|
|||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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} を削除しました。`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} 已移除。`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue