Commit graph

16823 commits

Author SHA1 Message Date
Teknium
e66e02dc83 test(gateway): activate multiplex flag in cross-profile env isolation test
The test installs a secret scope and asserts a scope miss does NOT fall
back to the default profile's env — that isolation guarantee only holds
under multiplexing, which the real gateway activates at startup via
set_multiplex_active().  With the #67827 overlay fallthrough (scope miss
→ os.environ when multiplex is OFF), the test needs to model the
multiplexed runtime it is actually testing.
2026-07-22 03:20:35 -07:00
Soju06
c758ded6d2 fix(secrets): fall back to os.environ on scope miss when multiplexing is off
fdab380a1 wraps every cron job in a <home>/.env secret scope regardless of
deployment mode. get_secret() treats any installed scope as authoritative,
so in single-profile deployments where provider keys live only in the
process environment (systemd Environment=, pass-cli/op run wrappers, shell
exports) every cron credential read returns empty, the OpenAI client is
built with the no-key-required placeholder, and each scheduled job 401s —
while interactive turns keep working. Scope-miss reads now fall through to
os.environ when multiplexing is off; multiplexed scopes stay authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 03:20:35 -07:00
Teknium
c7b0c0d35f
fix(secrets): mark _APPLIED_HOMES only after a real fetch attempt (#40597) (#69056)
_apply_external_secret_sources() added the home to _APPLIED_HOMES before
loading config, so a malformed config.yaml, a missing secrets section, or
all-sources-disabled permanently disabled secret loading for the process
— even after the user fixed the config.  Long-lived processes (gateway)
never recovered without a restart.

Now the home is marked only after apply_all() actually ran with at least
one enabled source.  Fetch errors still mark the home (so import-time
load_hermes_dotenv() calls don't re-fetch and re-print the same failure
3-5x per startup); the cheap early-exit paths stay retryable.

Fixes #40597.
2026-07-22 03:20:25 -07:00
izumi0uu
8e089db689 fix(secrets): validate bitwarden status token
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
2026-07-22 03:20:09 -07:00
andrexibiza
fe5d0be6db fix(env): stop printing Bitwarden secret names 2026-07-22 03:19:58 -07:00
SeoYeonKim
2beed8b53d fix(mcp): pass secret-source-injected env vars to stdio servers
Surgical reapply of PR #37523 onto current main (the original branch
predates the SecretSource registry refactor).  _build_safe_env() now
forwards env vars tagged in env_loader._SECRET_SOURCES — widened from
Bitwarden-only to any registered secret source (Bitwarden, 1Password,
plugin backends), since the provenance map is source-agnostic.
Explicit server env: config still wins; untagged secrets stay filtered.

Fixes #37499.
2026-07-22 03:19:42 -07:00
menhguin
fe8c0f7eef fix(secrets): pass OP_LOAD_DESKTOP_APP_SETTINGS through to the op child env
The 1Password secret source builds a minimal allowlisted environment for the
`op read` child process. The allowlist omits OP_LOAD_DESKTOP_APP_SETTINGS, so a
user who exports it (shell, .env, or service unit) sees it silently stripped
before it reaches `op`.

That var is `op`'s documented switch to skip the desktop-app integration probe.
When the 1Password desktop app is installed, `op` probes its settings/socket at
startup *before* evaluating service-account auth. If the desktop app's group
container is wedged (e.g. macOS 'Interrupted system call' on the 1Password group
container), that probe blocks with no timeout, so `op read` hangs indefinitely
even with a valid OP_SERVICE_ACCOUNT_TOKEN present. Setting
OP_LOAD_DESKTOP_APP_SETTINGS=false is the intended escape hatch — but stripping
it means it has no effect on exactly the headless boxes that need it.

Fix: add OP_LOAD_DESKTOP_APP_SETTINGS to _OP_ENV_ALLOWLIST so the documented
var reaches the child. No behavior change when it's unset. Adds a focused test
alongside the existing allowlist test.

Repro: on a machine with a wedged 1Password desktop container + a valid SA
token, `op read` hangs 600s+ without the var and returns in ~4s with it — but
only if it actually reaches the op process, which this allowlist entry ensures.

Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com>
2026-07-22 03:19:32 -07:00
briandevans
a616b9fb0f fix(secrets): fold OP_CONNECT_HOST/OP_CONNECT_TOKEN into 1Password auth cache-key
_auth_fingerprint() built the 1Password secret cache-key from the
service-account token, OP_ACCOUNT, and OP_SESSION_* vars but omitted
OP_CONNECT_HOST/OP_CONNECT_TOKEN, which are in _OP_ENV_ALLOWLIST and are
forwarded to the op child (the Connect-server auth path). Rotating
OP_CONNECT_TOKEN or re-pointing OP_CONNECT_HOST at a different Connect
identity left the fingerprint unchanged, so both the in-process and disk
caches kept serving secrets resolved under the old Connect credentials for
the full TTL (default 300s, disk-persisted across invocations). This
contradicts the function's own docstring invariant that a value cached
under a previous identity is never served under a new one; it closes the
gap for the Connect path, matching the OP_SESSION_*/service-account paths
that are already protected.
2026-07-22 03:19:32 -07:00
JackJin
7db521a697 fix(secrets): port stale-cache fallback to current DiskCache API + gate by error kind
The stale-fallback branch called _read_disk_cache(), a helper removed in
db495b0fba when disk-cache logic moved to the
shared DiskCache class — every fallback attempt raised NameError instead of
serving cached secrets, silently defeating the PR's whole purpose. Port to
_DISK_CACHE.read().

Also tighten the fallback per DiskCache's TTL contract and the secret-source
error taxonomy:
- Gate on cache_ttl_seconds > 0 so a caller that opted out of caching
  entirely (ttl=0) never gets a secret value that didn't come from a live
  fetch, even on the failure path.
- Gate on _classify_bws_error(str(exc)) being NETWORK or TIMEOUT, reusing
  the existing classifier — an AUTH_FAILED or malformed-output failure must
  still raise, since serving stale secrets there would mask a real
  credential/config problem instead of a transient outage.

Ported the test helpers off the removed _write_disk_cache to a direct JSON
write (matching this file's existing disk-cache test convention) and added
tests for the auth-failure, malformed-output, and zero-TTL gates. Reverting
the fix and re-running confirms 7 of 8 stale-fallback tests fail with the
original NameError.
2026-07-22 03:19:16 -07:00
JackJin
77f3b3ef7f fix(secrets): fall back to stale disk cache when bws live fetch fails
Without this, a single DNS hiccup or BWS outage at gateway startup leaves
the whole fleet running with an empty credential pool — every model call
fails until someone restarts after the network recovers.  When a previous
successful fetch already populated the disk cache, return those secrets
with an explicit warning instead of raising RuntimeError.

`use_cache=False` (explicit opt-out) still raises so manual flows like
the setup wizard surface the original error.  The disk cache is not
re-written on the fallback path so a process restart still triggers a
proper TTL re-check.

Fixes #41925
2026-07-22 03:19:16 -07:00
kshitij
7de554277d chore: add kshitij@kshitij.dev to AUTHOR_MAP 2026-07-22 14:12:50 +05:30
kshitij
0c76cc6c36 fix: discard both session IDs on compression for profile re-injection
The _profile_prefetched_sessions set stores whichever session_id was
passed to prefetch(), which may differ from self._session_id. On
compression, only old_session_id (self._session_id) was discarded,
missing the case where the stored key was the prefetch session_id
parameter. Discard both old and new IDs to cover all cases.
2026-07-22 14:12:50 +05:30
Hao Zhe
8af2133009 fix(openviking): align session context with shared profile contract 2026-07-22 14:12:50 +05:30
Flownium
11c1ca01c5 fix(openviking): inject session-start memory context
(cherry picked from commit 18b474d0bd)
2026-07-22 14:12:50 +05:30
kshitij
c3c80e1796 refactor: cleanup follow-up for salvaged PR #58871
- Remove dead current_sid parameter from _recover_pending_sessions
- Remove dead cleanup parameter from _release_owner_run_claim (always True)
- Set _run_lock_path after flock succeeds, not before
- Collapse redundant BlockingIOError branch (covered by OSError+errno check)
- Track _pending_marked_sids to skip re-writing marker file on every sync_turn
2026-07-22 14:05:28 +05:30
Chris Korhonen
81fc424592 fix(openviking): chunk structured session sync
Preserve ordered structured turns across OpenViking's 100-message batch limit and resume retries from the first unconfirmed message.

Based on the OpenViking batching work from commit 1a567f7067 in #58981.
2026-07-22 14:05:28 +05:30
Hao Zhe
3fd96583f4 fix(openviking): serialize orphan session recovery 2026-07-22 14:05:28 +05:30
Naveen Fernando
f7c198dec8 docs: clarify OpenViking local setup
(cherry picked from commit a6807170f1)
(cherry picked from commit 6fb4e9aa8a)
2026-07-22 14:05:28 +05:30
Hao Zhe
323e9baf5d fix(openviking): recover pending session commits 2026-07-22 14:05:28 +05:30
kshitijk4poor
9ecacd6bf4 fix(telegram): update auth check tests for group_allow_from split
Update test_telegram_auth_check.py to use group_allow_from for group
messages (matching the PR's intentional behavior split: allow_from for
DMs, group_allow_from for groups). Add test_is_user_authorized_from_message_group_allow_from
to cover the new group path.
2026-07-22 12:47:41 +05:30
kshitijk4poor
7078430934 fix(telegram): address review findings from PR #67816
- Update test_observed_group_context_preserves_slash_command_text_for_dispatch
  to assert user_id is preserved for COMMAND messages (new correct behavior)
- Add _coerce_allow_set helper to handle both list and comma-separated
  string allowlist inputs (prevents character-by-character iteration bug)
- Include 'channel' in chat_type checks for group-scoped authorization
- Add _telegram_extra fallback for group_allowed_chats (consistent with
  group_allow_from fallback)
- Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose
2026-07-22 12:47:41 +05:30
Nyaruko
45fce38b9e fix(telegram): group authz fallback + command sender identity
- authz_mixin: add config.extra fallback for group_allowed_chats
  when observe-unmentioned mode strips user_id from env-var check
- authz_mixin: check adapter allow_from/group_allow_from for
  user authorization from config.yaml without env vars
- telegram/adapter: separate group_allow_from for group chats
  vs allow_from for DMs
- telegram/adapter: preserve sender source for command messages
  so admin-only slash commands work in groups
- telegram/adapter: add _telegram_extra fallback for
  group_allow_from config reading
2026-07-22 12:47:41 +05:30
kshitijk4poor
9eb7b1a6b1 chore: AUTHOR_MAP for McHermes 2026-07-22 11:46:05 +05:30
McHermes
f4896015c1 fix(compression): ignore assistant handoff summaries in tail anchor
Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize.

Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion.
2026-07-22 11:46:05 +05:30
kshitijk4poor
9fa2906c18 fix: restore base_url rstrip, extract should_clear_context_pin helper
Salvage follow-up for PR #68899:
- Restore .rstrip('/') on base_url in _swap_credential (both anthropic
  and OpenAI paths) to match every other assignment site. The route
  identity comparison still uses normalize_route_base_url which handles
  trailing slash correctly.
- Extract should_clear_context_pin() into hermes_cli/route_identity.py,
  consolidating 7 copy-pasted call sites across cli.py, gateway/run.py,
  gateway/slash_commands.py, and hermes_cli/model_switch.py into a
  single fail-closed helper.

C1 (anthropic path TLS re-application): pre-existing gap — the Anthropic
adapter (build_anthropic_client) has no TLS customization support at
all, so this is out of scope for this salvage.
2026-07-22 11:19:37 +05:30
cucurigoo
63dd651b3d fix(providers): scope route-owned runtime settings 2026-07-22 11:19:37 +05:30
cucurigoo
f3f0135154 fix(providers): fail closed on missing active route 2026-07-22 11:19:37 +05:30
cucurigoo
ddd667503e test(providers): cover URL whitespace route identity 2026-07-22 11:19:37 +05:30
cucurigoo
639cee5216 test(providers): complete hermetic route coverage 2026-07-22 11:19:37 +05:30
cucurigoo
2507af2194 test(providers): cover query path slash identity 2026-07-22 11:19:37 +05:30
cucurigoo
fcae6fb9b8 test(providers): cover route URL identity boundaries 2026-07-22 11:19:37 +05:30
cucurigoo
ca6b8cd85f test(compression): cover overflow after blocked preflight 2026-07-22 11:19:37 +05:30
cucurigoo
cb785e6b49 fix(providers): align custom route scoping 2026-07-22 11:19:37 +05:30
cucurigoo
97499d702e fix(compression): harden startup route scoping 2026-07-22 11:19:37 +05:30
cucurigoo
377244f7c8 fix(compression): prevent stale-budget retry loops 2026-07-22 11:19:37 +05:30
kshitijk4poor
3e953ed815 chore: AUTHOR_MAP for tinetwork 2026-07-22 10:48:10 +05:30
tinetwork
52d219b072 fix: refresh vulnerable npm lockfile entries 2026-07-22 10:48:10 +05:30
HexLab98
b7b0c37ef7 test(update): cover fleet restart timeout isolation (#68523) 2026-07-22 10:35:50 +05:30
HexLab98
e99882c2d9 fix(update): isolate systemctl timeouts per gateway unit during fleet restart
A TimeoutExpired from one hermes-gateway*.service used to abort the whole
per-scope restart loop, leaving later profile gateways on pre-update
in-memory code after hermes update. Catch timeouts per unit, continue the
fleet, warn with the exact stale units, and exit non-zero when any remain
unrestarted (#68523).
2026-07-22 10:35:50 +05:30
kshitijk4poor
4999de8fe6 chore: map anoop.mehendale@gmail.com -> anoopmehendale-cue
For PR #69007 salvage (#69112).
2026-07-22 10:31:59 +05:30
anoopmehendale-cue
2ab153218b fix(gateway): make adapter fatal-error handoff cancellation-proof; exit if a platform is stranded
The fatal-error notification runs on the failing adapter's own polling
task, and adapter.disconnect() inside the handler can cancel that task
(its current-task guard misses because _safe_adapter_disconnect runs the
close in a wrapper task). The CancelledError killed the handler between
the fatal log and the reconnect queue, leaving the platform permanently
dead inside a live gateway process. #68447 fixed this for telegram at
the adapter layer; this hardens the shared gateway dispatch so every
platform gets the same protection (qqbot #25505/#29005, photon #68693).

- _handle_adapter_fatal_error now runs the real handler in a detached
  task, awaited through asyncio.shield() so caller cancellation cannot
  tunnel into it (Task.cancel() also cancels the task's _fut_waiter).
- If a retryable platform still ends up neither reconnected nor queued,
  the gateway exits with failure so launchd/systemd KeepAlive restarts
  it instead of running indefinitely with a dead platform (#68693).

Fixes #68693

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:31:59 +05:30
kshitijk4poor
e57918ac80 test: update mock assertions for conversation_history kwarg
The /branch and /resume flush tests asserted the old positional-only
call signature. Update to match the fix from #68480.
2026-07-22 10:00:39 +05:30
kshitijk4poor
a46fbafe31 test: drop source-grep change-detector from #68480
The three behavior tests (control proves dup, boundary is noop, tail-only
write) fully cover the flush semantics. The source-grep test reading
cli.py + cli_commands_mixin.py as text and asserting a string appears is
a change-detector that breaks on benign refactors without adding coverage.
2026-07-22 10:00:39 +05:30
Bartok9
1c0a57832b fix(cli): pass conversation_history on /new /resume /branch flush
Closes #68454

Root cause: cold-resumed transcript rows lack _DB_PERSISTED_MARKER until a
normal turn flush stamps them. Immediate /new,/resume,/branch flushed with
no history boundary, so every restored row was re-appended to the old session.

Fix: pass conversation_history=self.conversation_history at all three sites
(mirrors #68205). Add offline regression coverage for noop + tail-only write.

Verification: pytest tests/agent/test_session_rotation_flush_cold_resume_68454.py (4 passed)
2026-07-22 10:00:39 +05:30
kshitijk4poor
4425ddd94d fix(gateway): hard-exit on KeyboardInterrupt path too
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).
2026-07-22 09:53:51 +05:30
web3blind
c9ab8baf31 test: update gateway run stub for hard-exit helper 2026-07-22 09:53:51 +05:30
web3blind
fd96e138b6 fix(gateway): hard-exit CLI runner after graceful teardown 2026-07-22 09:53:51 +05:30
hermes-seaeye[bot]
d8bf3df255
fmt(js): npm run fix on merge (#69067)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-22 02:53:21 +00:00
Siddharth Balyan
60ec6a3b8e
feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761)
* feat(desktop): native in-app downgrade (chargeless preview → schedule → undo)

Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce
to the portal — picking a lower tier runs the gateway pending-change flow in-app;
the scheduled state renders on the plan card with an undo. Upgrades keep the portal
deep link.

- api.ts: add previewSubscriptionChange / scheduleSubscriptionChange /
  resumeSubscription wrappers over subscription.preview|change|resume
  ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse +
  BillingMutationResponse (now re-exported from types.ts).
- use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule,
  refetch + onScheduled on success; typed refusals surface via the shared
  BillingRefusalInline, so insufficient_scope drives the existing step-up exactly
  like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo).
  Both accept a `simulate` switch so DEV fixtures click through with canned success.
- plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an
  in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect
  <date>. No charge now; you keep your current plan until then."). The scheduled
  downgrade target renders an inert "Scheduled" marker; other lower tiers stay
  actionable (picking one reschedules).
- CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to
  <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps.
- use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the
  ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target
  by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView
  gains `pending`, derived from current.pending_downgrade_* .
- inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline /
  StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware
  refusal renderer without a circular import; openExternal now delegates to the
  canonical @/lib/external-link opener.
- dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free
  downgrade scheduled for Aug 15) for the plan-card pending state + grid marker.

Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume +
insufficient_scope refusal); view derivation (pending plan-card state, scheduled
grid marker); confirm flow (preview shown, change called with the right tier_id,
refetch on success, schedule refusal → step-up affordance); undo flow; the
use-subscription-change hooks (preview-refusal retry, cancel, simulate path).
Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck
(app/electron/e2e) + lint clean.

PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges.

* fix(desktop): format downgrade credits delta as signed dollars

The downgrade preview rendered the raw wire string ("Monthly credits change:
-88."), violating the "monthly credits are DOLLARS" ruling. NAS sends
monthly_credits_delta as a bare decimal; format it as signed dollars through the
same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero /
absent still hides the line.

Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/
absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm
flow. Billing suite 99/99 green; typecheck + lint clean.

* fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim

Addresses the adversarial review of the native-downgrade diff.

- Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule
  RPC is in flight). While a change commits, every other Downgrade tile and the
  Back button are disabled; the active panel's Confirm/Cancel already lock. The
  plan-card Undo blocks on its own resume via `busy`. (The server also 409s
  overlapping per-org mutations — this is UI honesty, not the only defense.)
- Accessibility: the confirm panel is role="status" aria-live="polite" and takes
  focus on open (tabIndex=-1 container); closing it returns focus to the tile card,
  so keyboard focus is never stranded and the async preview text is announced.
- DEV-gated simulation: the canned preview/change/resume seam is ignored unless
  import.meta.env.DEV, so a production build never takes the simulated branch even
  if a `simulate` prop leaks through.
- Comments: documented the deliberate manual-retry-after-step-up (no auto-replay,
  matching auto-reload) and that name-matching the scheduled target is safe because
  SubscriptionTypes.name is @unique in NAS.

Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule;
simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule;
Undo disabled mid-resume; confirm panel role + focus on open. typecheck
(app/electron/e2e) + lint clean.

* fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits

Addresses the native-downgrade review threads.

Scheduled cancellations were invisible (NEW review item). subscription.current
carries cancel_at_period_end + cancellation_effective_* and subscription.resume
clears cancellations exactly like downgrades, but the pending-transition helper only
read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain
renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName,
when } | { kind:'cancellation', when } — computed once in deriveBillingView and
threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with
the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a
cancellation has no target tier). Precedence: a downgrade wins if both fields are set
(it names a concrete target — the stronger signal), commented at the helper. Adds a
`pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker,
downgrade-wins precedence).

Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow)
so two same-tick clicks — before React commits busy='schedule' — cannot fire two
schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success).
useResumeFlow reorders its unlock: a refusal releases immediately, a success holds
runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending
card. Test: a synchronous double-activation fires one schedule RPC.

Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) instead of relying on outer narrowing / `?? ''`.

Billing suite green (109); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): move DEV billing simulation behind the api seam

The fixture simulation lived as `simulate` / `simulateResume` prop drills and
`if (simulated)` branches inside the flow hooks, and it could not actually produce
the state it advertised (a simulated schedule never showed the pending card).

Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi
built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known,
and supplied to the whole subtree via a new `BillingApiProvider` (context override on
`useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy
of the fixture and its subscription-change mutations WRITE that copy's pending state:
schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation.
Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted;
queries always enabled; an effect refetches on fixture switch), so the click-through
genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared.

Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every
`if (simulated)` branch — the hooks are now production-pure. Added a test driving the
full simulated loop (schedule → pending appears → resume → cleared), plus cancellation
undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests.

Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): extract billing row/card components out of index.tsx

Purely mechanical, no behavior change: split the settings billing route file
(1065 → 593 lines) into focused siblings now that the downgrade feature has settled
their final shape.

- billing-amounts.ts — the dollar parse/format/validate/clamp helpers.
- account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow).
- current-plan-card.tsx — CurrentPlanCard.
- auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor).

index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture
wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules

Polish that composes with the api-seam rework:

- ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union
  (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible
  combinations (a preview AND a refusal, "ready" with no quote) can no longer be
  represented; the hook and panel branch on one `kind`, and `mutating` is simply
  `phase.kind === 'scheduling'`.
- The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase,
  fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`.
- inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal`
  moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s
  `openExternalLink`), and `InlineMessage` moves back into its sole consumer
  (auto-reload-row.tsx).

No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.
2026-07-22 02:46:06 +00:00
Siddharth Balyan
9baad4e0aa
feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)
* 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.
2026-07-22 08:11:09 +05:30