fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)

* 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).
This commit is contained in:
Siddharth Balyan 2026-07-21 12:20:25 +05:30 committed by GitHub
parent 7a8852ddcb
commit b0da653ac8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 159 additions and 142 deletions

View file

@ -1,4 +1,4 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods

View file

@ -32,19 +32,19 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.',
title: 'Terminal billing needs approval'
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
title: 'Remote Spending needs approval'
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin turned off terminal billing for this terminal.'
: 'You turned off terminal billing for this terminal.'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Terminal billing was turned off'
title: 'Remote spending was stopped'
}
}
@ -60,8 +60,9 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message: 'Terminal billing is off for this account — an admin must enable it on the portal.',
title: 'Terminal billing is off'
message:
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
title: 'Remote spending is off'
}
case 'role_required':

View file

@ -74,7 +74,9 @@ describe('BillingSettings', () => {
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.')
screen.getByText(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy()
@ -197,10 +199,8 @@ describe('BillingSettings', () => {
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy()
expect(
screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
).toBeTruthy()
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})

View file

@ -65,7 +65,7 @@ describe('deriveBillingView', () => {
const buyCredits = view.accountRows.find(row => row.id === 'buy_credits')
expect(buyCredits?.description).toBe(
'Terminal billing is off for this account — an admin must enable it on the portal.'
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
expect(buyCredits?.chips).toBeUndefined()
expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({

View file

@ -458,7 +458,7 @@ function deriveUsageRows(
value: clamp01(usedFraction)
}
: undefined,
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly terminal billing spend',
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
id: 'monthly_cap',
title: 'Monthly spend cap',
value

View file

@ -121,7 +121,7 @@ export function useStepUpFlow() {
if (!result.data.granted) {
setMessage({
kind: 'error',
text: 'Verification finished without granting billing management access.',
text: 'Verification finished without allowing Remote Spending for this terminal.',
title: 'Verification was not approved'
})
@ -134,7 +134,7 @@ export function useStepUpFlow() {
])
setMessage({
kind: 'success',
text: 'Billing management access was verified.',
text: 'Remote Spending is allowed for this terminal.',
title: 'Verification complete'
})
}, [api, gateway, queryClient, unsubscribe])

View file

@ -1,12 +1,12 @@
/**
* Shared terminal-billing wire contracts.
* Shared Remote Spending wire contracts.
*
* These shapes round-trip between the Python tui_gateway and TypeScript clients
* such as the TUI and desktop app. Keep rendering state, client logic, and the
* gateway event union out of this runtime-free module.
*/
// ── Terminal billing (Phase 2b) ──────────────────────────────────────
// ── Remote Spending (Phase 2b) ──────────────────────────────────────
/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */
export interface UsageBarData {

View file

@ -30,7 +30,7 @@ Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`,
| `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). |
| `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` |
| Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. |
| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. |
| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page.` Same collapsed menu. |
Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level**
switch, not the per-terminal `billing:manage` scope — that's discovered
@ -44,10 +44,10 @@ Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`.
| `error` code | Copy | Portal URL | `retry_after` |
|---|---|:-:|:-:|
| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — |
| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — |
| `insufficient_scope` | `This needs Remote Spending allowed. Start a top-up to allow it, then retry.` | if present | — |
| `remote_spending_revoked` (CF-4) | `{An admin stopped remote spending for this terminal. \| You stopped remote spending for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — |
| `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — |
| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — |
| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.` | if present | — |
| `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — |
| `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — |
| `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — |
@ -136,14 +136,15 @@ just because NAS hasn't caught up yet.
| `error` | Copy |
|---|---|
| `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` |
| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` |
| `remote_spending_revoked` | `{message}` or `Remote spending was stopped for this terminal — reconnect from the portal, then retry.` |
| `rate_limited` | `Too many attempts — wait a moment, then try again.` |
| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` |
| other/unknown | `{message}` or `Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.` |
A **repeat** scope denial during a post-grant replay never re-enters the
step-up screen (it's already mounted there — re-patching would freeze it);
`allowStepUp=false` instead surfaces a terminal result: `Terminal billing
still isn't enabled for this org — enable it on the portal, then retry.`
`allowStepUp=false` instead surfaces a terminal result: `Remote Spending still
isnt active for this terminal — the authorization didnt take. Retry, or make
this change on the portal.`
## Text-mode (CLI) parity

View file

@ -4131,9 +4131,9 @@ class GatewaySlashCommandsMixin:
"""Handle /topup -- show the Nous balance and hand off to the portal.
Renders the balance block + identity line + a tappable portal URL that
opens the billing page. Terminal billing is managed on the portal: the
terminal does NOT charge, confirm, or track payment here everything
happens in the browser and the next /topup shows the new balance. The
opens the billing page. Remote spending is managed on the portal: this
messaging command does NOT charge, confirm, or track payment here
everything happens in the browser and the next /topup shows the new balance. The
tappable URL is the affordance and works on every platform (button-capable
or plain text like SMS/email). Fetched off the event loop; fail-open.
"""

View file

@ -8154,7 +8154,7 @@ def step_up_nous_billing_scope(
The lazy step-up (plan D-A): triggered when a billing endpoint returns
``403 insufficient_scope``. Runs a fresh device-connect with
``inference:invoke tool:invoke billing:manage`` on the scope. The user must be
an ADMIN/OWNER and tick "Allow terminal billing" in the portal for the minted
an ADMIN/OWNER and select "Allow Remote Spending" in the portal for the minted
token to actually carry the scope; otherwise the server silently downscopes and this
returns False.

View file

@ -382,7 +382,7 @@ class CLIBillingMixin:
if allow_stepup:
self._subscription_handle_scope_required(state, retry=("preview", tier_id))
else:
print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.")
print(" Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal.")
return
except BillingError as exc:
self._subscription_render_error(state, exc)
@ -562,12 +562,12 @@ class CLIBillingMixin:
if allow_stepup:
self._subscription_handle_scope_required(state, retry=action, idempotency_key=key)
else:
print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.")
print(" Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal.")
except BillingError as exc:
self._subscription_render_error(state, exc)
def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None):
"""insufficient_scope → grant terminal billing (step-up), then replay `retry`.
"""insufficient_scope → allow remote spending (step-up), then replay `retry`.
Mirrors _billing_handle_scope_required: the classic CLI calls
step_up_nous_billing_scope directly (it opens the browser + blocks), then
@ -577,34 +577,34 @@ class CLIBillingMixin:
print()
print(" ! One-time setup")
_cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}")
_cprint(f" {_d('To change your plan from the terminal, allow Remote Spending once. It opens your browser to authorize, then your change picks up right here.')}")
if not getattr(self, "_app", None):
print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.")
print(" Run `hermes portal` and allow Remote Spending, then re-run /subscription.")
return
confirm_choices = [
("yes", "Enable terminal billing", "open your browser to authorize"),
("yes", "Allow Remote Spending", "open your browser to authorize"),
("no", "Not now", "cancel"),
]
raw = self._prompt_text_input_modal(
title="Enable terminal billing",
title="Allow Remote Spending",
detail="Opens your browser to authorize this terminal.",
choices=confirm_choices,
)
if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes":
print(" No change made. Enable terminal billing when you're ready.")
print(" No change made. Allow Remote Spending when you're ready.")
return
print(" Opening your browser to enable terminal billing…")
print(" Opening your browser to allow Remote Spending…")
try:
from hermes_cli.auth import step_up_nous_billing_scope
granted = step_up_nous_billing_scope(open_browser=True)
except Exception as exc:
print(f" Couldn't enable terminal billing: {exc}")
print(f" Couldn't allow Remote Spending: {exc}")
return
if not granted:
print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.")
print(" Couldn't allow Remote Spending — an org admin or owner has to approve it for this org.")
return
_cprint(f" {_DIM}Terminal billing enabled.{_RST}")
_cprint(f" {_DIM}Remote Spending allowed.{_RST}")
# Bust the 30s token cache so the replay uses the freshly-scoped token. The
# cache still holds the pre-grant unscoped token, and _request only busts it
# on a 401 (not a 403 scope denial) — without this, the replay would 403
@ -637,7 +637,7 @@ class CLIBillingMixin:
msg = str(exc) or "Something went wrong."
if code == "insufficient_scope":
# Defensive: the flow routes scope to the step-up before reaching here.
_cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.")
_cprint(" 🟡 Remote Spending isn't allowed yet. Allow it, then retry.")
elif code in ("subscription_mutation_rejected", "preview_rejected"):
_cprint(f" 🟡 {msg}")
else:
@ -661,11 +661,11 @@ class CLIBillingMixin:
_cprint(f" Portal: {_url}")
# ------------------------------------------------------------------
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
# /billing — Phase 2b Remote Spending (CLI surface, all 5 screens)
# ------------------------------------------------------------------
def _show_billing(self, command: str = "/topup"):
"""`/topup` — terminal billing for Nous (one interactive modal).
"""`/topup` — Remote Spending for Nous (one interactive modal).
ZERO sub-commands: any argument is ignored. Bare ``/topup`` always
opens the Overview (Screen 1), whose numbered menu is the *only* way to
@ -710,7 +710,7 @@ class CLIBillingMixin:
Dollars-only (no "credits") mirrors the TUI /topup overlay: balance
leads in the title, the shared plan + top-up bars render below, then the
reordered menu (Add funds first). No scope preflight terminal billing
reordered menu (Add funds first). No scope preflight remote spending
is discovered reactively when a charge 403s insufficient_scope.
"""
from cli import _cprint, _b, _d
@ -762,8 +762,11 @@ class CLIBillingMixin:
self._billing_portal_hint(state)
return
if not state.cli_billing_enabled:
_cprint(f" {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.")
_cprint(f" {_d('Remote spending is off for this org.')}")
self._billing_portal_hint(
state,
reason="A billing admin can turn it on from the portal's Hermes Agent page to add funds here.",
)
return
# A missing card does NOT gate the whole overview — the org may already have
@ -778,7 +781,7 @@ class CLIBillingMixin:
return
# Add funds first, then settings, then the scopeless browser handoff.
# No "Enable terminal billing" item — that's discovered at pay time.
# No "Allow Remote Spending" item — that's discovered at pay time.
# "Add funds" charges in-terminal against the org's portal-saved card
# (server-held via POST /charge — no card ref leaves the client). A
# missing card is NOT gated here: the buy flow reacts to the server's
@ -856,8 +859,11 @@ class CLIBillingMixin:
return False
if not state.cli_billing_enabled:
print()
_cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal first.")
_cprint(f" 💳 {_d('Remote spending is off for this org.')}")
self._billing_portal_hint(
state,
reason="A billing admin can turn it on from the portal's Hermes Agent page before adding funds.",
)
return False
return True
@ -1033,7 +1039,7 @@ class CLIBillingMixin:
try:
result = post_charge(amount_usd=amount, idempotency_key=key)
except BillingScopeRequired:
# In-flight reauth: enable terminal billing, then resume THIS charge
# In-flight reauth: allow remote spending, then resume THIS charge
# (press-Enter beat) — no command re-run. Reuses the same idem key.
self._billing_handle_scope_required(state, amount=amount, idempotency_key=key)
return
@ -1129,7 +1135,7 @@ class CLIBillingMixin:
print(" 💳 No card on file — top up and manage billing on the portal.")
elif code in ("cli_billing_disabled", "remote_spending_disabled") or \
getattr(exc, "code", None) == "remote_spending_disabled":
print(" Terminal billing is off for this account — an admin must enable it on the portal.")
print(" Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.")
elif code == "role_required":
print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.")
elif code == "idempotency_conflict":
@ -1146,8 +1152,8 @@ class CLIBillingMixin:
print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.")
elif code == "insufficient_scope":
# Never leak the raw billing:manage scope (the post-grant replay can
# re-raise it if the grant raced) — the concept is "terminal billing".
print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.")
# re-raise it if the grant raced) — the concept is "Remote Spending".
print(" 🔴 Remote Spending needs approval — run /topup to allow it, then retry.")
else:
print(f" 🔴 {exc}")
if portal_url:
@ -1156,9 +1162,9 @@ class CLIBillingMixin:
def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None):
"""403 insufficient_scope → in-flight reauth, then resume the held charge.
The buy path discovers terminal billing isn't enabled only when the
charge 403s there is no preflight. We enable it in-flight ("Enable
terminal billing" → browser device-flow), then on return ask the user to
The buy path discovers remote spending isn't allowed only when the
charge 403s there is no preflight. We allow it in-flight ("Allow
Remote Spending" → browser device-flow), then on return ask the user to
press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so
the resumed charge collapses with the original). Never leaks the raw
billing:manage scope.
@ -1170,33 +1176,33 @@ class CLIBillingMixin:
amount_str = format_money(amount) if amount is not None else "your top-up"
print()
print(" ! One-time setup")
_cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}")
_cprint(f" {_d(f'To charge from this terminal, allow Remote Spending once. It opens your browser to authorize, then {amount_str} picks up right here.')}")
if not getattr(self, "_app", None):
print(" Run `hermes portal` and enable terminal billing, then retry.")
print(" Run `hermes portal` and allow Remote Spending, then retry.")
return
confirm_choices = [
("yes", "Enable terminal billing", "open your browser to authorize"),
("yes", "Allow Remote Spending", "open your browser to authorize"),
("no", "Not now", "cancel"),
]
raw = self._prompt_text_input_modal(
title="Enable terminal billing",
title="Allow Remote Spending",
detail="Opens your browser to authorize this terminal.",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "yes":
print(" No charge made. Run /topup when you want to enable terminal billing.")
print(" No charge made. Run /topup when you want to allow Remote Spending.")
return
print(" Opening your browser to enable terminal billing…")
print(" Opening your browser to allow Remote Spending…")
try:
from hermes_cli.auth import step_up_nous_billing_scope
granted = step_up_nous_billing_scope(open_browser=True)
except Exception as exc:
print(f" Couldn't enable terminal billing: {exc}")
print(f" Couldn't allow Remote Spending: {exc}")
return
if not granted:
print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.")
print(" Couldn't allow Remote Spending — an org admin or owner has to approve it. Your card was not charged.")
return
# Granted. The token now carries the scope, but the ORG kill-switch
@ -1206,7 +1212,7 @@ class CLIBillingMixin:
fresh = build_billing_state()
if not (fresh.logged_in and fresh.cli_billing_enabled):
print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.")
print(" Remote Spending is allowed for this terminal, but it's still off for this org. A billing admin can turn it on from the portal's Hermes Agent page, then run /topup again.")
self._billing_portal_hint(fresh)
return
@ -1214,7 +1220,7 @@ class CLIBillingMixin:
# file. If there's none, this is a half-done state: say so and route to the
# portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done.
if fresh.card is None:
print("Terminal billing enabled — but there's no card on file yet.")
print("Remote Spending allowed — but there's no card on file yet.")
_cprint(f" {_d('Top up and manage billing on the portal to continue.')}")
self._billing_portal_hint(fresh)
return
@ -1222,12 +1228,12 @@ class CLIBillingMixin:
# Nothing to resume (scope-required hit outside a charge, e.g. auto-reload
# config) → just tell the user it's ready.
if amount is None:
print("Terminal billing enabled. Run /topup to continue.")
print("Remote Spending allowed. Run /topup to continue.")
return
# Press-Enter beat: the user is back from the browser; resume the held
# purchase on an explicit confirm (reassuring, not silent).
print("Terminal billing enabled.")
print("Remote Spending allowed.")
resume_choices = [
("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"),
("cancel", "Cancel", "do not charge"),

View file

@ -1,4 +1,4 @@
"""Nous Portal terminal-billing HTTP client (Phase 2b).
"""Nous Portal Remote Spending HTTP client (Phase 2b).
Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal
billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns
@ -90,8 +90,8 @@ class BillingScopeRequired(BillingError):
"""``403 insufficient_scope`` — the held token lacks ``billing:manage``.
The lazy step-up trigger: catching this kicks off a fresh device-connect that
requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow
terminal billing"). Also fires mid-session if the scope is stripped on refresh
requests ``billing:manage`` (and tells the user an ADMIN must select "Allow
Remote Spending"). Also fires mid-session if the scope is stripped on refresh
after the user loses ADMIN.
"""
@ -363,11 +363,11 @@ def _raise_for_error(
)
raise BillingAuthError(message or "Authentication required.", **common)
if status == 403:
# This terminal's spending was revoked (NOT the same as never having the
# scope). Disable spend UI immediately; recovery is reconnect.
# Remote spending was stopped for this terminal (NOT the same as never
# having the scope). Disable spend UI immediately; recovery is reconnect.
if error == "remote_spending_revoked":
raise BillingRemoteSpendingRevoked(
message or "Remote Spending was revoked for this terminal.", **common
message or "Remote spending was stopped for this terminal.", **common
)
if error == "insufficient_scope":
raise BillingScopeRequired(

View file

@ -1,4 +1,4 @@
"""Unit tests for the Phase 2b terminal-billing core + HTTP client.
"""Unit tests for the Phase 2b Remote Spending core + HTTP client.
Covers:
- Decimal money parsing/formatting (server emits decimal strings, not 2dp).

View file

@ -81,7 +81,11 @@ def test_billing_killswitch_off_blocks(cli, monkeypatch, capsys):
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "turned off for this org" in out
assert "Remote spending is off for this org." in out
assert (
"A billing admin can turn it on from the portal's Hermes Agent page "
"to add funds here."
) in out
def test_billing_limit_screen_readonly(cli, monkeypatch, capsys):

View file

@ -1,7 +1,7 @@
"""Tests for the /subscription CLI change flow (cli.py::_show_subscription).
Parity with the TUI overlay: the classic CLI now previews + applies a plan change
in-terminal (picker preview confirm apply), grants terminal billing inline on
in-terminal (picker preview confirm apply), allows remote spending inline on
insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner.
Interactive screens are driven by mocking `_prompt_text_input_modal`.
"""
@ -158,7 +158,7 @@ def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsy
def _put(**kw):
calls["n"] += 1
if calls["n"] == 1:
raise nb.BillingScopeRequired("terminal billing required")
raise nb.BillingScopeRequired("remote spending required")
return {"message": "Scheduled."}
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
@ -171,7 +171,7 @@ def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsy
# applied once (scope-denied), granted, replayed → applied again
assert calls["n"] == 2
assert "Terminal billing enabled" in out
assert "Remote Spending allowed" in out
def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys):
@ -183,7 +183,7 @@ def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys):
def _put(**kw):
calls["n"] += 1
raise nb.BillingScopeRequired("terminal billing required")
raise nb.BillingScopeRequired("remote spending required")
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
import hermes_cli.auth as auth
@ -194,7 +194,7 @@ def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys):
out = capsys.readouterr().out
assert calls["n"] == 1 # applied once, grant denied, no replay
assert "Couldn't enable terminal billing" in out
assert "Couldn't allow Remote Spending" in out
def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys):
@ -235,7 +235,10 @@ def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys)
out = capsys.readouterr().out
assert calls["n"] == 2 # applied, granted, replayed once — no third attempt
assert "still isn't enabled" in out
assert (
"Remote Spending still isn't active for this terminal — the authorization "
"didn't take. Retry, or make this change on the portal."
) in out
def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys):

View file

@ -8136,7 +8136,7 @@ def _(rid, params: dict) -> dict:
# ===========================================================================
# Phase 2b terminal billing RPC methods
# Phase 2b Remote Spending RPC methods
# ===========================================================================
#
# These return STRUCTURED success envelopes (result.ok / result.error) rather

View file

@ -96,7 +96,7 @@ npm run test:watch
- `types.ts``SlashCommand` interface and `SlashRunCtx` execution context (gateway rpc, transcript helpers, session refs, stale-guard)
- `registry.ts` — assembles `SLASH_COMMANDS` from all command files in registration order (core → billing → credits → session → ops → setup → debug) and exposes `findSlashCommand(name)` for case-insensitive lookup
- `commands/core.ts` — general TUI commands
- `commands/billing.ts``/billing`: manage Nous terminal billing — buy credits, auto-reload, limits
- `commands/billing.ts``/billing`: manage Nous remote spending — buy credits, auto-reload, limits
- `commands/credits.ts``/credits`
- `commands/session.ts` — session and agent commands
- `commands/ops.ts` — operations commands
@ -231,7 +231,7 @@ The following commands are handled directly by the TUI client. Unrecognized comm
`/status`, `/title`, `/fortune`, `/redraw`, `/terminal-setup`
### Billing (`billing.ts`)
`/billing` — manage Nous terminal billing — buy credits, auto-reload, limits
`/billing` — manage Nous remote spending — buy credits, auto-reload, limits
### Session (`session.ts`)
`/model`, `/sessions` (aliases `/switch`, `/session`, `/resume`),
@ -366,7 +366,7 @@ ui-tui/
types.ts SlashCommand interface and SlashRunCtx execution context
registry.ts SLASH_COMMANDS assembly and findSlashCommand lookup
commands/
billing.ts /billing — manage Nous terminal billing
billing.ts /billing — manage Nous remote spending
core.ts general TUI commands
credits.ts /credits
debug.ts /heapdump, /mem

View file

@ -205,7 +205,7 @@ const FIXTURES: Record<string, Fixture> = {
node: billEl(billState({ is_admin: false }))
},
'topup-disabled': {
desc: '/topup overview — terminal billing OFF for org',
desc: '/topup overview — remote spending OFF for org',
node: billEl(billState({ cli_billing_enabled: false }))
},
'topup-buy': {

View file

@ -93,11 +93,11 @@ const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState =>
state: billState()
})
describe('BillingOverlay — step-up screen (Enable terminal billing)', () => {
describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => {
it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => {
const out = render(overlay('stepup'))
expect(out).toContain('One-time setup')
expect(out).toContain('Enable terminal billing')
expect(out).toContain('Allow Remote Spending')
expect(out).toContain('$100') // resumes the held purchase
expect(out).toContain('Not now')
expect(out).not.toContain('billing:manage')
@ -112,8 +112,8 @@ describe('BillingOverlay — overview (reordered, dollars)', () => {
expect(out).toContain('Auto-reload')
expect(out).toContain('Manage on portal')
expect(out.toLowerCase()).not.toContain('credits') // dollars only
// No standalone "Enable terminal billing" item — discovered at pay time.
expect(out).not.toContain('Enable terminal billing')
// No standalone "Allow Remote Spending" item — discovered at pay time.
expect(out).not.toContain('Allow Remote Spending')
})
it('renders the two-bar dollar usage when a usage model is present', () => {

View file

@ -353,11 +353,11 @@ describe('SubscriptionOverlay — overview actions', () => {
})
describe('SubscriptionOverlay — step-up', () => {
it('prompts to enable terminal billing (never leaks the raw scope)', () => {
it('prompts to allow Remote Spending (never leaks the raw scope)', () => {
const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } }))
expect(out).toContain('Terminal billing')
expect(out).toContain('Enable terminal billing')
expect(out).toContain('Remote Spending')
expect(out).toContain('Allow Remote Spending')
expect(out).not.toContain('billing:manage')
})
})

View file

@ -311,8 +311,8 @@ describe('/billing slash command (overlay-driven)', () => {
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
it.each([
['admin', 'An admin turned off terminal billing for this terminal'],
['self', 'You turned off terminal billing for this terminal']
['admin', 'An admin stopped remote spending for this terminal'],
['self', 'You stopped remote spending for this terminal']
])(
'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy',
async (actor, copy) => {
@ -387,7 +387,7 @@ describe('/billing slash command (overlay-driven)', () => {
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Terminal billing is off for this account')
expect(out).toContain('Remote spending is off for this account')
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
expect(getOverlayState().billing).toBeTruthy()
})

View file

@ -551,7 +551,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
sys('💳 Open this link to grant terminal billing access:')
sys('💳 Open this link to allow Remote Spending:')
sys(url)
if (code) {

View file

@ -127,7 +127,7 @@ export interface BillingOverlayCtx {
*/
charge: (amount: string, idempotencyKey?: string) => Promise<BillingChargeOutcome>
/**
* Run the `billing.step_up` device flow (grant Remote Spending). Resolves
* Run the `billing.step_up` device flow (allow Remote Spending). Resolves
* `true` when the grant lands. The browser opens via the gateway's
* out-of-band `billing.step_up.verification` event the overlay just awaits.
*/
@ -176,15 +176,15 @@ export interface BillingOverlayState {
// scheduled at date / no-op / blocked) + the apply action.
// result — the outcome, including an SCA/decline upgrade handed off to the
// portal.
// stepup — reached when a mutation returns insufficient_scope: grants the
// terminal-billing scope in place, then auto-replays the held action.
// stepup — reached when a mutation returns insufficient_scope: allows remote
// spending in place, then auto-replays the held action.
export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup'
// The action held while the stepup screen grants terminal billing, replayed on
// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume.
// The action held while the stepup screen allows remote spending, replayed after
// approval: re-preview a tier, re-apply the confirmed pending change, or re-resume.
export type SubscriptionStepUpRetry = { kind: 'apply' } | { kind: 'preview'; tierId: string } | { kind: 'resume' }
/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */
/** Outcome of a remote-spending step-up: granted, plus the typed denial (for copy). */
export interface StepUpResult {
granted: boolean
error?: string
@ -215,7 +215,7 @@ export interface SubscriptionOverlayCtx {
/** POST /upgrade: charge the card on the subscription + flip the plan now. */
upgrade: (tierId: string, idempotencyKey?: string) => Promise<SubscriptionUpgradeResponse | null>
/**
* Run the `billing.step_up` device flow (grant terminal billing / "Remote
* Run the `billing.step_up` device flow (allow remote spending / "Remote
* Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so
* the stepup screen shows the right recovery. The browser opens via the
* gateway's out-of-band verification event the stepup screen just awaits.

View file

@ -37,9 +37,9 @@ const renderBillingError = (
switch (env.error) {
case 'insufficient_scope':
// Reached by non-charge mutations (e.g. auto-reload config) that need
// terminal billing enabled. The resumable step-up lives on the buy/charge
// Remote Spending allowed. The resumable step-up lives on the buy/charge
// path; point the user there rather than leaking the raw scope name.
sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
sys('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')
break
case 'remote_spending_revoked': {
@ -49,8 +49,8 @@ const renderBillingError = (
const who =
env.actor === 'admin'
? 'An admin turned off terminal billing for this terminal.'
: 'You turned off terminal billing for this terminal.'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`)
@ -67,9 +67,11 @@ const renderBillingError = (
case 'cli_billing_disabled':
case 'remote_spending_disabled':
// Account-wide switch is OFF (dual-emitted error/code). An admin must flip
// it on the portal; this is NOT a per-terminal revoke.
sys('Terminal billing is off for this account — an admin must enable it on the portal.')
// Account-wide switch is OFF (dual-emitted error/code). A billing admin can
// turn it on from the portal's Hermes Agent page; this is NOT a per-terminal stop.
sys(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
break

View file

@ -83,7 +83,7 @@ interface ScreenProps {
function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
// Full charge menu only for an admin with the org kill-switch on; otherwise it
// collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the
// ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope —
// ORG-level gate (cli_billing_enabled), NOT the per-terminal remote spending scope —
// that's discovered reactively at pay time (a charge 403s insufficient_scope
// and the confirm screen routes into the resumable step-up). We deliberately
// do NOT preflight the scope here.
@ -92,7 +92,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const note = !s.is_admin
? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).'
: !s.cli_billing_enabled
? 'Terminal billing is off for this org — manage it on the portal.'
? "Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page."
: null
// Always show the full billing menu for an admin/billing-on org — a missing
@ -495,14 +495,14 @@ function ConfirmScreen({
)
}
// ── Screen: Step-up (resumable "Enable terminal billing") ────────────
// ── Screen: Step-up (resumable "Allow Remote Spending") ─────────────
// Reached ONLY when a charge returns insufficient_scope — there is no preflight
// or scope check anywhere; the buy path discovers it reactively. The modal stays
// MOUNTED through the browser device-flow:
// prompt (heads-up) → waiting (browser authorize) → granted (press Enter to
// resume) → replay the held charge (pendingCharge.amount) → settle → close.
// Never leaks the raw billing:manage scope — the user-facing concept is
// "terminal billing".
// "Remote Spending".
function StepUpScreen({
amount,
@ -526,12 +526,12 @@ function StepUpScreen({
}
setPhase('waiting')
ctx.sys('Opening your browser to enable terminal billing…')
ctx.sys('Opening your browser to allow Remote Spending…')
void ctx.requestRemoteSpending().then(granted => {
if (!granted) {
ctx.sys(
"! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged."
"! Couldn't allow Remote Spending — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged."
)
onClose()
@ -550,12 +550,12 @@ function StepUpScreen({
}
setPhase('resuming')
ctx.sys('✓ Terminal billing enabled — resuming your purchase.')
ctx.sys('✓ Remote Spending allowed — resuming your purchase.')
void ctx.charge(amount, idempotencyKey).then(outcome => {
// If the replay STILL can't spend (grant raced/expired or downscoped),
// say so — don't close on a reassuring line with no charge made.
if (outcome === 'needs_remote_spending') {
ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.')
ctx.sys('! Remote Spending still needs approval — run /topup to try again. Your card was not charged.')
}
onClose()
@ -563,7 +563,7 @@ function StepUpScreen({
}
const decline = () => {
ctx.sys('No charge made. Run /topup when you want to enable terminal billing.')
ctx.sys('No charge made. Run /topup when you want to allow Remote Spending.')
onClose()
}
@ -622,7 +622,7 @@ function StepUpScreen({
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Enable terminal billing
Allow Remote Spending
</Text>
<Text color={t.color.warn}>Waiting for your browser</Text>
<Text color={t.color.muted}>Approve in the page that just opened.</Text>
@ -637,7 +637,7 @@ function StepUpScreen({
return (
<Box flexDirection="column">
<Text bold color={t.color.ok}>
Terminal billing enabled
Remote Spending allowed
</Text>
<Text color={t.color.text}>Your ${amount} top-up is ready to finish.</Text>
<Text />
@ -652,7 +652,7 @@ function StepUpScreen({
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Enable terminal billing
Allow Remote Spending
</Text>
<Text color={t.color.muted}>Resuming your ${amount} top-up</Text>
<Text />
@ -667,12 +667,12 @@ function StepUpScreen({
<Text bold color={t.color.warn}>
One-time setup
</Text>
<Text color={t.color.text}>To charge this terminal, enable terminal billing once.</Text>
<Text color={t.color.text}>To charge from this terminal, allow Remote Spending once.</Text>
<Text color={t.color.muted}>
It opens your browser to authorize, then your ${amount} top-up picks up right here.
</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label="Enable terminal billing" t={t} />
<ActionRow active={sel === 0} color={t.color.ok} label="Allow Remote Spending" t={t} />
<ActionRow active={sel === 1} label="Not now" t={t} />
<Text />
{footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)}

View file

@ -29,7 +29,7 @@ interface SubscriptionOverlayProps {
/**
* The /subscription modal an in-terminal plan-change flow (V3). A small state
* machine: overview picker confirm result, with a stepup screen spliced in
* when a mutation needs terminal billing. Downgrades / cancellations / resume are
* when a mutation needs remote spending. Downgrades / cancellations / resume are
* chargeless; an upgrade charges the card on the subscription, and an SCA/decline
* is handed off to the portal. Starting a NEW subscription still deep-links (needs
* a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`.
@ -162,7 +162,7 @@ function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: nu
return errorResult(r)
}
/** Map a failed terminal-billing step-up to the right recovery copy (typed). */
/** Map a failed remote-spending step-up to the right recovery copy (typed). */
function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult {
if (res.error === 'session_revoked') {
return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false }
@ -170,8 +170,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip
if (res.error === 'remote_spending_revoked') {
return {
message:
res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.',
message: res.message || 'Remote spending was stopped for this terminal — reconnect from the portal, then retry.',
ok: false
}
}
@ -183,7 +182,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip
return {
message:
res.message ||
'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.',
'Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.',
ok: false
}
}
@ -196,7 +195,8 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip
// Post-grant replays pass allowStepUp=false and surface this instead (mirrors the
// CLI's allow_stepup=False cap).
const scopeStillDeniedResult: SubscriptionResult = {
message: 'Terminal billing still isnt enabled for this org — enable it on the portal, then retry.',
message:
'Remote Spending still isnt active for this terminal — the authorization didnt take. Retry, or make this change on the portal.',
ok: false
}
@ -818,7 +818,7 @@ function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
)
}
// ── Screen: Step-up (grant terminal billing inline, then replay) ──────
// ── Screen: Step-up (allow remote spending inline, then replay) ───────
function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
const { ctx } = overlay
@ -908,7 +908,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
]
: phase === 'prompt'
? [
{ color: t.color.ok, label: 'Enable terminal billing', run: enable },
{ color: t.color.ok, label: 'Allow Remote Spending', run: enable },
{ label: 'Cancel', run: back }
]
: []
@ -918,12 +918,12 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Terminal billing
Remote Spending
</Text>
{phase === 'prompt' && (
<>
<Text color={t.color.text}>
Changing your plan needs terminal billing enabled for this org. Enable it here, then continue.
Changing your plan needs Remote Spending allowed for this terminal. Allow it here, then continue.
</Text>
<Text color={t.color.muted}>
Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser.
@ -935,7 +935,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
Opening your browser to approve finish there, then come back nothing is charged until you continue.
</Text>
)}
{phase === 'granted' && <Text color={t.color.ok}>Terminal billing enabled. Continue to finish your change.</Text>}
{phase === 'granted' && <Text color={t.color.ok}>Remote Spending allowed. Continue to finish your change.</Text>}
{phase === 'resuming' && <Text color={t.color.muted}>Applying your change</Text>}
<Text />
{rows.map((row, i) => (

View file

@ -45,7 +45,7 @@ export interface SlashExecResponse {
warning?: string
}
// ── Terminal billing (Phase 2b) ──────────────────────────────────────
// ── Remote Spending (Phase 2b) ──────────────────────────────────────
// Wire shapes now live in @hermes/shared for reuse by TypeScript clients.
export type {

View file

@ -113,7 +113,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/version` | Show Hermes Agent version, build, and environment info. |
| `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. |
| `/credits` | Show your Nous credit balance and a top-up handoff link. |
| `/billing` | CLI terminal-billing flow for Nous — view balance, buy credits, and manage auto-reload / monthly limits. |
| `/billing` | CLI Remote Spending flow for Nous — view balance, buy credits, and manage auto-reload / monthly limits. |
| `/insights` | Show usage insights and analytics (last 30 days) |
| `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status (CLI-only summary view). |
| `/paste` | Attach a clipboard image |