Two tightenings on /auth/native/authorize (a public pre-auth route):
- Per-IP pending cap (8): the broker store is capacity-bounded fail-closed
at 256 entries with a 600s TTL, so one unauthenticated spammer could fill
it and deny native sign-in gateway-wide for the pending window. Pending
entries now record the requester IP and each address is capped well above
any legitimate concurrent-login count; other addresses keep signing in.
- Loopback redirect_uri accepts IP literals only (127.0.0.1 / ::1):
'localhost' can be re-pointed via the hosts file or a hostile resolver
(RFC 8252 \u00a78.3 says to use loopback IP literals); the desktop always
sends 127.0.0.1, so nothing legitimate used the name.
3 new tests: per-IP cap enforced, cap frees on TTL expiry, localhost
redirect rejected at the route.
The Desktop app can now sign in to a gated gateway using the user's SYSTEM
browser and OAuth 2.0 for Native Apps (RFC 8252) instead of an embedded
Electron BrowserWindow, and authenticates with bearer tokens it holds itself
instead of relying on HttpOnly browser session cookies.
Why brokered: the upstream IDP (Nous Portal) binds client_id to the gateway
instance and only permits redirect_uris on the gateway's own origin, so a
desktop loopback redirect can't be a direct Portal client. The gateway
therefore acts as the authorization server TO the desktop and an OAuth client
TO the Portal, reusing the existing PKCE start_login/complete_login provider
path unchanged.
Server (Ben's dashboard-auth lane):
- native_flow.py: in-memory broker — binds the desktop's PKCE challenge to a
completed Session, mints a single-use, short-TTL, PKCE-verified gateway
authorization code. Constant-time compare, single-use (consumed before the
PKCE check so a wrong verifier can't be retried), capacity-bounded.
- routes.py: GET /auth/native/authorize (starts the brokered PKCE login,
loopback-only redirect_uri, S256-only), POST /auth/native/token (loopback
code + verifier -> tokens in the JSON body, never Set-Cookie), POST
/auth/native/refresh (desktop-held RT rotation). /auth/callback branches to
mint a loopback code + 302 to 127.0.0.1 when a broker_state rides the PKCE
cookie; the cookie/SPA path is untouched.
- middleware.py: the gate accepts Authorization: Bearer <access_token>,
verified via the same verify_session provider stack (no cookie set/read),
with the same "provider unreachable -> 503, not logout" semantics.
- web_server.py /api/status: advertise auth_flows (["cookie","native_pkce"])
so clients can detect the capability; native_pkce only when a brokerable
OAuth provider is registered.
Desktop (Ben's lane):
- native-oauth.ts: pure PKCE/capability/URL/callback/token helpers.
- native-oauth-login.ts: loopback-listener orchestration (system browser via
openExternal, ephemeral 127.0.0.1 listener, state/PKCE verification), all
I/O injected for testability.
- main.ts: capability-gated oauth-login IPC — native flow when advertised,
automatic fallback to the existing embedded-webview cookie flow otherwise;
tokens stored encrypted (safeStorage/OS keychain), REST + ws-ticket
authenticated by bearer, transparent refresh, logout clears both shapes.
Tests: 18 server pytest (broker unit + full authorize->callback->token E2E +
cookieless bearer auth of a gated route + ws-ticket mint + capability
advertisement + refresh); desktop node --test/vitest for both pure modules
(PKCE, capability detection, callback CSRF, loopback round trip, timeout,
browser-open failure). Electron project typechecks clean.
Docs: website/docs/guides/desktop-native-signin.md.
MCP server config already resolves Cursor-style ${env:VAR} references
(mcp_tool._env_ref_name); config.yaml's expander treated the same shape
as a literal string — a confusing half-support. _expand_env_vars() now
strips the env: prefix and resolves identically, _env_ref_snapshot()
tracks the ref under the REAL var name (preserving the #58514 cache-
invalidation contract), and refs with a non-env source prefix
(bitwarden:/vault:/file:) warn with a pointer to the secrets: block
instead of being silently treated as a variable named 'bitwarden:FOO'.
Salvaged from PR #59516 — the audit-CLI half and the main() exit-code
change were out of scope and are not included.
Co-authored-by: andynguyendk <35395190+andynguyendk@users.noreply.github.com>
Snapshot values applied by external secret sources per resolved HERMES_HOME so a later profile cannot replace an earlier profile scope through shared os.environ.
Keep provider and credential-pool fallback reads on the active secret scope, and fail closed on unscoped multiplex reads.
Tests: scripts/run_tests.sh tests/test_env_loader_secret_sources.py tests/test_env_loader_op_bootstrap.py tests/agent/test_secret_scope.py tests/agent/test_credential_pool.py tests/tools/test_credential_pool_env_fallback.py tests/hermes_cli/test_xiaomi_provider.py tests/cron/test_run_one_job.py tests/hermes_cli/test_api_key_providers.py tests/gateway/test_multiplex_credential_isolation.py -q (395 passed)
Keep the env-presence row, but add a real Bitwarden probe so revoked or malformed tokens no longer look healthy in hermes secrets bitwarden status.
Also document the new status behavior and lock it in with a dedicated regression test.
Refs: NousResearch/hermes-agent#40275
Tested: ./scripts/run_tests.sh tests/hermes_cli/test_bitwarden_status.py tests/test_bitwarden_secrets.py
Tested: .venv/bin/python -m ruff check hermes_cli/secrets_cli.py tests/hermes_cli/test_bitwarden_status.py
The KeyboardInterrupt handler in run_gateway() was the only exit path
that still used bare 'return' instead of _hard_exit_after_gateway_teardown().
While less common than service-managed restarts, a console Ctrl+C still
leaves the process vulnerable to the same Python finalization hang on
non-daemon worker threads (cron ThreadPoolExecutor jobs). Route it through
the same backstop, with a 'return' guard for test stubs that don't raise
on code 0 (production os._exit never returns).
* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split
Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:
- /subscription on Free (admin/owner, interactive) prints the plan catalog
(name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
credits render as dollars). A numbered pick opens the manage-subscription
deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
The paid change flow's blocked/unknown-preview portal fallback carries plan=
for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
stated in each first sentence ("Add funds now — a single charge…" vs "Refill
when low — charges … automatically …"), keeping "credits" out of the
dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.
Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.
* refactor(billing): share plan-catalog helpers + harden manage-url builder
- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
popping only the contract-owned org_id/plan) and restricts to http/https schemes,
matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
catalog and the paid picker/blocked-preview branch share one implementation:
selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
· $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
hidden when absent/zero), and is_upgrade(state, tier_id).
* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy
- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
device-code flows' console-browser / remote-session guard (webbrowser.open returns
True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
both amounts are present and finite; otherwise the generic sentence.
* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)
Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.
Changing a single color kept wrecking the rest because the agent hand-authored a
new skin (often from `default`, which has no `background`, resetting the terminal
to black). Add `hermes skin set <key> <hex>`: edits the ACTIVE skin's one key in
place (a built-in is forked into an editable copy carrying its full palette), so
everything else — background included — is preserved. Plus `skin use` / `skin
list`. The skill now points tweaks at this command instead of hand-authoring.
Pixel-sampled against the reference screenshots: the beloved classic
light-mode look is the vivid authored golds rendered essentially raw
(#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no
contrast lift of their own, and pre-darkening every foreground to WCAG 4.5
produced the reported mustard mud. Light-mode display floors become
near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps
1.45/2.2); the default skin ships a fills-only light_colors OVERLAY
(polarity-flip the navy menu/status fills, foregrounds inherit the vivid
colors), and themeForSkin merges polarity overlays instead of replacing.
Palette audit reworked: base colors audited fully, overlays for valid keys
and fill polarity.
The desktop color-mix system, ported: a theme is a handful of identity
SEEDS (text, primary, accent, border, status hues); every secondary tone
(muted, label, surfaces, chips, selection) is a color-mix derivative
against the real terminal background. lib/color.ts consolidates the
primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative
liftForContrast). Knobs are grid-search fitted so the math reproduces the
classic hand-tuned literals (contract-tested). The display shim renders
authored palettes RAW and only rescues near-invisible colors. Boot reads
the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first
frame paints in the right palette (no default-dark flash), and the
placeholder cursor follows the bubbles textinput pattern.
OSC-11 asks the terminal for its actual background at startup (env
heuristics are blind on xterm.js hosts) and the theme re-derives against
the answer: desktop-contract adaptation (contrast floors + fill polarity),
a shared list-row selection primitive instead of per-picker panel fills,
paired light_colors/dark_colors skin blocks with a machine audit, and a
/theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies.
E2E coverage for the OSC reply chain + /theme-info diagnostics.
The /api/status gateway_updated_at field and the gateway /health/detailed
updated_at field passed through whatever gateway_state.json contained,
untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy
gateways wrote unix epoch floats, and a corrupt or hand-edited state file
can inject numbers or arbitrary garbage — while the frontend types
(web/src/lib/api.ts) declare string | null.
Add normalize_updated_at() in gateway/status.py as the single funnel:
- str: accepted iff datetime.fromisoformat parses (trailing Z tolerated);
naive timestamps coerced to UTC; canonical isoformat returned
- int/float: treated as unix epoch seconds -> UTC ISO string, with a
plausibility guard (reject < 2000-01-01, > now+1day, non-finite)
- bool: rejected explicitly (int subclass, but never a timestamp)
- anything else: None
Apply it at both emit sites: the dashboard /api/status handler (covers
both the local read_runtime_status() branch and the remote
/health/detailed cross-container fallback branch) and the gateway API
server's /health/detailed response.
Contract tests: parametrized /api/status normalization (epoch float/int,
garbage string, None, bool, dict, absent key), remote-health numeric and
garbage bodies, dashboard shape test round-trip assertion, direct
normalize_updated_at units (range guards, Z suffix, naive coercion,
non-finite floats), and a write_runtime_status -> read_runtime_status
round-trip proving the writer side stays tz-aware parseable.
The dashboard's own liveness surface could report healthy while every
authenticated request 500'd (e.g. wedged state DB) — /api/status carried
gateway-only fields, no storage/dashboard signal, and no middleware
counted unhandled exceptions.
- DashboardHealth state holder: rolling 5-min deque of unhandled-error
timestamps + last self-test result. last_error_type/last_error_path
are internal-only diagnostics — snapshot() exports counts/enums/
timestamps exclusively (PUBLIC_API_PATHS no-secrets contract).
- Outermost @app.middleware('http') (registered last) wraps call_next
in try/except: records + re-raises unhandled exceptions, and records
responses with status >= 500.
- /api/status gains 'components' {gateway, storage, dashboard,
platforms} + top-level 'overall' ok|degraded. storage reuses the
gateway readiness state_db probe (read-only, 1s-bounded) in an
executor; platforms derive ok/degraded from existing
gateway_platforms states.
- Authenticated self-test task started in the lifespan: every 60s an
in-process httpx ASGITransport GET of /api/sessions?limit=1 with the
real _SESSION_TOKEN, feeding the dashboard component. Skips cleanly
when httpx is unavailable and while the OAuth gate is engaged (the
legacy token is not honoured there).
Tests: middleware increments on raising route and on 5xx, window
expiry, components shape + overall, storage degraded when the state_db
probe fails, dashboard degraded after an error, no secret-bearing
fields in the public payload, self-test pass/fail recording (mocked
client) + a real ASGI round trip.
start_background_mcp_discovery() sets _mcp_discovery_started once and never
resets it. If the first background run exits without connecting any MCP
server (startup cancellation, OOM restart, transient network failure), every
later call returns immediately and the process is permanently stuck with
zero MCP tools until a full restart.
Fix: when discovery is marked started but the thread is dead and no server
is connected, reset the flag and spawn a fresh discovery thread. Also log a
WARNING when a discovery run completes with zero connected servers, so the
condition is visible instead of silent.
Caught in production on a long-running gateway fleet where a gateway
restarted under memory pressure and came back with all MCP tools missing.
PR #63455 shipped the flock without tests. Cover the three contention
paths: contended+dist serves stale without a second build, uncontended
builds under the lock (creating the lock file), and contended+no-dist
blocks then skips the rebuild because the staleness re-check runs under
the lock and sees the winner's stamp. Also pin the lock filename into
.gitignore via a regression assertion.
Replace mtime-based web UI dist staleness with a content-hash stamp under HERMES_HOME, matching the desktop build freshness model.
This avoids false stale/fresh decisions when git operations rewrite source mtimes without changing bytes, while preserving the existing stale-dist fallback. Also treats malformed or missing stamp data as needing a rebuild.
mount_spa degrades to a JSON 404 catch-all when the dist directory is
fully missing, but _serve_index read index.html unguarded — so a dist
dir that exists while index.html is missing (partial build, wiped dist,
permissions) raised FileNotFoundError on EVERY request instead of
returning a useful error.
Catch OSError around the read and return the same JSON 404 payload the
fully-missing-dist path uses, so clients get a consistent signal. The
route recovers automatically once a rebuild restores the file.
--skip-build with a missing web_dist/index.html previously hard-failed
with sys.exit(1) (issue #59288). The desktop launcher passes
--build-mode skip on every boot, so a wiped or never-populated dist
bricked the dashboard until the user manually rebuilt.
Now the default-dist path logs a clear warning and attempts exactly ONE
recovery build through the existing _build_web_ui path. If the recovery
build also fails to produce index.html, the original fatal behavior is
preserved with a clear message. A custom HERMES_WEB_DIST stays fail-fast:
the build writes to the default dist location and cannot populate a
caller-managed directory.
Closes#59288
Adds kanban_db.repair_db() — a structured, non-raising wrapper around
the same narrow repair policy as the connect-time guard: probe with
PRAGMA integrity_check under the board's cross-process init flock;
quarantine the corrupt bytes FIRST via the content-addressed backup;
REINDEX only when every integrity message is index-scoped; re-check;
report ok / repaired / corrupt / missing. Locked/busy OperationalError
still propagates raw (a locked healthy DB is not corruption and gets
no quarantine), and a repair invalidates the per-process healthy-path
cache so the next connect() re-probes.
The CLI verb reports status human-readably (or --json), exits 0 for
ok/repaired/missing and 1 when the DB is still corrupt (non-index
corruption stays fail-closed with manual-recovery guidance). It
dispatches BEFORE kanban_command's auto-init: init_db() raises
KanbanDbCorruptError on a corrupt board, which previously would have
made a repair verb unreachable on exactly the boards that need it.
CLI tests drive the real argparse surface (build_parser +
kanban_command) against real corrupted SQLite fixtures.
Kanban connections set wal_autocheckpoint=100, but SQLite's passive
autocheckpoint backs off whenever any reader holds an open snapshot —
on a busy multi-process board the -wal file can grow without bound
between gateway restarts.
After each successful dispatch tick, while still holding the board's
single-writer dispatch flock, run PRAGMA wal_checkpoint(TRUNCATE)
best-effort at a coarse interval (>=5 min since this process last
checkpointed that board; module-level per-path monotonic timestamp, so
multi-board dispatchers checkpoint each board on its own clock).
Success and busy/locked skips are both logged at DEBUG; a failing
checkpoint can never fail the tick.
Content-addressed quarantine backups dedupe identical corrupt bytes,
but corruption that keeps mutating between failures (partial repairs,
further damage across dispatcher retries, multi-profile fleets) mints a
new sha-named backup every round — a user accumulated 124
.corrupt.*.bak files with no bound.
After each NEW backup is created, prune oldest-by-mtime backups beyond
_CORRUPT_BACKUP_RETENTION (module constant, default 10), including the
copied -wal/-shm sidecars. The just-created backup is always exempt
(copy2 preserves the source mtime, which can be older than existing
backups). Pruning is best-effort and never masks the corruption error
about to be raised; dedupe of identical corrupt bytes is unchanged.
_guard_existing_db_is_healthy previously failed closed on ANY
integrity_check failure, including the index-scoped class ('wrong # of
entries in index <name>' / 'row N missing from index <name>') where the
table b-trees are intact and REINDEX rebuilds the damaged indexes
losslessly. Boards hit by that class were bricked until manual surgery
even though SQLite can fix them in-place.
Now, when integrity_check output consists ONLY of index-scoped errors
(index name parsed generically from the message — no hardcoded list):
1. quarantine the corrupt bytes FIRST via the existing content-
addressed _backup_corrupt_db,
2. under the caller-held cross-process init flock, REINDEX each named
index (falling back to bare REINDEX if a parsed name doesn't
resolve),
3. re-run integrity_check and proceed only if it comes back clean.
Any non-index error class (page corruption, malformed image, freelist
damage) — or a REINDEX whose re-check is still dirty — fails closed
exactly as before: backup + KanbanDbCorruptError, no silent recreation.
Transient OperationalError (locked/busy) still propagates raw with no
quarantine.
Tests build a real board DB and corrupt a live index via the
writable_schema/partial-index REINDEX trick to produce the genuine
'wrong # of entries in index' shape, then assert auto-repair recovers
with data intact, page corruption still raises, and a dirty re-check
fails closed.
* feat(desktop): configure repository discovery
* fix(config): preserve additive default migration
* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe
projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).
Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.
Supersedes #67630; incorporates review feedback from that PR.
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
---------
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
* feat(secrets): one-command token rotation + actionable startup errors for all secret sources
When a Bitwarden machine-account token expired, users saw a raw Rust
error dump (invalid_client + Location: + backtrace hints) and the only
fix was manually editing .env or re-running the whole setup wizard.
- New `hermes secrets bitwarden token` / `hermes secrets onepassword
token`: paste a new token (masked prompt or flag), the command probes
the backend BEFORE persisting — a rejected token changes nothing; a
good one is written to .env and the fetch caches are cleared.
- New optional SecretSource.remediation(kind, cfg) hook: startup
warnings now print a '→ Run `hermes secrets <name> token`…' fix-it
line after any fetch error, for bundled AND plugin sources (generic
per-ErrorKind defaults in the ABC).
- bws stderr is summarized to its cause line (Location:/backtrace noise
dropped) and invalid_client/invalid_grant/400 identity rejects are
now classified AUTH_FAILED (was INTERNAL) with a plain-English
explanation naming the token env var.
- op whoami probe accepts a candidate token so rotation validates the
NEW credential, not the ambient one.
Additive hook with defaults — no SECRET_SOURCE_API_VERSION bump.
* docs: fix MDX parse error in secret-source-plugin hook table
Escaped backticks around a <name> placeholder made MDX parse it as an
unclosed JSX tag, breaking the docs-site build. Use a plain code span
instead.
Add regression tests locking in the new behavior: a JWT carrying a
chatgpt_account_id claim causes the probe to send ChatGPT-Account-Id,
while a malformed token omits the header instead of crashing.
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending
The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.
- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
admin can turn it on from the portal's Hermes Agent page") instead of
the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
unchanged; copy, comments, docs, and test expectations only.
* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename
Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
Sync with main after the contribution-shell refactor (#60638) and the backendConnectionState extraction (#65885) landed. Seven conflicts, all resolved in favor of main's new architecture with the SSH lifecycle ported on top:
- electron/main.ts: adopt backendConnectionState (attempt tokens, attachProcess, clearPromiseForAttempt) as the sole owner of the primary backend; drop the branch's raw hermesProcess/connectionPromise globals and commitConnectionFailure call site. SSH liveness-streak classification, teardown, and the SSH quit path are layered onto the new ownership model; before-quit combines SSH transactional teardown with the Windows sandbox marker (#38216).
- desktop-controller.tsx: deleted on main; its SSH beforeConnectionSwitch cleanup (preserved-route fresh draft, overlay return-route reset, project-tree reset, close-all-terminals) moves to contrib/wiring.tsx's useGatewayBoot call.
- use-session-actions/index.ts: keep main's onFreshDraftRouteIntent (fires unconditionally); preserveRoute gates only the navigate.
- gateway-settings.tsx: keep main's acceptSavedConfig/connectedCloudUrl; every save/sign-in/sign-out success path routes through acceptSavedConfig with the branch's stale-async seq guards intact.
- boot-failure-reauth.ts: keep both sshFailureMessage and main's isRemoteReauthFailure formatting.
- Both test harnesses take the union of props.
Validation: tsc -b clean; desktop suite 1704 passed / 1 skipped; electron SSH/connection modules 225 passed; python SSH + web_server suites 508 passed.
Both providers share the same models.dev ID (kimi-for-coding) but
have different API keys (KIMI_API_KEY vs KIMI_CN_API_KEY) and base
URLs (moonshot.ai vs moonshot.cn). The /model picker was only
showing one because the dedup key was mdev_id alone.
Changes in list_authenticated_providers():
- Resolve canonical provider profile name and skip alias hermes_ids
(e.g. "kimi", "moonshot" → "kimi-coding") so only canonical
entries are processed.
- Deduplicate by slug (hermes_id) instead of mdev_id so distinct
profiles sharing a models.dev ID (kimi-coding vs kimi-coding-cn)
both appear.
- Prefer PROVIDER_REGISTRY name for the display label so the CN
variant shows "Kimi / Moonshot (China)" instead of the generic
models.dev name.
Adds test coverage for all three key scenarios:
- Only KIMI_CN_API_KEY set → only kimi-coding-cn appears
- Only KIMI_API_KEY set → only kimi-coding appears
- Both keys set → both providers appear, aliases not duplicated
Closes#10526
A single Kimi credential surfaced two rows in the `/model` picker — the
bare alias `kimi` (PROVIDER_TO_MODELS_DEV pass) and the canonical
`kimi-coding` (CANONICAL_PROVIDERS cross-check, section 2b) — both backed
by the same `kimi-for-coding` provider.
`kimi`, `moonshot` and the canonical `kimi-coding` all map to one
models.dev id (`kimi-for-coding`). The seen_mdev_ids guard collapses them
to the first key in section 1, but that key is the bare alias, so 2b
re-emits the canonical name as a second row.
Emit the row under the canonical Hermes slug instead: resolve the alias
via _PROVIDER_ALIASES (`kimi` -> `kimi-coding`) before appending, so 2b's
seen_slugs check collapses the pair. This matches the picker's other alias
rows (copilot, gemini) and the overlay slug-resolution contract, and keeps
the surviving row resolvable to the real provider. A defensive seen_slugs
guard prevents emitting a duplicate canonical row.
Distinct providers keep their own row: `kimi-coding-cn` has its own
KIMI_CN_API_KEY and is still emitted by section 2b.
Regression tests assert the single-key case yields one `kimi-coding` row
(fails on clean main, which shows both `kimi` and `kimi-coding`) and that
the China endpoint is preserved.
Fixes#49439
TestPtyWebSocket's two python-resolution tests and the sibling fixture in
test_tui_resume_flow.py hard-linked sys.executable into pytest's tmp_path.
On machines where /tmp is a different filesystem than the venv (tmpfs vs
disk home) os.link raises OSError EXDEV and the tests fail before reaching
any assertion. copy2 preserves the executable bit and works across devices.
c253b0738 added clear_model_endpoint_credentials() to scrub an old endpoint's
inline secret (api_key, the legacy `api` alias, api_mode) when the web UI
switches the main model to a different provider. But _apply_main_model_assignment
gates the key-scrub path on model_cfg["api_key"] being truthy, so when the stale
secret lives only under the legacy `api` alias (no api_key), a provider switch
never clears it — the secret survives in config.yaml.
model.api is a live credential read path (_resolve_openrouter_runtime reads
`for k in ("api_key", "api")`), so the old endpoint's key contaminates a later
custom resolution — the exact harm clear_model_endpoint_credentials documents.
The sibling persistence sites (the gateway model-picker paths and the aux-slot
path) call the helper unconditionally on a non-custom switch and already scrub
`api`; only this caller had the api_key-only gate.
Widen the guard to fire on either field. The same-provider re-pick and
explicit-new-key paths are unchanged. Adds the api-alias case to the assignment
test (it fails without the fix).
Follow-up widening for salvaged PRs #67115, #67685, #67620:
- _PROVIDER_MODELS: add kimi-k3 atop kimi-coding / moonshot / opencode-go
curated lists (kimi-coding-cn covered by cherry-picked #67620)
- setup.py _DEFAULT_PROVIDER_MODELS: kimi-k3 for kimi-coding(-cn) + opencode-go
- model_metadata: align DEFAULT_CONTEXT_LENGTHS kimi-k3 entry to 1,048,576
(matches endpoint-scoped override, models.dev, and OpenRouter live metadata)
- anthropic_adapter: classify the bare Coding Plan slug 'k3' (and k3.x/k3-*)
as Kimi family so adaptive thinking applies on proxied endpoints
- moonshot_schema: is_moonshot_model matches bare 'k3' so tool-schema
sanitization runs on the chat-completions path
- contributor mappings for githubespresso407, datachainsystems, Punyko8
Tests: 582 passed across 11 targeted files; hermetic E2E verifies picker
order (kimi-k3 first), no dupes, and 1M context resolution.
activate_custom_endpoint copies the endpoint's base_url and api_key onto
cfg["model"]. delete_custom_endpoint pops the providers entry and saves —
it never touches that mirror.
So deleting the endpoint the agent is currently using leaves both behind:
DELETE /api/providers/custom-endpoints/acme -> 200
providers entry gone : True
model.api_key : sk-CUSTOM-ENDPOINT-SECRET
model.base_url : https://llm.acme.corp/v1
Two consequences, both silent:
* The agent keeps authenticating to the deleted host with the deleted key.
model.api_key outranks the environment at client construction, so this
also shadows whatever the operator configures next — the persistent-401
shape credential_lifecycle.py documents as #62269.
* A credential the operator just removed through the dashboard stays
sitting in config.yaml.
Scrub the main-slot mirror on delete, but only when it actually names the
deleted provider — an endpoint deleted while a different one is active must
leave that active assignment untouched. Both directions are pinned by tests.
_write_custom_endpoint builds a fresh entry dict from the request body and
assigns it over providers[endpoint_id], carrying nothing forward but api_key.
A providers.<name> block is not owned by that panel. It can carry keys the
dashboard has no field for, all of them load-bearing:
api_mode the protocol the endpoint speaks
key_env where the credential comes from
extra_headers per-provider HTTP headers (may carry credentials)
request_overrides extra body params
and a models map with more than the one model the panel names.
So an edit that only changes the default model destroys the rest:
BEFORE api_mode, base_url, extra_headers, key_env, model, models,
name, request_overrides
AFTER base_url, discover_models, model, models, name
FIELDS DESTROYED: ['api_mode', 'extra_headers', 'key_env',
'request_overrides']
The provider is left with no credential wiring (key_env gone, no api_key),
talking the wrong protocol, missing its proxy auth header — from a UI action
that said nothing about any of that. The models map also collapses to the one
named model, dropping the others and their context_length.
Merge onto the existing entry instead of replacing it, and merge the models
map rather than overwriting it. Managed fields still win, so the edit itself
still applies; a brand-new endpoint is unchanged. api_key keeps its previous
semantics — a supplied key overwrites, an omitted one leaves the stored key
in place (now via the merge rather than an explicit carry-forward branch).