Credential cleanup and provider removal fixes

NousResearch/hermes-agent · cluster 51071 · agent

Data as of

Nothing open on GitHub. Fix merged in #60514. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #60514. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #67213. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple bugs and fixes related to API key deletion leaving stale credential pool entries, causing removed providers to persist in model pickers across .env, auth.json, and config.yaml. Changes aim to ensure provider removal is fully propagated through all credential storage layers.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60514 fix: limit desktop model pickers to explicit providers (#56974)

    merged to main

    files: apps/desktop/src/app/chat/index.tsx, apps/desktop/src/app/settings/model-settings.test.tsx, apps/desktop/src/components/model-visibility-dialog.tsx, apps/desktop/src/components/onboarding/flow.tsx, apps/desktop/src/components/onboarding/index.tsx, apps/desktop/src/hermes.test.ts, apps/desktop/src/hermes.ts, apps/desktop/src/lib/model-options.ts, apps/desktop/src/store/onboarding.ts, hermes_cli/auth.py, hermes_cli/inventory.py, hermes_cli/web_server.py, scripts/release.py, tests/hermes_cli/test_auth_provider_gate.py, tests/hermes_cli/test_inventory.py, tests/hermes_cli/test_web_server_profile_unification.py, tests/test_tui_gateway_server.py, tui_gateway/server.py, ui-tui/src/components/modelPicker.tsx, web/src/components/ModelPickerDialog.tsx (+2 more)

    Show the diff
    • apps/desktop/src/app/chat/index.tsx
      @@ -358,7 +358,10 @@ export function ChatView({
               throw new Error('Hermes gateway unavailable')
             }
       
      -      return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
      +      return gateway.request<ModelOptionsResponse>('model.options', {
      +        session_id: activeSessionId,
      +        explicit_only: true
      +      })
           },
           enabled: gatewayOpen
         })
    • apps/desktop/src/app/settings/model-settings.test.tsx
      @@ -13,8 +13,10 @@ beforeAll(() => {
       const getGlobalModelInfo = vi.fn()
       const getGlobalModelOptions = vi.fn()
       const getAuxiliaryModels = vi.fn()
      +const getMoaModels = vi.fn()
       const setModelAssignment = vi.fn()
       const getRecommendedDefaultModel = vi.fn()
      +const saveMoaModels = vi.fn()
       const setEnvVar = vi.fn()
       const getHermesConfigRecord = vi.fn()
       const saveHermesConfig = vi.fn()
      @@ -24,8 +26,10 @@ vi.mock('@/hermes', () => ({
         getGlobalModelInfo: () => getGlobalModelInfo(),
         getGlobalModelOptions: () => getGlobalModelOptions(),
         getAuxiliaryModels: () => getAuxiliaryModels(),
      +  getMoaModels: () => getMoaModels(),
         setModelAssignment: (body: unknown) => setModelAssignment(body),
         getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
      +  saveMoaModels: (body: unknown) => saveMoaModels(body),
         setEnvVar: (key: string, value: string) => setEnvVar(key, value),
         getHermesConfigRecord: () => getHermesConfigRecord(),
         saveHermesConfig: (config: unknown) => saveHermesConfig(config)
      @@ -45,15 +49,6 @@ beforeEach(() => {
               models: ['hermes-4', 'hermes-4-mini'],
               authenticated: true,
               capabilities: { 'hermes-4': { reasoning: true, fast: true } }
      -      },
      -      // An unconfigured api_key provider — surfaced by the full-universe payload.
      -      {
      -        name: 'DeepSeek',
      -        slug: 'deepseek',
      -        models: [],
      -        authenticated: false,
      -        auth_type: 'api_key',
      -        key_env: 'DEEPSEEK_API_KEY'
             }
           ]
         })
      @@ -61,8 +56,9 @@ beforeEach(() => {
           main: { provider: 'nous', model: 'hermes-4' },
           tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
         })
      +  getMoaModels.mockResolvedValue(null)
         setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
      -  getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
      +  getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
         setEnvVar.mockResolvedValue({ ok: true })
         getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
         saveHermesConfig.mockResolvedValue({ ok: true })
      @@ -80,43 +76,19 @@ async function renderModelSettings() {
       }
       
       describe('ModelSettings', () => {
      -  it('loads the current main model and lists the full provider universe', async () => {
      +  it('loads the current main model and lists configured providers only', async () => {
           await renderModelSettings()
       
           await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
           await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
       
      -    // Open the provider Select — every provider from the full payload should be
      -    // listed, including the unconfigured one with its "set up" hint.
      +    // Open the provider Select — only configured providers should be listed.
           const triggers = await screen.findAllByRole('combobox')
           fireEvent.click(triggers[0])
       
      -    // "Nous" shows in both the trigger and the open list; the unconfigured
      -    // provider + its setup hint are the unique signal of the full universe.
      +    // "Nous" shows in both the trigger and the open list.
           expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
      -    expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
      -    expect(await screen.findByText(/set up/)).toBeTruthy()
      -  })
      -
      -  it('activates an unconfigured api_key provider inline by saving its key', async () => {
      -    await renderModelSettings()
      -
      -    await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
      -
      -    // Open the provider Select and pick the unconfigured provider.
      -    const triggers = screen.getAllByRole('combobox')
      -    fireEvent.click(triggers[0])
      -    const deepseekOption = await screen.findByText(/DeepSeek/)
      -    fireEvent.click(deepseekOption)
      -
      -    // The inline key input appears for an api_key provider that needs setup.
      -    const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
      -    fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
      -
      -    const activate = await screen.findByRole('button', { name: /Activate/ })
      -    fireEvent.click(activate)
      -
      -    await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
      +    expect(screen.queryByText(/DeepSeek/)).toBeNull()
         })
       
         it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
    • apps/desktop/src/components/model-visibility-dialog.tsx
      @@ -45,7 +45,10 @@ export function ModelVisibilityDialog({
           queryKey: ['model-options', sessionId || 'global'],
           queryFn: (): Promise<ModelOptionsResponse> => {
             if (gw && sessionId) {
      -        return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
      +        return gw.request<ModelOptionsResponse>('model.options', {
      +          session_id: sessionId,
      +          explicit_only: true
      +        })
             }
       
             return getGlobalModelOptions()
    • apps/desktop/src/components/onboarding/flow.tsx
      @@ -237,7 +237,7 @@ function ConfirmingModelPanel({
         // shows the same $/Mtok + Free/Pro info the picker and CLI do.
         const options = useQuery({
           queryKey: ['onboarding-model-options', flow.providerSlug],
      -    queryFn: () => getGlobalModelOptions()
      +    queryFn: () => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
         })
       
         const providerRow = options.data?.providers?.find(
    • apps/desktop/src/components/onboarding/index.tsx
      @@ -99,7 +99,7 @@ function useApiKeyCatalog(): ApiKeyOption[] {
           // Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
           // tests) is funneled into the same .catch instead of escaping.
           void Promise.resolve()
      -      .then(() => getGlobalModelOptions())
      +      .then(() => getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false }))
             .then(res => {
               if (!cancelled) {
                 setRows(res.providers ?? [])
    • apps/desktop/src/hermes.test.ts
      @@ -100,7 +100,7 @@ describe('Hermes REST session helpers', () => {
             [getHermesConfig, '/api/config'],
             [getHermesConfigDefaults, '/api/config/defaults'],
             [getGlobalModelInfo, '/api/model/info'],
      -      [() => getGlobalModelOptions(), '/api/model/options'],
      +      [() => getGlobalModelOptions(), '/api/model/options?explicit_only=1'],
             [getCronJobs, '/api/cron/jobs']
           ]
       
      @@ -134,4 +134,24 @@ describe('Hermes REST session helpers', () => {
             profile: 'xiaoxuxu'
           })
         })
      +
      +  it('defaults model options to configured providers only', async () => {
      +    await getGlobalModelOptions()
      +
      +    expect(api).toHaveBeenCalledWith(
      +      expect.objectContaining({
      +        path: '/api/model/options?explicit_only=1'
      +      })
      +    )
      +  })
      +
      +  it('can opt into unconfigured providers for onboarding flows', async () => {
      +    await getGlobalModelOptions({ includeUnconfigured: true, refresh: true, explicitOnly: false })
      +
      +    expect(api).toHaveBeenCalledWith(
      +      expect.objectContaining({
      +        path: '/api/model/options?refresh=1&include_unconfigured=1'
      +      })
      +    )
      +  })
       })
    • apps/desktop/src/hermes.ts
      @@ -869,10 +869,28 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
         })
       }
       
      -export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
      +export function getGlobalModelOptions(opts?: {
      +  refresh?: boolean
      +  includeUnconfigured?: boolean
      +  explicitOnly?: boolean
      +}): Promise<ModelOptionsResponse> {
      +  const params = new URLSearchParams()
      +
      +  if (opts?.refresh) {
      +    params.set('refresh', '1')
      +  }
      +
      +  if (opts?.includeUnconfigured) {
      +    params.set('include_unconfigured', '1')
      +  }
      +
      +  if (opts?.explicitOnly !== false) {
      +    params.set('explicit_only', '1')
      +  }
      +
         return window.hermesDesktop.api<ModelOptionsResponse>({
           ...profileScoped(),
      -    path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options',
      +    path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
           timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
         })
       }
    • apps/desktop/src/lib/model-options.ts
      @@ -1,12 +1,17 @@
       import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'
       
       interface ModelOptionsRequest {
      +  /** When false, include ambient/unconfigured providers (onboarding/setup
      +   *  surfaces). Chat pickers default to true so only explicitly configured
      +   *  providers are listed (#56974). */
      +  explicitOnly?: boolean
         gateway?: HermesGateway
         refresh?: boolean
         sessionId?: null | string
       }
       
       export function requestModelOptions({
      +  explicitOnly = true,
         gateway,
         refresh = false,
         sessionId
      @@ -22,8 +27,12 @@ export function requestModelOptions({
             params.refresh = true
           }
       
      +    if (explicitOnly) {
      +      params.explicit_only = true
      +    }
      +
           return gateway.request<ModelOptionsResponse>('model.options', params)
         }
       
      -  return getGlobalModelOptions(refresh ? { refresh: true } : undefined)
      +  return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
       }
    • apps/desktop/src/store/onboarding.ts
      @@ -239,7 +239,7 @@ async function fetchProviderDefaultModel(
         let options
       
         try {
      -    options = await getGlobalModelOptions()
      +    options = await getGlobalModelOptions({ includeUnconfigured: true, explicitOnly: false })
         } catch {
           return null
         }
    • hermes_cli/auth.py
      @@ -1495,6 +1495,33 @@ def is_provider_explicitly_configured(provider_id: str) -> bool:
                   if has_usable_secret(os.getenv(env_var, "")):
                       return True
       
      +    # 4. Check persisted credential-pool entries that came from EXPLICIT flows
      +    # the user initiated inside Hermes (manual add / device-code / PKCE), plus
      +    # env-backed pool entries. This intentionally excludes ambient borrowed
      +    # sources like gh_cli / claude_code / qwen-cli.
      +    try:
      +        for entry in read_credential_pool(normalized):
      +            if not isinstance(entry, dict):
      +                continue
      +            source = str(entry.get("source") or "").strip().lower()
      +            if not source:
      +                continue
      +            if source.startswith("env:"):
      +                # A stale env-seeded pool entry survives in auth.json after
      +                # the user deletes the env var (#55790) — only count it when
      +                # the referenced var still resolves to a usable secret NOW.
      +                env_var = entry.get("source", "").split(":", 1)[1].strip()
      +                if env_var and has_usable_secret(os.getenv(env_var, "")):
      +                    return True
      +                continue
      +            if (
      +                source in {"device_code", "loopback_pkce", "hermes_pkce", "manual"}
      +                or source.startswith("manual:")
      +            ):
      +                return True
      +    except Exception:
      +        pass
      +
           return False
       
       
    • hermes_cli/inventory.py
      @@ -111,6 +111,7 @@ def load_picker_context() -> ConfigContext:
       def build_models_payload(
           ctx: ConfigContext,
           *,
      +    explicit_only: bool = False,
           include_unconfigured: bool = False,
           picker_hints: bool = False,
           canonical_order: bool = False,
      @@ -126,6 +127,10 @@ def build_models_payload(
           needs from a single substrate call.
       
           Flags:
      +    - ``explicit_only``: keep only providers the user explicitly configured
      +      (current provider, providers from config, or providers backed by
      +      provider-specific env vars). This hides ambient / auto-seeded
      +      credentials from desktop chat pickers.
           - ``include_unconfigured``: append ``CANONICAL_PROVIDERS`` rows that
             ``list_authenticated_providers`` didn't emit (TUI uses this to show
             the full provider universe in the picker).
      @@ -180,6 +185,9 @@ def build_models_payload(
           if moa_row is not None:
               rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"]
       
      +    if explicit_only:
      +        rows = _filter_explicit_provider_rows(rows, ctx)
      +
           # --- Deduplicate: remove models from aggregators that overlap with
           # user-defined providers.  When a local proxy (e.g. litellm-proxy)
           # serves a model whose name also appears in an aggregator's curated
      @@ -308,6 +316,37 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict
           return extras
       
       
      +def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
      +    """Keep only rows backed by explicit user configuration.
      +
      +    ``list_authenticated_providers`` intentionally discovers ambient / auto-
      +    seeded credentials (for example GitHub CLI -> Copilot). Desktop chat model
      +    pickers want the narrower subset the user explicitly configured for Hermes.
      +    """
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +
      +    current_slug = str(ctx.current_provider or "").strip().lower()
      +    kept: list[dict] = []
      +    for row in rows:
      +        slug = str(row.get("slug", "")).strip().lower()
      +        if not slug:
      +            continue
      +        if row.get("is_user_defined"):
      +            kept.append(row)
      +            continue
      +        if current_slug and slug == current_slug:
      +            kept.append(row)
      +            continue
      +        if slug == "moa":
      +            # MoA is a virtual routing mode, not an independently configured
      +            # provider. Hide it from explicit-only pickers unless it is the
      +            # current provider (handled above).
      +            continue
      +        if is_provider_explicitly_configured(slug):
      +            kept.append(row)
      +    return kept
      +
      +
       def _apply_picker_hints(rows: list[dict]) -> None:
           """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.
       
    • hermes_cli/web_server.py
      @@ -4284,7 +4284,12 @@ def get_model_info(profile: Optional[str] = None):
       
       
       @app.get("/api/model/options")
      -def get_model_options(profile: Optional[str] = None, refresh: bool = False):
      +def get_model_options(
      +    profile: Optional[str] = None,
      +    refresh: bool = False,
      +    include_unconfigured: bool = False,
      +    explicit_only: bool = False,
      +):
           """Return authenticated providers + their curated model lists.
       
           REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
      @@ -4303,18 +4308,15 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False):
           try:
               from hermes_cli.inventory import build_models_payload, load_picker_context
       
      -        # include_unconfigured + picker_hints + canonical_order mirror the
      -        # tui_gateway `model.options` JSON-RPC handler exactly, so every GUI
      -        # surface fed by this endpoint (Settings → Model, the first-run
      -        # onboarding picker) sees the SAME full provider universe `hermes model`
      -        # exposes — not just the authenticated subset. Unconfigured providers
      -        # come back as skeleton rows carrying `authenticated=False` +
      -        # `auth_type`/`key_env`/`warning` so the GUI can render a setup
      -        # affordance instead of hiding the provider entirely.
      +        # Most desktop surfaces should only list providers the user has already
      +        # configured. Onboarding opts into the full provider universe via
      +        # include_unconfigured=1 so it can still render setup affordances for
      +        # providers that are not yet authenticated.
               with _profile_scope(profile):
                   return build_models_payload(
                       load_picker_context(),
      -                include_unconfigured=True,
      +                explicit_only=bool(explicit_only),
      +                include_unconfigured=bool(include_unconfigured),
                       picker_hints=True,
                       canonical_order=True,
                       pricing=True,
    • scripts/release.py
      @@ -90,6 +90,7 @@
           "[redacted:email]": "VolodymyrBg",  # PR #2861 salvage (webhook: drop the unused full request payload from retained _delivery_info entries — up to ~1MB dead weight per delivery for the 1h idempotency TTL)
           "[redacted:email]": "CharmingGroot",  # PR #2794 salvage (email: guard msg_data[0][1] against malformed IMAP fetch structures so one bad response can't abort the batch and permanently lose seen-marked messages; Message-ID domain falls back to localhost when EMAIL_ADDRESS lacks '@')
           "[redacted:email]": "sahibzada-allahyar",  # PR #39227 salvage (desktop: configured terminal.cwd overrides a stale remembered workspace-cwd localStorage value when no session is active; #38855)
      +    "[redacted:email]": "rarf",  # PR #56966 salvage (desktop chat model picker: hide implicitly discovered providers unless explicitly configured)
           "[redacted:email]": "plcunha",  # PR #55300 salvage (gateway: record child gateway peer metadata after a compression session-id rotation and repoint stale sessions.json compression-parent entries to the recovered live child; consolidated in the compression-routing-integrity salvage)
           "[redacted:email]": "jakepresent",  # PR #55721 salvage (gateway: identity-guard stale in-flight compression splits — a late run may publish its compressed child only if its run generation is still current and the session key still points at the run's original parent, so an old run can't overwrite a newer /new or moved binding)
           "[redacted:email]": "gumclaw",  # PR #57322 salvage (gateway: close per-delivery webhook sessions on completion so prune_sessions can reap them — fixes unbounded state.db growth from unprunable ended_at=NULL webhook rows)
    • tests/hermes_cli/test_auth_provider_gate.py
      @@ -82,3 +82,92 @@ def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatc
       
           from hermes_cli.auth import is_provider_explicitly_configured
           assert is_provider_explicitly_configured("anthropic") is False
      +
      +
      +def test_ambient_pool_source_does_not_count_as_explicit(tmp_path, monkeypatch):
      +    """gh_cli-seeded Copilot pool entries are ambient, not explicit config (#56974)."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
      +    monkeypatch.delenv("GH_TOKEN", raising=False)
      +    monkeypatch.delenv("GITHUB_TOKEN", raising=False)
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "copilot": [{
      +                "id": "abc123",
      +                "source": "gh_cli",
      +                "auth_type": "api_key",
      +                "access_token": "ghu_sometoken",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("copilot") is False
      +
      +
      +def test_explicit_pool_source_counts_as_explicit(tmp_path, monkeypatch):
      +    """manual / device_code / PKCE pool entries reflect explicit Hermes flows."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "anthropic": [{
      +                "id": "def456",
      +                "source": "manual:key-1",
      +                "auth_type": "api_key",
      +                "access_token": "sk-ant-api03-key",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("anthropic") is True
      +
      +
      +def test_stale_env_pool_entry_does_not_count_when_var_unset(tmp_path, monkeypatch):
      +    """An env-seeded pool entry left in auth.json after the env var was removed
      +    must not mark the provider configured (#55790): the picker showed removed
      +    providers forever because the record existed even though no secret resolves."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "deepseek": [{
      +                "id": "aaa111",
      +                "source": "env:DEEPSEEK_API_KEY",
      +                "auth_type": "api_key",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("deepseek") is False
      +
      +
      +def test_env_pool_entry_counts_when_var_still_resolves(tmp_path, monkeypatch):
      +    """The same env-seeded pool entry IS explicit while the var still resolves."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    monkeypatch.setenv("DEEPSEEK_API_KEY", "[redacted:openai-key]")
      +    _write_auth_store(tmp_path, {
      +        "version": 1,
      +        "providers": {},
      +        "active_provider": None,
      +        "credential_pool": {
      +            "deepseek": [{
      +                "id": "aaa111",
      +                "source": "env:DEEPSEEK_API_KEY",
      +                "auth_type": "api_key",
      +            }],
      +        },
      +    })
      +
      +    from hermes_cli.auth import is_provider_explicitly_configured
      +    assert is_provider_explicitly_configured("deepseek") is True
    • tests/hermes_cli/test_inventory.py
      @@ -378,6 +378,44 @@ def test_include_unconfigured_skips_already_present_slugs():
           assert or_rows[0]["models"] == ["m1"]  # the authenticated row, not skeleton
       
       
      +def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_rows():
      +    rows = [
      +        {"slug": "openai-codex", "name": "OpenAI Codex", "models": ["gpt-5.4"],
      +         "total_models": 1, "is_current": True, "is_user_defined": False,
      +         "source": "hermes"},
      +        {"slug": "gemini", "name": "Gemini", "models": ["gemini-2.5-pro"],
      +         "total_models": 1, "is_current": False, "is_user_defined": False,
      +         "source": "built-in"},
      +        {"slug": "copilot", "name": "Copilot", "models": ["gpt-5.4"],
      +         "total_models": 1, "is_current": False, "is_user_defined": False,
      +         "source": "hermes"},
      +        {"slug": "nous", "name": "Nous", "models": ["anthropic/claude-sonnet-5"],
      +         "total_models": 1, "is_current": False, "is_user_defined": False,
      +         "source": "hermes"},
      +        {"slug": "custom:lab", "name": "Lab", "models": ["lab-1"],
      +         "total_models": 1, "is_current": False, "is_user_defined": True,
      +         "source": "user-config"},
      +        {"slug": "moa", "name": "MoA", "models": ["default"],
      +         "total_models": 1, "is_current": False, "is_user_defined": False,
      +         "source": "virtual"},
      +    ]
      +    ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4")
      +    with (
      +        _list_auth_returning(rows),
      +        patch(
      +            "hermes_cli.auth.is_provider_explicitly_configured",
      +            side_effect=lambda slug: slug == "gemini",
      +        ),
      +    ):
      +        payload = build_models_payload(ctx, explicit_only=True)
      +
      +    assert [row["slug"] for row in payload["providers"]] == [
      +        "openai-codex",
      +        "gemini",
      +        "custom:lab",
      +    ]
      +
      +
       # ─── picker_hints ──────────────────────────────────────────────────────
       
       
    • tests/hermes_cli/test_web_server_profile_unification.py
      @@ -316,6 +316,36 @@ def test_model_options_unknown_profile_404(self, client, isolated_profiles):
               resp = client.get("/api/model/options", params={"profile": "ghost"})
               assert resp.status_code == 404
       
      +    def test_model_options_hides_unconfigured_providers_by_default(self, client, monkeypatch):
      +        calls = []
      +
      +        monkeypatch.setattr(
      +            "hermes_cli.inventory.load_picker_context",
      +            lambda: object(),
      +        )
      +
      +        def _fake_build_models_payload(_ctx, **kwargs):
      +            calls.append(kwargs)
      +            return {"providers": [], "model": "", "provider": ""}
      +
      +        monkeypatch.setattr(
      +            "hermes_cli.inventory.build_models_payload",
      +            _fake_build_models_payload,
      +        )
      +
      +        resp = client.get("/api/model/options")
      +        assert resp.status_code == 200
      +        assert calls[-1]["explicit_only"] is False
      +        assert calls[-1]["include_unconfigured"] is False
      +
      +        resp = client.get("/api/model/options", params={"explicit_only": "1"})
      +        assert resp.status_code == 200
      +        assert calls[-1]["explicit_only"] is True
      +
      +        resp = client.get("/api/model/options", params={"include_unconfigured": "1"})
      +        assert resp.status_code == 200
      +        assert calls[-1]["include_unconfigured"] is True
      +
           def test_model_info_unknown_profile_404(self, client, isolated_profiles):
               """Regression: the broad except used to convert the 404 into a 200
               with empty model info ("no model set" — silently wrong)."""
    • tests/test_tui_gateway_server.py
      @@ -5984,6 +5984,52 @@ def test_model_options_propagates_list_exception(monkeypatch):
           assert "catalog blew up" in resp["error"]["message"]
       
       
      +def test_model_options_hides_unconfigured_providers_by_default(monkeypatch):
      +    from hermes_cli.inventory import ConfigContext
      +
      +    calls = []
      +
      +    monkeypatch.setattr(server, "_resolve_model", lambda: "")
      +    monkeypatch.setattr(
      +        "hermes_cli.inventory.load_picker_context",
      +        lambda: ConfigContext(
      +            current_provider="",
      +            current_model="",
      +            current_base_url="",
      +            user_providers={},
      +            custom_providers=[],
      +        ),
      +    )
      +
      +    def _fake_build_models_payload(_ctx, **kwargs):
      +        calls.append(kwargs)
      +        return {"providers": [], "model": "", "provider": ""}
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.inventory.build_models_payload",
      +        _fake_build_models_payload,
      +    )
      +
      +    resp = server._methods["model.options"](99, {"session_id": ""})
      +    assert "result" in resp, resp
      +    assert calls[-1]["explicit_only"] is False
      +    assert calls[-1]["include_unconfigured"] is False
      +
      +    resp = server._methods["model.options"](
      +        100,
      +        {"session_id": "", "explicit_only": True},
      +    )
      +    assert "result" in resp, resp
      +    assert calls[-1]["explicit_only"] is True
      +
      +    resp = server._methods["model.options"](
      +        101,
      +        {"session_id": "", "include_unconfigured": True},
      +    )
      +    assert "result" in resp, resp
      +    assert calls[-1]["include_unconfigured"] is True
      +
      +
       # ---------------------------------------------------------------------------
       # prompt.submit — auto-title
       # ---------------------------------------------------------------------------
    • tui_gateway/server.py
      @@ -12398,17 +12398,18 @@ def _(rid, params: dict) -> dict:
                   ),
                   current_base_url=getattr(agent, "base_url", "") if agent else "",
               )
      -        # picker_hints + canonical_order produce the TUI's required shape:
      +        # picker_hints + canonical_order produce the TUI/desktop picker shape:
               # `authenticated`/`auth_type`/`key_env`/`warning` per row, in
      -        # CANONICAL_PROVIDERS declaration order. include_unconfigured=True
      -        # so the picker can show the full provider universe (with the
      -        # setup-hint warning attached) instead of only authed rows.
      +        # CANONICAL_PROVIDERS declaration order. Desktop pickers default to the
      +        # configured subset; callers that need setup affordances can pass
      +        # include_unconfigured=true explicitly.
               # Curated model lists are preserved — list_authenticated_providers
               # populates `models` from the curated catalog, not provider_model_ids
               # (which would pull non-agentic models like TTS/embeddings/etc.).
               payload = build_models_payload(
                   ctx,
      -            include_unconfigured=True,
      +            explicit_only=bool(params.get("explicit_only")),
      +            include_unconfigured=bool(params.get("include_unconfigured")),
                   picker_hints=True,
                   canonical_order=True,
                   pricing=True,
    • ui-tui/src/components/modelPicker.tsx
      @@ -60,7 +60,12 @@ export function ModelPicker({
         useEffect(() => {
           gw.request<ModelOptionsResponse>('model.options', {
             ...(sessionId ? { session_id: sessionId } : {}),
      -      ...(initialRefresh ? { refresh: true } : {})
      +      ...(initialRefresh ? { refresh: true } : {}),
      +      // The TUI picker shows the full provider universe with setup
      +      // affordances ("paste KEY to activate"), so opt into unconfigured
      +      // rows — the backend now defaults to the configured subset for
      +      // desktop chat pickers (#56974).
      +      include_unconfigured: true
           })
             .then(raw => {
               const r = asRpcResult<ModelOptionsResponse>(raw)
    • web/src/components/ModelPickerDialog.tsx
      @@ -138,6 +138,10 @@ export function ModelPickerDialog(props: Props) {
                 {
                   ...(sessionId ? { session_id: sessionId } : {}),
                   ...(refresh ? { refresh: true } : {}),
      +            // Dashboard picker mirrors the TUI: full provider universe with
      +            // setup warnings. The backend now defaults to the configured
      +            // subset (#56974), so opt into unconfigured rows explicitly.
      +            include_unconfigured: true,
                 },
               );
       
    • web/src/lib/api.test.ts
      @@ -22,7 +22,7 @@ describe("api.getModelOptions", () => {
           await api.getModelOptions({ refresh: true });
       
           expect(fetchMock).toHaveBeenCalledWith(
      -      "/api/model/options?refresh=1",
      +      "/api/model/options?refresh=1&include_unconfigured=1",
             expect.objectContaining({ credentials: "[redacted:secret-assignment]" }),
           );
         });
      @@ -41,7 +41,7 @@ describe("api.getModelOptions", () => {
           await api.getModelOptions({ profile: "default", refresh: true });
       
           expect(fetchMock).toHaveBeenCalledWith(
      -      "/api/model/options?profile=default&refresh=1",
      +      "/api/model/options?profile=default&refresh=1&include_unconfigured=1",
             expect.objectContaining({ credentials: "[redacted:secret-assignment]" }),
           );
         });
    • web/src/lib/api.ts
      @@ -487,6 +487,11 @@ export const api = {
           const qs = new URLSearchParams();
           if (profile) qs.set("profile", profile);
           if (refresh) qs.set("refresh", "1");
      +    // Dashboard surfaces (Models page, profile builder, cron) are
      +    // management/setup UIs: keep the full provider universe with setup
      +    // affordances. The endpoint now defaults to the configured subset for
      +    // desktop chat pickers (#56974), so opt in explicitly here.
      +    qs.set("include_unconfigured", "1");
           const suffix = qs.toString() ? `?${qs.toString()}` : "";
           return fetchJSON<ModelOptionsResponse>(`/api/model/options${suffix}`);
         },
  • #67213 fix(credentials): unified provider key delete/update across .env, auth.json, config.yaml

    merged to main

    files: hermes_cli/config.py, hermes_cli/credential_lifecycle.py, hermes_cli/web_server.py, tests/hermes_cli/test_credential_lifecycle.py, tests/hermes_cli/test_env_export_line_lifecycle.py, tui_gateway/server.py

    Show the diff
    • hermes_cli/config.py
      @@ -7846,6 +7846,20 @@ def _quote_env_value(value: str) -> str:
           return f'"{escaped}"'
       
       
      +def _env_line_defines_key(line: str, key: str) -> bool:
      +    """True when a .env line assigns ``key`` — plain or ``export``-prefixed.
      +
      +    ``load_env()`` accepts the bash-compatible ``export KEY=value`` form
      +    (#6659), so the writers must recognise the same shape. Otherwise a
      +    hand-added ``export`` line is invisible to save (duplicate appended) and
      +    remove (line survives → the value resurrects on the next load, #40041).
      +    """
      +    stripped = line.strip()
      +    if stripped.startswith("export "):
      +        stripped = stripped[7:].lstrip()
      +    return stripped.startswith(f"{key}=")
      +
      +
       def save_env_value(key: str, value: str):
           """Save or update a value in ~/.hermes/.env."""
           if is_managed():
      @@ -7887,10 +7901,15 @@ def save_env_value(key: str, value: str):
       
           serialized_value = _quote_env_value(value)
       
      -    # Find and update or append
      +    # Find and update or append. Match both ``KEY=`` and the bash-compatible
      +    # ``export KEY=`` form — load_env() parses export lines (#6659), so a
      +    # user-added ``export GITHUB_TOKEN=...`` shows as set in every UI. If the
      +    # writer didn't match it, a save would append a SECOND line and a later
      +    # delete of that line would silently resurrect the old exported value
      +    # (#40041: "token detected but cannot be replaced through the UI").
           found = False
           for i, line in enumerate(lines):
      -        if line.strip().startswith(f"{key}="):
      +        if _env_line_defines_key(line, key):
                   lines[i] = f"{key}={serialized_value}\n"
                   found = True
                   break
      @@ -7969,7 +7988,7 @@ def remove_env_value(key: str) -> bool:
               lines = f.readlines()
           lines = _sanitize_env_lines(lines)
       
      -    new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
      +    new_lines = [line for line in lines if not _env_line_defines_key(line, key)]
           found = len(new_lines) < len(lines)
       
           if found:
      @@ -8030,7 +8049,12 @@ def save_anthropic_api_key(value: str, save_fn=None):
       
       
       def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
      -    save_env_value(key, value)
      +    # Route through the unified credential lifecycle so a rotation via the
      +    # secret-capture path also refreshes any config.yaml mirror of the old
      +    # value and lifts a prior env-source suppression (#62269 fix family).
      +    from hermes_cli.credential_lifecycle import save_provider_env_credential
      +
      +    save_provider_env_credential(key, value)
           return {
               "success": True,
               "stored_as": key,
      @@ -8450,7 +8474,11 @@ def set_config_value(key: str, value: str):
               sys.exit(1)
           # Check if it's an API key (goes to .env)
           if _is_env_config_key(key):
      -        save_env_value(key.upper(), value)
      +        # Unified lifecycle: also rotates any config.yaml mirror of the old
      +        # value so a stale higher-precedence copy can't win (#62269).
      +        from hermes_cli.credential_lifecycle import save_provider_env_credential
      +
      +        save_provider_env_credential(key.upper(), value)
               print(f"✓ Set {key} in {get_env_path()}")
               return
           
      @@ -8556,8 +8584,12 @@ def unset_config_value(key: str):
               sys.exit(1)
       
           if _is_env_config_key(key):
      -        removed = remove_env_value(key.upper())
      -        if not removed:
      +        # Unified lifecycle: prune env-seeded credential_pool entries and
      +        # model-cache rows too, so `hermes config unset <KEY>` fully removes
      +        # the provider instead of leaving it resurrectable (#51071 family).
      +        from hermes_cli.credential_lifecycle import remove_provider_env_credential
      +
      +        if not remove_provider_env_credential(key.upper()).get("found"):
                   print(f"Config key not set: {key}", file=sys.stderr)
                   sys.exit(1)
               print(f"✓ Unset {key} from {get_env_path()}")
    • hermes_cli/credential_lifecycle.py
      @@ -0,0 +1,272 @@
      +"""Unified provider-credential lifecycle across every store Hermes reads.
      +
      +A provider API key can live in up to THREE stores at once:
      +
      +    1. ``~/.hermes/.env``                     — the canonical secret store
      +    2. ``~/.hermes/auth.json`` →
      +       ``credential_pool.<provider>[*]``      — env-seeded pool entries
      +       (``source == "env:<VAR>"``) persisted by the pool loader
      +    3. ``~/.hermes/config.yaml``              — inline mirrors written by the
      +       custom-endpoint flows (``model.api_key``, ``auxiliary.<task>.api_key``,
      +       ``custom_providers[*].api_key``)
      +
      +Historically the desktop/dashboard endpoints (PUT/DELETE ``/api/env``) and the
      +TUI-gateway RPCs only mutated store 1. That divergence is the root cause of a
      +whole bug family:
      +
      +    * #51071 / #59761 — deleting a key removes it from ``.env`` but the stale
      +      ``credential_pool`` entry (and ``provider_models_cache.json`` row)
      +      survives, so the provider keeps appearing in the model picker, even
      +      across restarts (the pool loader is additive-only).
      +    * #62269 — updating a key rewrites ``.env`` but leaves the OLD key in a
      +      higher-precedence ``config.yaml`` mirror (``model.api_key`` wins over
      +      env at client construction), producing persistent 401s with a key the
      +      UI no longer shows.
      +
      +This module is the single choke point: every surface that saves or removes a
      +provider credential should route through :func:`save_provider_env_credential`
      +/ :func:`remove_provider_env_credential` so all three stores stay consistent.
      +
      +OAuth preservation contract: removal only prunes credential-pool entries whose
      +``source`` is exactly ``env:<VAR>``. OAuth/device-code/manual/borrowed entries
      +(``device_code``, ``manual*``, ``gh_cli``, ``claude_code``, ``oauth``, …) and
      +the ``providers.<id>`` OAuth token blocks in auth.json are never touched —
      +deleting an API key must not revoke an OAuth grant for the same provider.
      +
      +Secrecy contract: no function in this module logs, prints, or returns a
      +credential value. Results carry key NAMES and config PATHS only.
      +"""
      +
      +from __future__ import annotations
      +
      +from typing import Any, Dict, List
      +
      +__all__ = [
      +    "save_provider_env_credential",
      +    "remove_provider_env_credential",
      +    "purge_env_credential_references",
      +]
      +
      +
      +def _providers_for_env_var(env_var: str) -> List[str]:
      +    """Provider ids whose registered api_key_env_vars include ``env_var``."""
      +    try:
      +        from hermes_cli.auth import PROVIDER_REGISTRY
      +    except Exception:
      +        return []
      +    hits: List[str] = []
      +    for pid, cfg in PROVIDER_REGISTRY.items():
      +        try:
      +            if env_var in (cfg.api_key_env_vars or ()):
      +                hits.append(pid)
      +        except Exception:
      +            continue
      +    return hits
      +
      +
      +def _prune_env_pool_entries(env_var: str) -> List[str]:
      +    """Drop ``credential_pool`` entries seeded from ``env:<env_var>``.
      +
      +    Operates across ALL providers in the pool (the source string names the
      +    env var unambiguously, and shared vars like GITHUB_TOKEN may seed more
      +    than one provider). Entries with any other source — OAuth, device-code,
      +    manual, borrowed-CLI — are preserved verbatim, as are the
      +    ``providers.<id>`` OAuth blocks.
      +
      +    Returns the list of provider ids that had entries pruned.
      +    """
      +    from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
      +
      +    source = f"env:{env_var}"
      +    pruned: List[str] = []
      +    with _auth_store_lock():
      +        auth_store = _load_auth_store()
      +        pool = auth_store.get("credential_pool")
      +        if not isinstance(pool, dict):
      +            return pruned
      +        changed = False
      +        for provider in list(pool.keys()):
      +            entries = pool[provider]
      +            if not isinstance(entries, list):
      +                continue
      +            kept = [
      +                entry
      +                for entry in entries
      +                if not (isinstance(entry, dict) and entry.get("source") == source)
      +            ]
      +            if len(kept) == len(entries):
      +                continue
      +            changed = True
      +            pruned.append(provider)
      +            if kept:
      +                pool[provider] = kept
      +            else:
      +                del pool[provider]
      +        if changed:
      +            _save_auth_store(auth_store)
      +    return pruned
      +
      +
      +def _scrub_config_yaml_mirrors(old_value: str, new_value: str | None) -> List[str]:
      +    """Reconcile config.yaml api_key mirrors that hold ``old_value``.
      +
      +    Value-matched on purpose: we only touch a config entry when it provably
      +    holds the SAME credential that just changed in ``.env`` — an independent
      +    key the user configured for a different endpoint is left alone.
      +
      +    ``new_value=None`` removes the mirror field; a string replaces it.
      +    Operates on the RAW user config (never the defaults-merged view) so the
      +    write doesn't bake defaults into the user's file. Returns the dotted
      +    paths that were updated (names only — never values).
      +    """
      +    if not old_value:
      +        return []
      +    from utils import atomic_yaml_write, fast_safe_load
      +
      +    from hermes_cli.config import (
      +        get_config_path,
      +        require_readable_config_before_write,
      +    )
      +
      +    config_path = get_config_path()
      +    if not config_path.exists():
      +        return []
      +    try:
      +        with open(config_path, encoding="utf-8") as f:
      +            user_config = fast_safe_load(f) or {}
      +    except Exception:
      +        return []
      +    if not isinstance(user_config, dict):
      +        return []
      +
      +    touched: List[str] = []
      +
      +    def _fix(section: Any, key_path: str) -> None:
      +        if not isinstance(section, dict):
      +            return
      +        # "api" is the legacy alias for model.api_key kept by older configs.
      +        for field in ("api_key", "api"):
      +            current = section.get(field)
      +            if isinstance(current, str) and current == old_value:
      +                if new_value:
      +                    section[field] = new_value
      +                else:
      +                    section.pop(field, None)
      +                touched.append(f"{key_path}.{field}")
      +
      +    _fix(user_config.get("model"), "model")
      +
      +    aux = user_config.get("auxiliary")
      +    if isinstance(aux, dict):
      +        for task, slot_cfg in aux.items():
      +            _fix(slot_cfg, f"auxiliary.{task}")
      +
      +    custom = user_config.get("custom_providers")
      +    if isinstance(custom, list):
      +        for idx, entry in enumerate(custom):
      +            _fix(entry, f"custom_providers.{idx}")
      +    elif isinstance(custom, dict):
      +        for name, entry in custom.items():
      +            _fix(entry, f"custom_providers.{name}")
      +
      +    if touched:
      +        require_readable_config_before_write(config_path)
      +        atomic_yaml_write(config_path, user_config, sort_keys=False)
      +    return touched
      +
      +
      +def purge_env_credential_references(
      +    env_var: str, *, clear_models_cache: bool = True
      +) -> Dict[str, Any]:
      +    """Remove non-.env references to an env-var credential.
      +
      +    Prunes ``credential_pool`` env-seeded entries and (optionally) the
      +    affected providers' rows in ``provider_models_cache.json`` so the model
      +    picker stops advertising a provider whose key is gone (#59761).
      +    """
      +    pruned = _prune_env_pool_entries(env_var)
      +    providers = sorted(set(pruned) | set(_providers_for_env_var(env_var)))
      +    # Make the removal sticky the same way `hermes auth remove` does: a
      +    # lingering shell export (or another live process's os.environ) would
      +    # otherwise re-seed the pool entry on the next load_pool(). The matching
      +    # save path lifts the suppression on an explicit re-add.
      +    try:
      +        from hermes_cli.auth import suppress_credential_source
      +
      +        for provider in providers:
      +            suppress_credential_source(provider, f"env:{env_var}")
      +    except Exception:
      +        pass
      +    if clear_models_cache and providers:
      +        try:
      +            from hermes_cli.models import clear_provider_models_cache
      +
      +            for provider in providers:
      +                clear_provider_models_cache(provider)
      +        except Exception:
      +            # Cache cleanup is best-effort — a failure here must not block
      +            # the credential removal itself.
      +            pass
      +    return {"pool_pruned": pruned, "providers": providers}
      +
      +
      +def save_provider_env_credential(env_var: str, value: str) -> Dict[str, Any]:
      +    """Save/update a credential in ``.env`` and reconcile every mirror.
      +
      +    After the ``.env`` write, any config.yaml mirror that held the PREVIOUS
      +    value of this var (``model.api_key`` etc.) is updated to the new value so
      +    a stale higher-precedence copy cannot shadow the rotation (#62269).
      +    Suppressed ``env:<VAR>`` pool sources are re-enabled so a deliberate
      +    re-add through the UI behaves like ``hermes auth add``.
      +    """
      +    from hermes_cli.config import load_env, save_env_value
      +
      +    old_value = load_env().get(env_var)
      +    save_env_value(env_var, value)
      +
      +    config_updates: List[str] = []
      +    if value and old_value and old_value != value:
      +        config_updates = _scrub_config_yaml_mirrors(old_value, value)
      +
      +    # A prior UI/CLI removal may have suppressed this env source; a fresh
      +    # save is an explicit re-add, so lift the suppression for every provider
      +    # that reads this var.
      +    try:
      +        from hermes_cli.auth import unsuppress_credential_source
      +
      +        for provider in _providers_for_env_var(env_var):
      +            unsuppress_credential_source(provider, f"env:{env_var}")
      +    except Exception:
      +        pass
      +
      +    return {"ok": True, "key": env_var, "config_updates": config_updates}
      +
      +
      +def remove_provider_env_credential(env_var: str) -> Dict[str, Any]:
      +    """Remove a credential from EVERY store it lives in.
      +
      +    Clears the ``.env`` entry (and process env), prunes env-seeded
      +    ``credential_pool`` entries, drops the affected providers' model-cache
      +    rows, and removes any config.yaml mirror holding the same value.
      +    OAuth/device-code/manual credentials are preserved (see module docstring).
      +
      +    ``found`` is True when ANY store held the credential — callers that
      +    previously 404'd on ".env miss" should key off this instead so a stale
      +    pool-only entry can still be cleaned up through the same button.
      +    """
      +    from hermes_cli.config import load_env, remove_env_value
      +
      +    old_value = load_env().get(env_var)
      +    removed_from_env = remove_env_value(env_var)
      +    refs = purge_env_credential_references(env_var)
      +    config_scrubbed = _scrub_config_yaml_mirrors(old_value, None) if old_value else []
      +
      +    return {
      +        "ok": True,
      +        "key": env_var,
      +        "removed": removed_from_env,
      +        "pool_pruned": refs["pool_pruned"],
      +        "providers": refs["providers"],
      +        "config_scrubbed": config_scrubbed,
      +        "found": bool(removed_from_env or refs["pool_pruned"] or config_scrubbed),
      +    }
    • hermes_cli/web_server.py
      @@ -6354,8 +6354,15 @@ def _row(var_name: str, info: dict, *, custom: bool = False) -> dict:
       async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None):
           try:
               with _profile_scope(body.profile or profile):
      -            save_env_value(body.key, body.value)
      -        return {"ok": True, "key": body.key}
      +            # Unified credential lifecycle: writes .env AND reconciles any
      +            # config.yaml mirror still holding the previous value of this var
      +            # (model.api_key / auxiliary.*.api_key / custom_providers[*]),
      +            # so a rotation can't leave a stale higher-precedence copy that
      +            # keeps authenticating with the old key (#62269).
      +            from hermes_cli.credential_lifecycle import save_provider_env_credential
      +
      +            result = save_provider_env_credential(body.key, body.value)
      +        return result
           except ValueError as exc:
               # save_env_value raises ValueError for invalid names and for keys
               # on the denylist (LD_PRELOAD, PATH, PYTHONPATH, …). Surface the
      @@ -6473,10 +6480,18 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
       async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None):
           try:
               with _profile_scope(body.profile or profile):
      -            removed = remove_env_value(body.key)
      -        if not removed:
      +            # Unified credential lifecycle: clears the .env entry AND every
      +            # mirror of the credential — env-seeded credential_pool entries in
      +            # auth.json (stale ones kept providers alive in the model picker,
      +            # #51071/#59761), the affected providers' model-cache rows, and
      +            # value-matched config.yaml api_key mirrors. OAuth/device-code/
      +            # manual pool entries for the same provider are preserved.
      +            from hermes_cli.credential_lifecycle import remove_provider_env_credential
      +
      +            result = remove_provider_env_credential(body.key)
      +        if not result.get("found"):
                   raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
      -        return {"ok": True, "key": body.key}
      +        return result
           except HTTPException:
               raise
           except ValueError as exc:
    • tests/hermes_cli/test_credential_lifecycle.py
      @@ -0,0 +1,335 @@
      +"""E2E tests for the unified provider-credential lifecycle (#51071 #59761 #62269).
      +
      +A provider API key can live in .env, auth.json's credential_pool, and
      +config.yaml mirrors at once. These tests drive the REAL dashboard endpoint
      +handlers (PUT/DELETE /api/env) against real on-disk fixtures in a temp
      +HERMES_HOME (tests/conftest.py isolation) and assert every store agrees
      +afterwards.
      +
      +All fake secrets are constructed at runtime so no key-shaped literal ever
      +lands in the repo.
      +"""
      +
      +import json
      +
      +import pytest
      +from fastapi.testclient import TestClient
      +
      +from hermes_cli.web_server import _SESSION_TOKEN, app
      +
      +client = TestClient(app)
      +HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
      +
      +# Runtime-constructed fake credentials (never literal key-shaped strings).
      +FAKE_ZAI_KEY = "zk-" + "a" * 24
      +FAKE_OAUTH_TOKEN = "oa-" + "b" * 24
      +NEW_KEY = "zk-" + "c" * 24
      +
      +
      [redacted:email]
      +def hermes_home(monkeypatch, tmp_path):
      +    """Fresh HERMES_HOME with .env + auth.json + config.yaml fixtures."""
      +    home = tmp_path / "cred_home"
      +    home.mkdir()
      +    monkeypatch.setenv("HERMES_HOME", str(home))
      +    from hermes_cli.config import invalidate_env_cache
      +
      +    invalidate_env_cache()
      +    return home
      +
      +
      +def _write_env(home, **pairs):
      +    home.joinpath(".env").write_text(
      +        "".join(f"{k}={v}\n" for k, v in pairs.items()), encoding="utf-8"
      +    )
      +    from hermes_cli.config import invalidate_env_cache
      +
      +    invalidate_env_cache()
      +
      +
      +def _write_auth(home, pool):
      +    home.joinpath("auth.json").write_text(
      +        json.dumps({"credential_pool": pool}), encoding="utf-8"
      +    )
      +
      +
      +def _read_auth(home):
      +    return json.loads(home.joinpath("auth.json").read_text(encoding="utf-8"))
      +
      +
      +def _zai_pool_fixture():
      +    """One env-seeded API-key entry plus one OAuth entry for the same provider."""
      +    return {
      +        "zai": [
      +            {
      +                "id": "e1",
      +                "label": "env",
      +                "auth_type": "api_key",
      +                "priority": 0,
      +                "source": "env:ZAI_API_KEY",
      +                "access_token": FAKE_ZAI_KEY,
      +            },
      +            {
      +                "id": "o1",
      +                "label": "oauth",
      +                "auth_type": "oauth",
      +                "priority": 0,
      +                "source": "device_code",
      +                "access_token": FAKE_OAUTH_TOKEN,
      +                "refresh_token": "rt-" + "d" * 16,
      +            },
      +        ]
      +    }
      +
      +
      +# ---------------------------------------------------------------------------
      +# DELETE — #51071 / #59761: stale credential_pool entries must be pruned
      +# ---------------------------------------------------------------------------
      +
      +
      +def test_delete_env_key_prunes_env_seeded_pool_entry(hermes_home):
      +    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]
      +    _write_auth(hermes_home, _zai_pool_fixture())
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    body = resp.json()
      +    assert body["ok"] is True
      +    assert "zai" in body["pool_pruned"]
      +
      +    # .env cleared
      +    from hermes_cli.config import load_env
      +
      +    assert "ZAI_API_KEY" not in load_env()
      +
      +    # auth.json: env-seeded entry gone, OAuth entry preserved
      +    store = _read_auth(hermes_home)
      +    sources = [e["source"] for e in store["credential_pool"]["zai"]]
      +    assert "env:ZAI_API_KEY" not in sources
      +    assert "device_code" in sources, "OAuth grant must survive an API-key delete"
      +
      +
      +def test_delete_env_key_removes_provider_pool_key_when_emptied(hermes_home):
      +    """A provider whose ONLY pool entry was env-seeded disappears entirely."""
      +    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]
      +    _write_auth(
      +        hermes_home,
      +        {"zai": [_zai_pool_fixture()["zai"][0]]},  # env entry only
      +    )
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    store = _read_auth(hermes_home)
      +    assert "zai" not in store.get("credential_pool", {}), (
      +        "provider must vanish from credential_pool so the model picker "
      +        "stops listing it (#51071)"
      +    )
      +
      +
      +def test_delete_survives_pool_reload(hermes_home):
      +    """#59761: the pool loader must not resurrect the entry after 'restart'."""
      +    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]
      +    _write_auth(hermes_home, {"zai": [_zai_pool_fixture()["zai"][0]]})
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +
      +    # Simulate restart: reload the pool from disk the way startup does.
      +    from agent.credential_pool import load_pool
      +
      +    entries = load_pool("zai").entries()
      +    assert entries == [], f"stale entries resurrected: {[e.source for e in entries]}"
      +
      +
      +def test_delete_clears_provider_models_cache(hermes_home):
      +    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]
      +    _write_auth(hermes_home, {"zai": [_zai_pool_fixture()["zai"][0]]})
      +    cache_path = hermes_home / "provider_models_cache.json"
      +    cache_path.write_text(
      +        json.dumps({"zai": {"models": ["glm-5"], "ts": 0}}), encoding="utf-8"
      +    )
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    if cache_path.exists():
      +        cache = json.loads(cache_path.read_text(encoding="utf-8"))
      +        assert "zai" not in cache
      +
      +
      +def test_delete_pool_only_credential_still_cleans_up(hermes_home):
      +    """Stale pool entry with NO .env line (the #59761 restart state) is
      +    removable through the same delete button instead of 404ing."""
      +    _write_env(hermes_home)  # empty .env
      +    _write_auth(hermes_home, {"zai": [_zai_pool_fixture()["zai"][0]]})
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    store = _read_auth(hermes_home)
      +    assert "zai" not in store.get("credential_pool", {})
      +
      +
      +def test_delete_unknown_key_404s(hermes_home):
      +    _write_env(hermes_home)
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "NEVER_SET_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 404
      +
      +
      +def test_delete_does_not_touch_other_providers(hermes_home):
      +    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]
      +    other_key = "dk-" + "e" * 24
      +    pool = _zai_pool_fixture()
      +    pool["deepseek"] = [
      +        {
      +            "id": "d1",
      +            "label": "env",
      +            "auth_type": "api_key",
      +            "priority": 0,
      +            "source": "env:DEEPSEEK_API_KEY",
      +            "access_token": other_key,
      +        }
      +    ]
      +    _write_auth(hermes_home, pool)
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    store = _read_auth(hermes_home)
      +    assert [e["source"] for e in store["credential_pool"]["deepseek"]] == [
      +        "env:DEEPSEEK_API_KEY"
      +    ]
      +
      +
      +# ---------------------------------------------------------------------------
      +# UPDATE — #62269: config.yaml mirrors of the old key must rotate with .env
      +# ---------------------------------------------------------------------------
      +
      +
      +def _write_config(home, text):
      +    home.joinpath("config.yaml").write_text(text, encoding="utf-8")
      +
      +
      +def test_update_rotates_config_yaml_model_mirror(hermes_home):
      +    old = "sk-oe-" + "f" * 24
      +    new = "sk-oe-" + "g" * 24
      +    _write_env(hermes_home, OPENAI_API_KEY=old)
      +    _write_config(
      +        hermes_home,
      +        "model:\n"
      +        "  provider: custom\n"
      +        "  default: my-model\n"
      +        "  base_url: https://llm.example.test/v1\n"
      +        f"  api_key: [redacted:secret-assignment]",
      +    )
      +
      +    resp = client.put(
      +        "/api/env", json={"key": "OPENAI_API_KEY", "value": new}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    assert "model.api_key" in resp.json().get("config_updates", [])
      +
      +    cfg_text = hermes_home.joinpath("config.yaml").read_text(encoding="utf-8")
      +    assert old not in cfg_text, "stale old key left in config.yaml (#62269)"
      +    assert new in cfg_text, "config.yaml mirror not rotated to the new key"
      +
      +    from hermes_cli.config import load_env
      +
      +    assert load_env()["OPENAI_API_KEY"] == new
      +
      +
      +def test_update_rotates_custom_provider_mirror(hermes_home):
      +    old = "sk-cp-" + "h" * 24
      +    new = "sk-cp-" + "i" * 24
      +    _write_env(hermes_home, OPENAI_API_KEY=old)
      +    _write_config(
      +        hermes_home,
      +        "custom_providers:\n"
      +        "  - name: myendpoint\n"
      +        "    base_url: https://llm.example.test/v1\n"
      +        f"    api_key: [redacted:secret-assignment]",
      +    )
      +
      +    resp = client.put(
      +        "/api/env", json={"key": "OPENAI_API_KEY", "value": new}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    cfg_text = hermes_home.joinpath("config.yaml").read_text(encoding="utf-8")
      +    assert old not in cfg_text
      +    assert new in cfg_text
      +
      +
      +def test_update_leaves_unrelated_config_keys_alone(hermes_home):
      +    """A DIFFERENT key configured inline must not be rewritten by value-match."""
      +    old = "sk-un-" + "j" * 24
      +    unrelated = "sk-un-" + "k" * 24
      +    _write_env(hermes_home, OPENAI_API_KEY=old)
      +    _write_config(hermes_home, f"model:\n  provider: custom\n  api_key: [redacted:secret-assignment]")
      +
      +    resp = client.put(
      +        "/api/env",
      +        json={"key": "OPENAI_API_KEY", "value": "sk-un-" + "l" * 24},
      +        headers=HEADERS,
      +    )
      +    assert resp.status_code == 200
      +    cfg_text = hermes_home.joinpath("config.yaml").read_text(encoding="utf-8")
      +    assert unrelated in cfg_text, "unrelated inline key must be preserved"
      +
      +
      +def test_delete_scrubs_config_yaml_mirror(hermes_home):
      +    old = "sk-dl-" + "m" * 24
      +    _write_env(hermes_home, OPENAI_API_KEY=old)
      +    _write_config(hermes_home, f"model:\n  provider: custom\n  api_key: [redacted:secret-assignment]")
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "OPENAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    assert "model.api_key" in resp.json()["config_scrubbed"]
      +    cfg_text = hermes_home.joinpath("config.yaml").read_text(encoding="utf-8")
      +    assert old not in cfg_text
      +
      +
      +# ---------------------------------------------------------------------------
      +# Suppression round-trip: delete sticks, re-add lifts it
      +# ---------------------------------------------------------------------------
      +
      +
      +def test_delete_then_resave_round_trip(hermes_home):
      +    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]
      +    _write_auth(hermes_home, {"zai": [_zai_pool_fixture()["zai"][0]]})
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "ZAI_API_KEY"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +
      +    from hermes_cli.auth import is_source_suppressed
      +
      +    assert is_source_suppressed("zai", "env:ZAI_API_KEY"), (
      +        "delete must suppress the env source so a lingering shell export "
      +        "can't re-seed the pool"
      +    )
      +
      +    resp = client.put(
      +        "/api/env", json={"key": "ZAI_API_KEY", "value": NEW_KEY}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +    assert not is_source_suppressed("zai", "env:ZAI_API_KEY"), (
      +        "an explicit re-save must lift the suppression (like `hermes auth add`)"
      +    )
      +
      +    from hermes_cli.config import load_env
      +
      +    assert load_env()["ZAI_API_KEY"] == NEW_KEY
    • tests/hermes_cli/test_env_export_line_lifecycle.py
      @@ -0,0 +1,125 @@
      +"""Regression tests for the Tools & Keys GitHub PAT save/remove path (#40041).
      +
      +Users following generic docs add ``export GITHUB_TOKEN=[redacted:secret-assignment] to
      +``~/.hermes/.env``. ``load_env()`` parses the export prefix (#6659), so every
      +UI shows the token as set (green light) — but ``save_env_value`` /
      +``remove_env_value`` only matched plain ``KEY=`` lines. Result: the UI could
      +neither replace nor remove the token (delete 404s as "not found in .env";
      +save appends a duplicate line that a later delete removes while the export
      +line silently resurrects the old value).
      +
      +Fake tokens are constructed at runtime — no key-shaped literals on disk.
      +"""
      +
      +import pytest
      +from fastapi.testclient import TestClient
      +
      +from hermes_cli.web_server import _SESSION_TOKEN, app
      +
      +client = TestClient(app)
      +HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
      +
      +# Classic-PAT-shaped token, constructed at runtime (36 chars after prefix).
      +OLD_PAT = "ghp_" + "A" * 36
      +NEW_PAT = "ghp_" + "B" * 36
      +
      +
      [redacted:email]
      +def hermes_home(monkeypatch, tmp_path):
      +    home = tmp_path / "pat_home"
      +    home.mkdir()
      +    monkeypatch.setenv("HERMES_HOME", str(home))
      +    from hermes_cli.config import invalidate_env_cache
      +
      +    invalidate_env_cache()
      +    return home
      +
      +
      +def _write_env_raw(home, text):
      +    home.joinpath(".env").write_text(text, encoding="utf-8")
      +    from hermes_cli.config import invalidate_env_cache
      +
      +    invalidate_env_cache()
      +
      +
      +def test_classic_pat_save_via_endpoint_succeeds(hermes_home):
      +    """Saving a ghp_* classic PAT through the env endpoint must not 500 —
      +    GITHUB_TOKEN is a REST/Skills-Hub credential, not a Copilot one."""
      +    resp = client.put(
      +        "/api/env", json={"key": "GITHUB_TOKEN", "value": NEW_PAT}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200, resp.text
      +
      +    from hermes_cli.config import load_env
      +
      +    assert load_env()["GITHUB_TOKEN"] == NEW_PAT
      +
      +
      +def test_remove_export_prefixed_token(hermes_home):
      +    """DELETE must clear an ``export KEY=...`` line, not 404 on it."""
      +    _write_env_raw(hermes_home, f"export GITHUB_TOKEN=[redacted:secret-assignment]")
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "GITHUB_TOKEN"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200, (
      +        "export-prefixed lines are parsed by load_env (UI shows the token as "
      +        "set) so the delete path must recognise them too (#40041)"
      +    )
      +
      +    env_text = hermes_home.joinpath(".env").read_text(encoding="utf-8")
      +    assert OLD_PAT not in env_text
      +
      +    from hermes_cli.config import load_env
      +
      +    assert "GITHUB_TOKEN" not in load_env()
      +
      +
      +def test_update_export_prefixed_token_does_not_duplicate(hermes_home):
      +    """Saving over an ``export KEY=`` line must replace it in place."""
      +    _write_env_raw(hermes_home, f"export GITHUB_TOKEN=[redacted:secret-assignment]")
      +
      +    resp = client.put(
      +        "/api/env", json={"key": "GITHUB_TOKEN", "value": NEW_PAT}, headers=HEADERS
      +    )
      +    assert resp.status_code == 200
      +
      +    env_text = hermes_home.joinpath(".env").read_text(encoding="utf-8")
      +    assert OLD_PAT not in env_text, "old exported token line must be replaced"
      +    assert env_text.count("GITHUB_TOKEN") == 1, (
      +        "save must not append a duplicate GITHUB_TOKEN line alongside the "
      +        "export-prefixed one"
      +    )
      +
      +    from hermes_cli.config import load_env
      +
      +    assert load_env()["GITHUB_TOKEN"] == NEW_PAT
      +
      +
      +def test_plain_line_save_and_remove_still_work(hermes_home):
      +    """Sanity: the ordinary KEY= path is unchanged."""
      +    from hermes_cli.config import load_env, remove_env_value, save_env_value
      +
      +    save_env_value("GITHUB_TOKEN", OLD_PAT)
      +    assert load_env()["GITHUB_TOKEN"] == OLD_PAT
      +    save_env_value("GITHUB_TOKEN", NEW_PAT)
      +    env_text = hermes_home.joinpath(".env").read_text(encoding="utf-8")
      +    assert env_text.count("GITHUB_TOKEN") == 1
      +    assert remove_env_value("GITHUB_TOKEN") is True
      +    assert "GITHUB_TOKEN" not in load_env()
      +
      +
      +def test_export_line_with_comment_untouched(hermes_home):
      +    """Commented-out export lines are not live assignments — leave them."""
      +    _write_env_raw(
      +        hermes_home,
      +        f"# export GITHUB_TOKEN=[redacted:secret-assignment]",
      +    )
      +
      +    resp = client.request(
      +        "DELETE", "/api/env", json={"key": "GITHUB_TOKEN"}, headers=HEADERS
      +    )
      +    assert resp.status_code == 404
      +    env_text = hermes_home.joinpath(".env").read_text(encoding="utf-8")
      +    assert "# export GITHUB_TOKEN=" in env_text
      +    assert "OTHER_KEY=value" in env_text
    • tui_gateway/server.py
      @@ -13984,7 +13984,7 @@ def _(rid, params: dict) -> dict:
           """
           try:
               from hermes_cli.auth import PROVIDER_REGISTRY
      -        from hermes_cli.config import is_managed, save_env_value
      +        from hermes_cli.config import is_managed
               from hermes_cli.inventory import build_models_payload, load_picker_context
       
               slug = (params.get("slug") or "").strip()
      @@ -14008,9 +14008,13 @@ def _(rid, params: dict) -> dict:
               if not pconfig.api_key_env_vars:
                   return _err(rid, 4004, f"no env var defined for {pconfig.name}")
       
      -        # Save the key to ~/.hermes/.env
      +        # Save the key to ~/.hermes/.env via the unified credential lifecycle
      +        # so any stale config.yaml mirror of the previous key (model.api_key,
      +        # custom_providers[*].api_key) is rotated in the same action (#62269).
               env_var = pconfig.api_key_env_vars[0]
      -        save_env_value(env_var, api_key)
      +        from hermes_cli.credential_lifecycle import save_provider_env_credential
      +
      +        save_provider_env_credential(env_var, api_key)
               # Also set in current process so the refreshed inventory sees it.
               import os
       
      @@ -14064,7 +14068,7 @@ def _(rid, params: dict) -> dict:
           """
           try:
               from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth
      -        from hermes_cli.config import remove_env_value
      +        from hermes_cli.credential_lifecycle import remove_provider_env_credential
       
               slug = (params.get("slug") or "").strip()
               if not slug:
      @@ -14074,13 +14078,19 @@ def _(rid, params: dict) -> dict:
               cleared_env = False
               cleared_auth = False
       
      -        # Remove API key env vars from .env and process
      +        # Remove API key env vars from .env and process, plus every mirror
      +        # (env-seeded credential_pool entries, provider model cache rows,
      +        # value-matched config.yaml api_key copies) via the unified helper —
      +        # otherwise the provider resurrects in the picker after restart
      +        # (#51071 / #59761).
               if pconfig and pconfig.api_key_env_vars:
                   for ev in pconfig.api_key_env_vars:
      -                if remove_env_value(ev):
      +                if remove_provider_env_credential(ev).get("found"):
                           cleared_env = True
       
      -        # Clear OAuth / credential pool state
      +        # Clear OAuth / credential pool state. This is a full provider
      +        # disconnect (TUI "disconnect" action), so removing OAuth grants
      +        # here is the documented intent — unlike the key-only delete paths.
               cleared_auth = clear_provider_auth(slug)
       
               if not cleared_env and not cleared_auth:

2 PRs closed without merging

From the close comments: done elsewhere 1 · superseded by another PR 1

  • #51086 fix(dashboard): remove env-seeded auth on API key delete · done elsewhere → 9a987f1, #67213, #62269, #59761
  • #55791 fix: clear credential pool entries when env vars are deleted · superseded by another PR → #60514
Closed issues · 3
  • Closed#51071Desktop: 'Delete API Key' only removes from .env, provider still appears in model list due to stale auth.json credentials
  • Closed#55790[Bug]: Stale credential pool entries cause removed providers to persist in model picker
  • Closed#59761[Bug] Settings → Providers "remove key" doesn't fully remove provider — credential_pool persists across restart
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#60514, #67213
Closed issues
3
Attempts
2 PRs closed without merging
Fix link checked
19 Jul 2026

For your agent

The same facts, typed.