mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
7a8852ddcb
commit
b0da653ac8
28 changed files with 159 additions and 142 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
isn’t active for this terminal — the authorization didn’t take. Retry, or make
|
||||
this change on the portal.`
|
||||
|
||||
## Text-mode (CLI) parity
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 isn’t enabled for this org — enable it on the portal, then retry.',
|
||||
message:
|
||||
'Remote Spending still isn’t active for this terminal — the authorization didn’t 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) => (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue