feat(connectors): no guest on page open, connector sign-in offer (#143248)

* feat(connectors): list hosted apps without creating a guest identity

Opening the desktop Connectors page used to create a Nous guest identity:
connectors.list, connectors.catalog, connectors.accounts and
connectors.policy.get each created one before answering, because the
Portal's catalog route needs a token. A user who only looked at the page
got an identity anyway.

While there is no identity yet (and one may still be created), those four
reads now answer without one: the catalog from a snapshot that ships with
Hermes, an empty connector list, no accounts and no rules. Connecting an
app still creates the identity, as before.

scripts/refresh_connector_catalog.py rewrites the snapshot from a
signed-in install. It leaves logos out: no client draws them.

* feat(free-tier): sign-in offer for guests who connected apps

Every free-tier sign-in offer keyed on the welcome trial, so a user on
their own model key who connected apps through a guest identity never saw
one.

The offer record now tracks two offers on the same schedule. `trial` is
unchanged. `connector` is for a guest without the trial that has connected
an app: each app that finishes connecting sets `has_connected_app` on the
guest state (so it dies with the guest) and counts as a finished task.
free_tier.status (still a pure local read) adds connector_nudge_due_in, and
free_tier.claim_nudge takes a `kind`.

The desktop reads the connector offer whether or not the trial is
available, re-reads the status after a turn or a page connect, and shows
connector copy instead of the free-allowance copy. After a page connect
settles it also re-reads the catalog and rules, so the bundled catalog
from before the identity existed is replaced by the account's own.

* fix(connectors): point the sign-in hint at `hermes auth upgrade`

`hermes connectors` told users to run `hermes login`, which no longer
exists. Both hints now say "Run `hermes auth upgrade` to sign in."

* feat(connectors): read the public catalog with no token; one guest rule per entry point

The bundled catalog snapshot listed apps a new guest cannot connect and
went stale whenever the catalog changed. With no token, the Connectors
page now reads the Nous Portal's public catalog route
(GET /api/v1/connectors/catalog/public), sent with no Authorization and
parsed with the existing catalog model. Once a token exists the page reads
the signed-in route, as before.

Whether an entry point may create the guest identity is now declared once
per entry point instead of checked inside handlers:
- managed_client(creates_guest=...) is required: connect (page, agent,
  card retry) and the explicit lists (CLI `hermes connectors list`, agent
  manage_connections status/display) pass True, unchanged.
- account RPCs declare creates_guest on _account_method: catalog,
  accounts and policy.get never create one; tools and the account writes
  still do.
- the page's connectors.list never creates one; connect does.

With no token the page reads give true answers: public catalog, empty
connected list, no accounts. The invented "unrestricted" policy is gone:
the desktop asks for rules only once the list has rows, which needs a
token, and policy.get without one refuses with sign-in-required.

Deleted: the bundled snapshot, its refresh script, and the no-identity
stubs.

* fix(connectors): CI and review round: older backends, outside connects, link connects, guest opt-out

- CI: test_legacy_fallback_ticket_profile_verbs faked managed_client with
  no arguments and gave the secondary profile no token, so the page list
  (now token-gated, creates_guest=False) never reached it. The fake takes
  the new keyword and the profile holds a token.
- Older backends: a trial claim sends free_tier.claim_nudge with no
  params again. An older backend rejects the unknown `kind` key and would
  have dropped the trial offer; the new backend defaults to the trial.
  Only the connector claim sends `kind`.
- Outside connects: while the page's list is empty (no identity), every
  finished turn or connect re-reads the list and catalog. An agent connect
  in a chat then moves the page to the signed-in routes and its rules.
- Link connects: an agent status read that sees a connected app on a
  guest without the flag arms the connector offer, so messaging and
  one-shot connects (no operation watcher) count too.
- Opt-out: the connector offer requires nous.guest to be allowed.

* fix(connectors): the Connectors page list arms the connector offer too

A guest whose apps were connected by an older Hermes has no has_connected_app flag, and only the operation watcher or the agent's status read set it. The page's connectors.list now passes its rows to note_connected_apps_seen, which owns the any-connected check for both list reads.

* fix(connectors): review round 3: accounts after an outside connect, retryable offer record

- An identity created outside the page (an agent connect in a chat) now also re-reads the accounts cached while there was none, so the new account row, rename and remove show up.
- note_app_connected records the connector task before it sets has_connected_app. The flag ends list-read sightings, so a failed schedule write no longer leaves the offer unarmed for good.
This commit is contained in:
Siddharth Balyan authored and GitHub committed 2026-10-23 06:08:27 +05:30
1 parent 20428afaf3
commit 7b0f11fc7b
31 files changed
+642 -99

No files matched your search

@@ -8,6 +8,7 @@ import { atom } from 'nanostores'
import type { ProfileScope } from '@/hermes'
import { type ConnectionTarget, parseConnectionTarget } from '@/store/connection-request'
import { noteFreeTierAppConnected } from '@/store/free-tier-sign-in'
import { activeGatewayConnectionId } from '@/store/gateway'
import { normalizeProfileKey } from '@/store/profile'
@@ -212,5 +213,11 @@ function refetchOnSettle(operation: AccountOperation): void {
return
}
invalidateConnectors(operation.scope, 'list', 'accounts')
// The first connect can create the identity the page opened without, so its bundled catalog and
// empty rules are re-read too.
invalidateConnectors(operation.scope, 'list', 'accounts', 'catalog', 'policy')
if (operation.targets.some(target => target.state === 'connected')) {
noteFreeTierAppConnected()
}
}
@@ -0,0 +1,82 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { queryClient } from '@/lib/query-client'
import { noteFreeTierTurnComplete } from '@/store/free-tier-sign-in'
import { setPrimaryGateway, setPrimaryGatewayConnectionId } from '@/store/gateway'
import { useHostedConnectors } from './queries'
const row = { connected: false, connector: 'notion', enabled: true, gateway_disabled_tools: [] }
function gateway(listed: object[]) {
const request = vi.fn(
async (method: string) =>
({
'connectors.accounts': { accounts: [] },
'connectors.catalog': { connectors: [] },
'connectors.list': { available: true, connectors: [...listed] },
'connectors.policy.get': { effective: { mode: 'unrestricted' }, layers: [] }
})[method]
)
// SAFETY: the page's queries call only `request` on the primary socket.
setPrimaryGateway({ request } as never)
setPrimaryGatewayConnectionId('connection-1')
return request
}
// The page invalidates through the app's one query client, so the hook runs on it too.
function mount() {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return renderHook(() => useHostedConnectors('default'), { wrapper })
}
const asked = (request: ReturnType<typeof gateway>, method: string) =>
request.mock.calls.some(([name]) => name === method)
afterEach(() => {
cleanup()
queryClient.clear()
})
describe('connector rules on the Connectors page', () => {
it('are not asked for while the list is empty (no identity yet)', async () => {
const request = gateway([])
const { result } = mount()
await waitFor(() => expect(result.current.phase).toBe('ready'))
await waitFor(() => expect(asked(request, 'connectors.catalog')).toBe(true))
expect(asked(request, 'connectors.policy.get')).toBe(false)
expect(result.current.rulesFailed).toBe(false)
})
it('are asked for once the gateway lists apps', async () => {
const request = gateway([row])
mount()
await waitFor(() => expect(asked(request, 'connectors.policy.get')).toBe(true))
})
it('are asked for once an identity appears outside the page (an agent connect in a chat)', async () => {
const listed: object[] = []
const request = gateway(listed)
const { result } = mount()
await waitFor(() => expect(result.current.phase).toBe('ready'))
listed.push(row)
noteFreeTierTurnComplete()
await waitFor(() => expect(asked(request, 'connectors.policy.get')).toBe(true))
// The accounts read cached while there was no identity is re-read too, or the new account stays hidden.
expect(request.mock.calls.filter(([name]) => name === 'connectors.accounts')).toHaveLength(2)
})
})
@@ -7,6 +7,7 @@ import type { ProfileScope } from '@/hermes'
import { translateNow } from '@/i18n'
import { isMissingRpcMethod, isOutOfSyncRpcParams } from '@/lib/gateway-rpc'
import { queryClient } from '@/lib/query-client'
import { $freeTierTurnCompleted } from '@/store/free-tier-sign-in'
import { notifyError } from '@/store/notifications'
import { hostedPhase } from '../derive'
@@ -102,8 +103,23 @@ export function useHostedConnectors(scope: ProfileScope): HostedConnectorsView {
queryKey: connectorsAccountsQueryKey(scope)
})
// Rules exist only for an identity. The page list is empty until one does (no token, no gateway
// read), so rules are asked for once it has rows.
const listed = (list.data?.connectors.length ?? 0) > 0
// The identity can appear outside this page (an agent connect in a chat). Until the list has rows,
// every finished turn or connect re-reads the list, catalog and accounts, which then move to the signed-in routes.
useEffect(
() =>
listed
? undefined
: $freeTierTurnCompleted.listen(() => invalidateConnectors(scope, 'list', 'catalog', 'accounts')),
[listed, scope]
)
const policy = useQuery({
...read('policy'),
enabled: listed,
queryFn: () => connectorPolicy(scope),
queryKey: connectorsPolicyQueryKey(scope)
})
@@ -30,7 +30,7 @@ async function walk(...states: FreeTierSignInState[]): Promise<string[]> {
}
const CLOSED: FreeTierSignInState = { status: 'closed' }
const OFFER: FreeTierSignInState = { status: 'offer' }
const OFFER: FreeTierSignInState = { kind: 'trial', status: 'offer' }
const SETTING_UP: FreeTierSignInState = { minting: false, status: 'setting_up' }
beforeEach(() => {
@@ -185,14 +185,14 @@ describe('FreeTierSignInDialog', () => {
$freeTierStatus.set({ ...dueNow, nudge_due_in: 0 })
})
expect(requestGateway).not.toHaveBeenCalledWith('free_tier.claim_nudge')
expect(requestGateway).not.toHaveBeenCalledWith('free_tier.claim_nudge', undefined)
await act(async () => {
setOnboardingSurfaceActive('onboarding', false)
})
await waitFor(() => expect(screen.getByText('Keep going with Hermes')).toBeTruthy())
expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge')
expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge', undefined)
})
it('a finished free-tier turn re-reads the status, and a due offer opens with no user action', async () => {
@@ -212,7 +212,7 @@ describe('FreeTierSignInDialog', () => {
it('Sign in continues into the normal sign-in flow', async () => {
await renderDialog()
await act(async () => $freeTierSignIn.set({ status: 'offer' }))
await act(async () => $freeTierSignIn.set({ kind: 'trial', status: 'offer' }))
await act(async () => screen.getByRole('button', { name: 'Sign in' }).click())
@@ -221,12 +221,27 @@ describe('FreeTierSignInDialog', () => {
it('Not now closes the offer', async () => {
await renderDialog()
await act(async () => $freeTierSignIn.set({ status: 'offer' }))
await act(async () => $freeTierSignIn.set({ kind: 'trial', status: 'offer' }))
await act(async () => screen.getByRole('button', { name: 'Not now' }).click())
expect($freeTierSignIn.get()).toEqual({ status: 'closed' })
expect(screen.queryByText('Keep going with Hermes')).toBeNull()
})
it('an app connected without the trial: claims the connector offer and shows its copy', async () => {
await renderDialog()
const ownKey = { ...dueNow, available: false, connector_nudge_due_in: null }
await act(async () => $freeTierStatus.set(ownKey))
requestGateway.mockImplementation(async (method?: string) =>
method === 'free_tier.claim_nudge' ? { claimed: true } : { ...ownKey, connector_nudge_due_in: 0 }
)
await act(async () => noteFreeTierTurnComplete())
await waitFor(() => expect(screen.getByText('Keep your connected apps')).toBeTruthy())
expect(requestGateway).toHaveBeenCalledWith('free_tier.claim_nudge', { kind: 'connector' })
expect(screen.queryByText('Keep going with Hermes')).toBeNull()
})
})
})
@@ -145,7 +145,10 @@ export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProp
<Dialog onOpenChange={open => !open && closeFreeTierSignIn()} open>
<DialogContent onOpenAutoFocus={preventCloseButtonAutoFocus}>
{state.status === 'offer' && (
<Screen body={copy.offer.body} heading={copy.offer.heading}>
<Screen
body={state.kind === 'connector' ? t.connectorOffer.body : copy.offer.body}
heading={state.kind === 'connector' ? t.connectorOffer.heading : copy.offer.heading}
>
<Actions>
<Button onClick={() => closeFreeTierSignIn()} size="sm" type="button" variant="text">
{copy.offer.notNow}
+8 -1
View File
@@ -35,5 +35,12 @@ export const enNotices = {
message:
'Hermes can keep your computer awake while a task runs. The screen still turns off, and on battery your computer may still sleep.',
action: 'Keep awake while working'
},
connectorOffer: {
heading: 'Keep your connected apps',
body: 'Sign in with a free Nous account to keep the apps you connected, like Gmail or Slack, on your account.'
}
} satisfies Pick<Translations, 'remoteDisplayBanner' | 'butterbar' | 'promptNotices' | 'keepAwakeOffer'>
} satisfies Pick<
Translations,
'remoteDisplayBanner' | 'butterbar' | 'promptNotices' | 'keepAwakeOffer' | 'connectorOffer'
>
+2
View File
@@ -382,6 +382,8 @@
"composer.wakeWordListening": ["ar"],
"composer.wakeWordOff": ["ar"],
"composer.wakeWordPausedVoice": ["ar"],
"connectorOffer.body": ["ar","de","es","fr","ja","ru","zh","zh-hant"],
"connectorOffer.heading": ["ar","de","es","fr","ja","ru","zh","zh-hant"],
"connectors.authorizedToolsUnavailable": ["ar","ja","ru","zh-hant"],
"connectors.cancel": ["ar","ja","ru","zh-hant"],
"connectors.checking": ["ar","ja","ru","zh","zh-hant"],
+2
View File
@@ -32,6 +32,8 @@ export interface NoticeTranslations {
/** Once per computer, after a timed sleep interrupted a turn with keep-awake off. */
keepAwakeOffer: { title: string; message: string; action: string }
/** The sign-in offer after apps connect on the free tier without the welcome trial. */
connectorOffer: { heading: string; body: string }
}
export interface SessionCommandNotices {
+18 -2
View File
@@ -90,7 +90,7 @@ describe('sign-in offer after a finished task', () => {
await vi.advanceTimersByTimeAsync(0)
expect(claims(requestGateway)).toBe(1)
expect($freeTierSignIn.get()).toEqual({ status: 'offer' })
expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
})
it('due later: waits out nudge_due_in, re-reads the status, then claims', async () => {
@@ -107,7 +107,23 @@ describe('sign-in offer after a finished task', () => {
expect(requestGateway).toHaveBeenCalledWith('free_tier.status')
expect(claims(requestGateway)).toBe(1)
expect($freeTierSignIn.get()).toEqual({ status: 'offer' })
expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
})
it('an older backend that rejects unknown params still serves the trial offer', async () => {
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (params !== undefined) {
throw new Error('unknown param')
}
return method === 'free_tier.claim_nudge' ? { claimed: true } : status(null)
// SAFETY: the code under test asks only these two methods and reads exactly these shapes back.
}) as ReturnType<typeof vi.fn> & FreeTierRequester
syncFreeTierOffer(status(0), requestGateway)
await vi.advanceTimersByTimeAsync(0)
expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
})
it('a claim another window won leaves the dialog closed', async () => {
@@ -218,9 +218,9 @@ describe('free-tier sign-in long-polls', () => {
showFreeTierSignInResult({ handoff_status: null, session_id: 's', status: 'approved' })
expect($freeTierSignIn.get()).toEqual({ status: 'closed' })
$freeTierSignIn.set({ status: 'offer' })
$freeTierSignIn.set({ kind: 'trial', status: 'offer' })
showFreeTierSignInResult({ handoff_status: claimed, session_id: 's', status: 'approved' })
expect($freeTierSignIn.get()).toEqual({ status: 'offer' })
expect($freeTierSignIn.get()).toEqual({ kind: 'trial', status: 'offer' })
$freeTierSignIn.set({ status: 'closed' })
showFreeTierSignInResult({
+46 -22
View File
@@ -14,7 +14,7 @@ import { LONG_POLL_MIN_GAP_MS, pause, restOfLongPollGap } from '@/lib/long-poll'
import { gatewayActivationEpoch } from '@/store/gateway'
import type { AppHandoffStatus, FreeTierStatus, OAuthPollResponse } from '@/types/hermes'
import { $freeTierStatus, type FreeTierRequester, NOUS_PROVIDER_ID, refreshFreeTierStatus } from './free-tier'
import { type FreeTierRequester, NOUS_PROVIDER_ID, refreshFreeTierStatus } from './free-tier'
import { $localModelOfferPending } from './local-model-offer'
import { afterOnboardingSurfaceClear, onboardingSurfaceActive } from './onboarding-presence'
@@ -27,6 +27,8 @@ import { afterOnboardingSurfaceClear, onboardingSurfaceActive } from './onboardi
* not name lands on `error`, which carries its own message. */
export type FreeTierSignInFailure = 'busy' | 'error' | 'timed_out' | 'unavailable' | 'unreachable'
export type FreeTierOfferKind = 'connector' | 'trial'
export type FreeTierSignInState =
| { status: 'already_signed_in' }
| { status: 'closed' }
@@ -34,10 +36,10 @@ export type FreeTierSignInState =
// A "please open the dialog" request from an entry point that has no gateway
// requester of its own. The mounted host picks it up and drives the flow.
| { status: 'requested' }
// The "keep going" offer after a finished task (backend-timed, repeating with
// back-off). Unlike `requested` it waits for the user's Sign in before
// starting the transfer.
| { status: 'offer' }
// A sign-in offer the backend timed (repeating with back-off): `trial` after a
// finished free-tier task, `connector` after apps connect without the trial.
// Unlike `requested` it waits for the user's Sign in before starting the transfer.
| { kind: FreeTierOfferKind; status: 'offer' }
// `handoff`: the free tier's apps moving to this account (null when the
// sign-in replaced no free tier). Followed while it is still retrying.
// `scope`: the profile the sign-in ran for when it is not the active one,
@@ -138,27 +140,45 @@ function clearOfferTimer() {
}
}
// Bumped when a turn completes on the free tier. The backend records the finished task before it
// sends message.complete, so the dialog owner's re-read on this signal learns when the next offer is due.
// Bumped when a turn completes or an app connects. The backend records the finished task (or the
// connected app) before it reports either, so the dialog owner's re-read on this signal learns when
// the next offer is due. Not gated on the cached status: a turn's own connect can create the identity
// the cache predates, and the re-read is local and zero-network.
export const $freeTierTurnCompleted = atom(0)
export function noteFreeTierTurnComplete() {
if ($freeTierStatus.get()?.available) {
$freeTierTurnCompleted.set($freeTierTurnCompleted.get() + 1)
}
$freeTierTurnCompleted.set($freeTierTurnCompleted.get() + 1)
}
/** An app connected from the Connectors page, outside any turn. */
export const noteFreeTierAppConnected = noteFreeTierTurnComplete
/** Stop a pending offer timer (the dialog owner unmounted). */
export function stopFreeTierOffer() {
clearOfferTimer()
}
// The trial offer rides on the trial being live; the connector offer is for users without it, so
// the backend owes at most one of the two.
function dueOffer(status: FreeTierStatus | null): { dueIn: number; kind: FreeTierOfferKind } | null {
const trial = status?.available ? (status.nudge_due_in ?? null) : null
if (trial !== null) {
return { dueIn: trial, kind: 'trial' }
}
const connector = status?.connector_nudge_due_in ?? null
return connector === null ? null : { dueIn: connector, kind: 'connector' }
}
/**
* Act on the backend's `nudge_due_in` (it alone decides when the next
* offer is due). Later: one timer that re-reads the status, whose fresh answer
* comes back through here. Due now: claim it, and only the one caller the
* backend says `claimed` opens the offer. Called on every status read and when
* guided onboarding leaves the screen; a guarded call simply waits for the next.
* Act on the backend's `nudge_due_in` / `connector_nudge_due_in` (it alone
* decides when the next offer is due). Later: one timer that re-reads the
* status, whose fresh answer comes back through here. Due now: claim it, and
* only the one caller the backend says `claimed` opens the offer. Called on
* every status read and when guided onboarding leaves the screen; a guarded
* call simply waits for the next.
*/
export function syncFreeTierOffer(status: FreeTierStatus | null, requestGateway: FreeTierRequester) {
// A re-sync on the same read (an onboarding change) keeps its running timer, so it is not pushed back.
@@ -167,12 +187,14 @@ export function syncFreeTierOffer(status: FreeTierStatus | null, requestGateway:
}
clearOfferTimer()
const dueIn = status?.available ? (status.nudge_due_in ?? null) : null
const due = dueOffer(status)
if (dueIn === null) {
if (due === null) {
return
}
const { dueIn, kind } = due
if (dueIn > 0) {
const isCurrent = sameGatewayRoute()
offerTimerFor = status
@@ -188,7 +210,7 @@ export function syncFreeTierOffer(status: FreeTierStatus | null, requestGateway:
return
}
void claimFreeTierOffer(requestGateway)
void claimFreeTierOffer(requestGateway, kind)
}
// The local-model offer after onboarding goes first; the sign-in offer is claimed once it is answered.
@@ -198,7 +220,7 @@ const offerBlocked = () =>
$localModelOfferPending.get() ||
$freeTierSignIn.get().status !== 'closed'
async function claimFreeTierOffer(requestGateway: FreeTierRequester) {
async function claimFreeTierOffer(requestGateway: FreeTierRequester, kind: FreeTierOfferKind) {
if (offerBlocked()) {
return
}
@@ -208,7 +230,9 @@ async function claimFreeTierOffer(requestGateway: FreeTierRequester) {
let claimed: boolean
try {
claimed = (await requestGateway<FreeTierClaimNudgeResult>('free_tier.claim_nudge'))?.claimed === true
// A trial claim sends no `kind`: an older backend rejects the unknown key, and defaults to the trial.
const params = kind === 'trial' ? undefined : { kind }
claimed = (await requestGateway<FreeTierClaimNudgeResult>('free_tier.claim_nudge', params))?.claimed === true
} catch {
// Not claimed; the next status read tries again. No re-read here, or a failing call would loop.
return
@@ -221,10 +245,10 @@ async function claimFreeTierOffer(requestGateway: FreeTierRequester) {
}
if (claimed && $freeTierSignIn.get().status === 'closed') {
set({ status: 'offer' })
set({ kind, status: 'offer' })
}
// The claim settled the offer either way; re-read so the cached `nudge_due_in` drops.
// The claim settled the offer either way; re-read so the cached due time drops.
void refreshFreeTierStatus(requestGateway, isCurrent)
}
+2
View File
@@ -201,6 +201,8 @@ export interface FreeTierStatus {
challenge?: FreeTierChallengePayload | null
/** Seconds until the one-time first-task sign-in offer is due (0 = now); null when none is owed. */
nudge_due_in?: number | null
/** The same for the connector offer: no trial, and apps connected on the free tier. */
connector_nudge_due_in?: number | null
}
export interface MemoryProviderOAuthStatus {
@@ -678,6 +678,7 @@ export interface FreeTierStatusResult {
retry_after?: number | null
challenge?: FreeTierChallengePayload | null
nudge_due_in?: number | null
connector_nudge_due_in?: number | null
}
/** ``hermes_cli/anon_challenge.py::BrowserChallenge.as_payload``: the ``free_tier.challenge`` event, and ``free_tier.status``'s ``challenge`` field for a client that connected after it. */
export interface FreeTierChallengePayload {
@@ -709,6 +710,10 @@ export interface FreeTierProvisionResult {
export interface FreeTierAckNoticeResult {
acked: boolean
}
export interface FreeTierClaimNudgeParams {
profile?: string | null
kind?: 'trial' | 'connector'
}
export interface FreeTierClaimNudgeResult {
claimed: boolean
}
@@ -6169,7 +6174,7 @@ export interface RpcMethods {
/** Report a browser window outcome for the matching pending attempt; mint remains authoritative. */
'free_tier.challenge_result': { params: FreeTierChallengeResultParams; result: FreeTierChallengeResult }
/** Claim the due sign-in offer; true for exactly one caller each time an offer comes due. */
'free_tier.claim_nudge': { params: ProfileParams; result: FreeTierClaimNudgeResult }
'free_tier.claim_nudge': { params: FreeTierClaimNudgeParams; result: FreeTierClaimNudgeResult }
/** Explicit retry of the free-tier identity mint when the boot bootstrap could not create it. */
'free_tier.provision': { params: ProfileParams; result: FreeTierProvisionResult }
/** Pure read of the focused profile's free-tier identity state (no network, no side effects). */
+41 -1
View File
@@ -1075,7 +1075,7 @@
{
"name": "params",
"schema": {
"$ref": "#/components/schemas/ProfileParams"
"$ref": "#/components/schemas/FreeTierClaimNudgeParams"
}
}
],
@@ -18636,6 +18636,34 @@
"title": "FreeTierChallengeResultParams",
"type": "object"
},
"FreeTierClaimNudgeParams": {
"additionalProperties": false,
"properties": {
"profile": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Profile"
},
"kind": {
"default": "trial",
"enum": [
"trial",
"connector"
],
"title": "Kind",
"type": "string"
}
},
"title": "FreeTierClaimNudgeParams",
"type": "object"
},
"FreeTierClaimNudgeResult": {
"additionalProperties": false,
"properties": {
@@ -18815,6 +18843,18 @@
],
"default": null,
"title": "Nudge Due In"
},
"connector_nudge_due_in": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null,
"title": "Connector Nudge Due In"
}
},
"required": [
+3 -3
View File
@@ -9,7 +9,7 @@ from typing import Any, Callable, NoReturn
_POLL_SECONDS = 2.0
_DONE_STATES = frozenset({"connected", "skipped", "failed", "expired", "not_connected"})
_SIGN_IN = "Sign in with `hermes login` to manage connectors."
_SIGN_IN = "Run `hermes auth upgrade` to sign in."
class _CommandError(Exception):
@@ -60,7 +60,7 @@ def _match_named(app: str, rows: list[dict[str, Any]], name: str) -> dict[str, A
def _list(args) -> int:
from tools.connectors.managed import managed_client
rows = managed_client().list_connectors()
rows = managed_client(creates_guest=True).list_connectors()
if not rows:
print("No connectors are available.")
return 0
@@ -237,7 +237,7 @@ def cmd_connectors(args) -> int:
print(f"Unknown connectors action: {args.connectors_action}", file=sys.stderr)
return 2
if not connectors_available():
print("Connectors are not available. Sign in with `hermes login` first.", file=sys.stderr)
print(f"Connectors are not available. {_SIGN_IN}", file=sys.stderr)
return 1
try:
return handler(args)
+69 -25
View File
@@ -1,53 +1,72 @@
"""When the free tier's "sign in for more" offer is due: after finished tasks, backing off.
"""When a free-tier "sign in" offer is due: after finished tasks, backing off.
Two offers share one schedule. ``trial`` is the welcome trial's "sign in for more"; a finished task
is ``tui_gateway.free_tier_task_done``'s call. ``connector`` is for a guest that has connected an app
on its own model key; each app that connects counts as a finished task (``note_app_connected``).
The first offer comes ``OFFER_DELAY_S`` after the first finished task. Each later offer needs another
finished task after the previous offer, comes ``OFFER_DELAY_S`` after that task, and never sooner than
``REOFFER_AFTER_S[n]`` after the previous offer, so a user who keeps saying "Not now" hears it less
often. "Finished task" is ``tui_gateway.free_tier_task_done``'s call. The record is install-wide, at
``<default root>/free_tier/sign_in_offer.json``. Signing in ends the free tier, and with it every offer.
often. Each record is install-wide, under ``<default root>/free_tier/``. Signing in ends the guest,
and with it every offer.
"""
from __future__ import annotations
import json
import logging
import math
import threading
import time
from contextlib import contextmanager
from typing import Any, Dict, Iterator, Optional
from typing import Any, Iterator, Optional
logger = logging.getLogger(__name__)
OFFER_DELAY_S = 180
REOFFER_AFTER_S = (60 * 60, 4 * 60 * 60, 24 * 60 * 60) # after the 1st, 2nd, then every later offer
TRIAL = "trial"
CONNECTOR = "connector"
CONNECTED_APP_FLAG = "has_connected_app"
_RECORDS = {TRIAL: "sign_in_offer.json", CONNECTOR: "connector_offer.json"}
_LOCK = threading.local()
_clock = time.time # tests swap this for a fake clock
def _path():
def _path(kind: str):
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "free_tier" / "sign_in_offer.json"
return get_default_hermes_root() / "free_tier" / _RECORDS[kind]
def _read() -> dict[str, Any]:
def _read(kind: str) -> dict[str, Any]:
try:
data = json.loads(_path().read_text(encoding="utf-8-sig"))
data = json.loads(_path(kind).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) and isinstance(data.get("first_task_at"), (int, float)) else {}
@contextmanager
def _transaction() -> Iterator[tuple[dict[str, Any], Any]]:
def _transaction(kind: str) -> Iterator[tuple[dict[str, Any], Any]]:
from hermes_cli.auth import _file_lock
from utils import atomic_write_text
path = _path()
path = _path(kind)
with _file_lock(path.with_suffix(".lock"), _LOCK, 5.0, f"Timed out waiting for the sign-in offer record ({path})"):
yield _read(), lambda data: atomic_write_text(path, json.dumps(data))
yield _read(kind), lambda data: atomic_write_text(path, json.dumps(data))
def _on_free_tier() -> bool:
def _eligible(kind: str) -> bool:
"""``trial``: the welcome trial carries inference. ``connector``: a guest that connected an app,
guests are still allowed (``nous.guest``), and no trial makes its own offer."""
from hermes_cli import anon_auth
return anon_auth.free_inference_active()
if kind == TRIAL:
return anon_auth.free_inference_active()
state = anon_auth.current_nous_state()
return (anon_auth.is_guest_state(state) and state.get(CONNECTED_APP_FLAG) is True and anon_auth.guest_allowed()
and not anon_auth.free_inference_active(state))
def _due_at(state: dict[str, Any]) -> Optional[float]:
@@ -63,30 +82,55 @@ def _due_at(state: dict[str, Any]) -> Optional[float]:
return max(last_task + OFFER_DELAY_S, last_offer + REOFFER_AFTER_S[min(offers, len(REOFFER_AFTER_S)) - 1])
def _due_in(state: dict[str, Any]) -> Optional[int]:
def _due_in(kind: str, state: dict[str, Any]) -> Optional[int]:
due_at = _due_at(state)
if due_at is None or not _on_free_tier():
if due_at is None or not _eligible(kind):
return None
return max(0, math.ceil(due_at - _clock()))
def record_task_done() -> None:
"""A task finished on the free tier: the first one starts the offer clock, later ones re-arm it."""
with _transaction() as (state, write):
def record_task_done(kind: str = TRIAL) -> None:
"""A task finished: the first one starts the offer clock, later ones re-arm it."""
with _transaction(kind) as (state, write):
now = _clock()
write({**state, "first_task_at": state.get("first_task_at", now), "last_task_at": now})
def offer_due_in() -> Optional[int]:
"""Seconds until the offer is due (0 = now); None when none is pending or the user is not on the
free tier. A pure read."""
return _due_in(_read())
def note_app_connected() -> None:
"""An app finished connecting. On a guest identity, count a finished task for the connector offer,
then flag the guest (so the flag dies with it). The flag ends list-read sightings, so it is set only
once the task is recorded. Never fails the connection."""
from hermes_cli import anon_auth
try:
if anon_auth.is_guest_state(anon_auth.current_nous_state()):
record_task_done(CONNECTOR)
anon_auth.set_guest_flag(CONNECTED_APP_FLAG)
except Exception:
logger.debug("connector sign-in offer record failed", exc_info=True)
def claim_offer() -> bool:
def note_connected_apps_seen(rows: list[Any]) -> None:
"""A connector list read (agent status, the Connectors page). The first one that shows a guest an
app connected counts as the connect: a connect by link (messaging, one-shot runs) never passes the
operation watcher, and an older Hermes connected apps without setting the flag."""
from hermes_cli import anon_auth
if not any(isinstance(row, dict) and row.get("connected") for row in rows):
return
state = anon_auth.current_nous_state()
if anon_auth.is_guest_state(state) and not state.get(CONNECTED_APP_FLAG):
note_app_connected()
def offer_due_in(kind: str = TRIAL) -> Optional[int]:
"""Seconds until the offer is due (0 = now); None when none is pending or the user is not
eligible for it. A pure read."""
return _due_in(kind, _read(kind))
def claim_offer(kind: str = TRIAL) -> bool:
"""Compare-and-set under the lock: True for exactly one caller each time an offer comes due."""
with _transaction() as (state, write):
if _due_in(state) != 0:
with _transaction(kind) as (state, write):
if _due_in(kind, state) != 0:
return False
write({**state, "offers": int(state.get("offers") or 0) + 1, "last_offered_at": _clock()})
return True
+2
View File
@@ -1089,6 +1089,8 @@
"composer.wakeWordOff",
"composer.wakeWordPausedVoice",
"composer.wakingProfile",
"connectorOffer.body",
"connectorOffer.heading",
"connectors.authorizedToolsUnavailable",
"connectors.cancel",
"connectors.checking",
+20
View File
@@ -111,3 +111,23 @@ def test_never_offered_off_the_free_tier(clock, monkeypatch):
monkeypatch.setattr(anon_auth, "free_inference_active", lambda state=None: False)
assert free_tier_offer.offer_due_in() is None
assert free_tier_offer.claim_offer() is False
@pytest.mark.parametrize("guest,connected,trial,allowed,offered", [
(True, True, False, True, True), # a guest on its own key that connected an app
(True, False, False, True, False), # a guest that connected nothing
(True, True, True, True, False), # the trial makes its own offer
(False, True, False, True, False), # signed in: no guest, no offer
(True, True, False, False, False), # nous.guest: false, the guest left on disk
], ids=["connector-guest", "nothing-connected", "on-trial", "signed-in", "guests-off"])
def test_connector_offer_is_only_for_a_guest_that_connected_an_app_without_the_trial(
clock, monkeypatch, guest, connected, trial, allowed, offered):
state = {"auth_method": anon_auth.ANON_AUTH_METHOD if guest else "device_code"}
if connected:
state[free_tier_offer.CONNECTED_APP_FLAG] = True
monkeypatch.setattr(anon_auth, "current_nous_state", lambda: state)
monkeypatch.setattr(anon_auth, "free_inference_active", lambda state=None: trial)
monkeypatch.setattr(anon_auth, "guest_allowed", lambda: allowed)
free_tier_offer.record_task_done(free_tier_offer.CONNECTOR)
assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == (DELAY if offered else None)
assert free_tier_offer.offer_due_in() is None # the trial's record is its own
+2 -1
View File
@@ -145,7 +145,8 @@ def test_each_connect_path_names_its_initiator(monkeypatch):
for initiator in (managed.AGENT_TOOL, managed.DESKTOP_PAGE):
operation = ConnectionOperation([Target("gmail", "connector", "connect")])
client = managed.managed_client() if initiator == managed.AGENT_TOOL else managed.managed_client(initiator=initiator)
client = (managed.managed_client(creates_guest=True) if initiator == managed.AGENT_TOOL
else managed.managed_client(creates_guest=True, initiator=initiator))
managed.mint(client, operation, ["gmail"], reinitiate=False, actor=Actor.backend_watcher)
operation.transition("gmail", TargetState.failed, Actor.backend_watcher)
@@ -0,0 +1,188 @@
"""The desktop Connectors page, the CLI and the agent tool, and the guest identity behind hosted connectors.
Contracts, for a user with no Nous identity (the fake account service in
``tests.hermes_cli.anon_portal``, Hermes' real RPC handlers and CLI):
- opening the page (list, catalog, accounts) creates no identity; the catalog comes from the Portal's
public route, asked once and without a bearer, and rules are refused rather than invented
- ``hermes connectors list`` and the agent's ``manage_connections`` status each create exactly one
identity and make the gateway list call, as before
- the first connect creates one identity, arms the connector sign-in offer, and the page then reads
the signed-in routes; a connect by link that a later status read sees connected arms it too, and
so does a page list that shows a guest's app connected on an older Hermes
"""
from __future__ import annotations
import json
from argparse import Namespace
import pytest
import tui_gateway.server as srv
from hermes_cli import free_tier_offer
from tests.hermes_cli.anon_portal import install_portal
from tests.waiting import wait_until
from tools.connectors import live
ACCOUNT = {"owner": {"type": "account"}}
CATALOG = {"connectors": [{"slug": "notion", "name": "Notion", "description": "Docs", "category": "Productivity",
"logoUrl": None, "state": "live"}]}
class _Response:
def __init__(self, body):
self.status_code, self._body, self.headers = 200, body, {}
def json(self):
return self._body
@pytest.fixture
def portal(monkeypatch, tmp_path):
fake = install_portal(monkeypatch, tmp_path)
live.reset_for_tests()
yield fake
live.reset_for_tests()
@pytest.fixture
def connector_portal(monkeypatch):
"""The Portal's connector routes: every request as ``(path, sent a bearer)``."""
requests = []
class Transport:
def request(self, method, url, *, headers=None, json=None, timeout=None):
path = "/" + url.split("://", 1)[1].split("/", 1)[1]
requests.append((path, "Authorization" in (headers or {})))
return _Response({"accounts": []} if path.endswith("/accounts") else CATALOG)
monkeypatch.setattr("tools.connectors.portal.client._default_transport", Transport)
return requests
class _Lists(list):
connected = False
@pytest.fixture
def gateway(monkeypatch):
"""The connector gateway: every list call, and an instant connect. ``connected`` is what it lists."""
lists = _Lists()
class Gateway:
def __init__(self, **_):
pass
def list_connectors(self, *, timeout=None):
lists.append(1)
return [{"connector": "notion", "enabled": True, "connected": lists.connected}]
def connections(self, names, **_):
return {"results": [{"connector": name, "status": "active", "connection_id": "c1"} for name in names]}
monkeypatch.setattr("tools.connectors.gateway.client.ConnectorClient", Gateway)
return lists
def _call(method: str, params: dict | None = None) -> dict:
return srv.handle_request({"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}})
def _result(method: str, params: dict | None = None) -> dict:
response = _call(method, params)
assert "result" in response, response
return response["result"]
def test_opening_the_page_creates_no_identity_and_reads_the_public_catalog(portal, connector_portal, gateway):
assert _result("connectors.list", ACCOUNT) == {"available": True, "connectors": []}
assert [row["slug"] for row in _result("connectors.catalog")["connectors"]] == ["notion"]
assert _result("connectors.accounts") == {"accounts": []}
assert _call("connectors.policy.get")["error"]["code"] == 4032
assert connector_portal == [("/api/v1/connectors/catalog/public", False)]
assert portal.creates() == 0 and gateway == []
@pytest.mark.parametrize("entry", ["cli", "agent"])
def test_explicit_lists_still_create_one_identity_and_list_from_the_gateway(portal, gateway, entry):
from hermes_cli.connectors_cmd import cmd_connectors
from tools.connectors.gateway.config import connectors_available
from tools.connectors.tool import manage_connections
if entry == "cli":
assert cmd_connectors(Namespace(connectors_action="list")) == 0
else:
result = json.loads(manage_connections({"action": "status"}, connectors_available=connectors_available))
assert result["connectors"][0]["connector"] == "notion"
assert portal.creates() == 1 and gateway == [1]
def test_first_connect_creates_one_identity_then_the_page_reads_signed_in(
portal, connector_portal, gateway, monkeypatch):
monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
_result("connectors.connect", {**ACCOUNT, "connectors": ["notion"]})
assert wait_until(lambda: "connector_nudge_due_in" in _result("free_tier.status"), timeout=5)
status = _result("free_tier.status")
assert status["connector_nudge_due_in"] == free_tier_offer.OFFER_DELAY_S
assert "nudge_due_in" not in status and status["has_guest"] and not status["available"]
assert _result("connectors.list", ACCOUNT)["connectors"][0]["connector"] == "notion"
_result("connectors.catalog")
assert connector_portal == [("/api/v1/connectors/catalog", True)]
assert portal.creates() == 1
def test_a_connect_by_link_seen_by_a_status_read_arms_the_connector_offer(portal, gateway, monkeypatch):
"""Messaging and one-shot runs connect by link and check with status; no operation watcher runs."""
from tools.connectors.gateway.config import connectors_available
from tools.connectors.tool import manage_connections
monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
gateway.connected = True
for _ in range(2):
manage_connections({"action": "status"}, connectors_available=connectors_available)
assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == free_tier_offer.OFFER_DELAY_S
assert portal.creates() == 1
def test_the_page_list_arms_the_offer_for_apps_a_guest_connected_on_an_older_hermes(portal, gateway, monkeypatch):
"""The guest exists without the connected-app flag (an older Hermes connected its apps); the
first page read that lists one connected must still arm the connector offer."""
from tools.connectors import ensure_guest_identity
monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
assert ensure_guest_identity() is None
gateway.connected = True
assert _result("connectors.list", ACCOUNT)["connectors"][0]["connected"] is True
assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == free_tier_offer.OFFER_DELAY_S
assert portal.creates() == 1
def test_a_failed_offer_write_is_retried_on_the_next_sighting(portal, gateway, monkeypatch):
"""The flag that ends the sightings is written only once the offer's schedule is, so a failed
schedule write leaves the next list read to try again."""
from tools.connectors import ensure_guest_identity
monkeypatch.setattr(free_tier_offer, "_clock", lambda: 1_000_000.0)
assert ensure_guest_identity() is None
gateway.connected = True
real_record = free_tier_offer.record_task_done
def fail_once(kind=free_tier_offer.TRIAL):
monkeypatch.setattr(free_tier_offer, "record_task_done", real_record)
raise TimeoutError("offer record locked")
monkeypatch.setattr(free_tier_offer, "record_task_done", fail_once)
_result("connectors.list", ACCOUNT)
assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) is None
_result("connectors.list", ACCOUNT)
assert free_tier_offer.offer_due_in(free_tier_offer.CONNECTOR) == free_tier_offer.OFFER_DELAY_S
@@ -37,6 +37,7 @@ def test_secondary_ticket_runs_connectors_and_billing_in_its_own_profile(homes,
import hermes_cli.nous_billing
import tools.connectors
import tools.connectors.managed
import tools.managed_tool_gateway
from hermes_constants import get_hermes_home
server, launch, bee = homes
seen = []
@@ -45,8 +46,9 @@ def test_secondary_ticket_runs_connectors_and_billing_in_its_own_profile(homes,
seen.append((label, Path(str(get_hermes_home())).resolve()))
monkeypatch.setattr(tools.connectors, 'connectors_available', lambda *a, **k: True)
monkeypatch.setattr(tools.managed_tool_gateway, 'read_nous_access_token', lambda: 'token')
monkeypatch.setattr(tools.connectors.managed, 'managed_client',
lambda: types.SimpleNamespace(list_connectors=lambda: rec('list') or []))
lambda **_: types.SimpleNamespace(list_connectors=lambda: rec('list') or []))
monkeypatch.setattr(hermes_cli.nous_billing, 'post_charge', lambda **k: rec('charge') or {'chargeId': 'c'})
for method, params in (('connectors.list', {'owner': {'type': 'account'}}), ('billing.charge', {'amount_usd': 5})):
_call(server, bee, method, params)
+1 -1
View File
@@ -77,7 +77,7 @@ def _run(start: AccountOperationStart, action: str, initiator: str | None) -> No
try:
drive_operation(
start.operation,
managed.managed_kind(managed.managed_client(initiator=initiator), action, force=False),
managed.managed_kind(managed.managed_client(creates_guest=True, initiator=initiator), action, force=False),
connection_callback=lambda _payload: start.done.set(),
tick_seconds=managed.WATCH_TICK_SECONDS,
with_urls_in_result=False,
+30 -7
View File
@@ -51,20 +51,40 @@ DESKTOP_PAGE = "desktop_page"
CARD_RETRY = "card_retry"
def managed_client(*, initiator: Optional[str] = AGENT_TOOL, call: Optional[Any] = None):
"""The real gateway client for a user action. The first one creates the guest identity when the
user has none: every hosted action is a request for an app. ``initiator`` names who asked, so
every link the client mints is attributed."""
def managed_client(*, creates_guest: bool, initiator: Optional[str] = AGENT_TOOL, call: Optional[Any] = None):
"""The real gateway client for a user action. ``creates_guest`` is the caller's declaration that
it may create the guest identity when the user has none (a connect, an explicit list); a page
read passes False and holds a token already. ``initiator`` names who asked, so every link the
client mints is attributed."""
from tools.connectors.gateway.client import ConnectorClient
from tools.connectors.gateway.errors import ToolGatewayError
if setup_error := ensure_guest_identity():
if creates_guest and (setup_error := ensure_guest_identity()):
raise ToolGatewayError(setup_error, code=GUEST_SETUP_FAILED, retryable=True)
return ConnectorClient(initiator=initiator, call=call)
def managed_kind(client: Any, action: str, force: bool) -> Kind:
return Kind(prepare=_prepare(client, action, force), observe=lambda operation: _observe(client, operation), note=NOTE)
noted: set[str] = set()
def noting(step: Callable[[ConnectionOperation], None]) -> Callable[[ConnectionOperation], None]:
def run(operation: ConnectionOperation) -> None:
step(operation)
_note_connected(operation, noted)
return run
return Kind(prepare=noting(_prepare(client, action, force)),
observe=noting(lambda operation: _observe(client, operation)), note=NOTE)
def _note_connected(operation: ConnectionOperation, noted: set[str]) -> None:
"""Each app that connects counts once toward the connector sign-in offer."""
fresh = {t.name for t in operation.targets if t.state == TargetState.connected} - noted
if fresh:
from hermes_cli.free_tier_offer import note_app_connected
noted.update(fresh)
note_app_connected()
def _status_by_slug(client: Any) -> dict[str, dict[str, Any]]:
@@ -336,6 +356,9 @@ def _no_card_result(client: Any, action: str, targets: list[HostedTarget], force
def _status_result(client: Any, connectors: list[str]) -> str:
items = client.list_connectors()
from hermes_cli.free_tier_offer import note_connected_apps_seen
note_connected_apps_seen(items)
known = {str(i.get("connector", "")).lower() for i in items}
unknown = {name: _unknown_slug(name, known) for name in connectors if name not in known}
if connectors:
@@ -427,7 +450,7 @@ def run_managed_action(
if action == "rename":
return _rename_result(targets[0])
call = CallRef(turn_id=turn_id, tool_call_id=tool_call_id)
client = client_factory() if client_factory else managed_client(call=call)
client = client_factory() if client_factory else managed_client(creates_guest=True, call=call)
if action == "display":
return _display_result(client, [t.name for t in targets], session_id=session_id, tool_call_id=tool_call_id,
connection_callback=connection_callback if card_surface else None, call=call)
+13 -3
View File
@@ -124,6 +124,12 @@ class PortalConnectorClient:
response, status, route = self._request("GET", "/api/v1/connectors/catalog")
return self._parse(ConnectorCatalogResponse, response, status, route, PortalConnectorUnavailable, "portal catalog unavailable")
def public_catalog(self) -> ConnectorCatalogResponse:
"""The catalog a new guest would get, for a caller with no token yet. Sent without a bearer so
every caller gets the one cached answer."""
response, status, route = self._request("GET", "/api/v1/connectors/catalog/public", public=True)
return self._parse(ConnectorCatalogResponse, response, status, route, PortalConnectorUnavailable, "portal catalog unavailable")
def list_accounts(self) -> list[dict[str, Any]]:
response, status, route = self._request("GET", "/api/v1/connectors/accounts")
inventory = self._parse(ConnectorAccountsResponse, response, status, route, PortalConnectorUnavailable, "portal accounts unavailable")
@@ -156,11 +162,15 @@ class PortalConnectorClient:
body: dict[str, Any] | None = None,
*,
headers: dict[str, str] | None = None,
public: bool = False,
) -> tuple[Any, int, str]:
"""The answer, its status and route key. A failed status is logged and raised here; a 2xx is
logged by ``_parse`` once its body is checked."""
logged by ``_parse`` once its body is checked. ``public`` routes get no Authorization."""
url = f"{self.origin()}{path}"
headers = self._authorized_headers(url, headers)
if public:
headers = {"Accept": "application/json", **(headers or {}), **connector_headers()}
else:
headers = self._authorized_headers(url, headers)
try:
response = self._transport.request(method, url, headers=headers, json=body, timeout=DEFAULT_TIMEOUT_SECONDS)
except Exception as exc:
@@ -199,7 +209,7 @@ def _route_key(path: str) -> str:
return ".".join(["portal", *named]) or "portal"
_PORTAL_ROUTE_WORDS = frozenset({"connectors", "tools", "catalog", "accounts", "policy"})
_PORTAL_ROUTE_WORDS = frozenset({"connectors", "tools", "catalog", "public", "accounts", "policy"})
def _safe_json(response: Any) -> Any:
+1 -1
View File
@@ -61,7 +61,7 @@ def reissue(operation: ConnectionOperation, names: Sequence[str], *, initiator:
if targets[0].kind == "connector":
from tools.connectors.managed import CARD_RETRY, managed_client, mint
client = managed_client(initiator=initiator or CARD_RETRY, call=operation.call)
client = managed_client(creates_guest=True, initiator=initiator or CARD_RETRY, call=operation.call)
mint(client, operation, stale, reinitiate=True, actor=Actor.user)
return None
from tools.connectors import catalog
@@ -207,6 +207,8 @@ class FreeTierStatusResult(Result):
# Seconds until the sign-in offer after a finished task is due (0 = due now); absent when none is
# pending (no finished task since the last offer, or not on the free tier).
nudge_due_in: int | None = None
# The same for the connector offer: a guest with no trial that has connected an app.
connector_nudge_due_in: int | None = None
method("free_tier.status", params=ProfileParams, result=FreeTierStatusResult,
@@ -248,11 +250,15 @@ method("free_tier.ack_notice", params=ProfileParams, result=FreeTierAckNoticeRes
doc="Mark the one-time availability notice as shown on the free-tier identity.")
class FreeTierClaimNudgeParams(ProfileParams):
kind: Literal["trial", "connector"] = "trial"
class FreeTierClaimNudgeResult(Result):
claimed: bool
method("free_tier.claim_nudge", params=ProfileParams, result=FreeTierClaimNudgeResult,
method("free_tier.claim_nudge", params=FreeTierClaimNudgeParams, result=FreeTierClaimNudgeResult,
doc="Claim the due sign-in offer; true for exactly one caller each time an offer comes due.")
+14 -6
View File
@@ -82,8 +82,8 @@ def _account_gate_closed(rid):
def _account_identity_error(rid):
"""Opening the Connectors page or connecting from it is the user asking for apps: create the
guest identity here when the user has none. Operation polls never call this."""
"""Connecting from the Connectors page is the user asking for apps: create the guest identity
here when the user has none. Page reads and operation polls never call this."""
from tools.connectors import ensure_guest_identity
from tui_gateway.contracts.connectors import ConnectorErrorReason
@@ -190,10 +190,18 @@ def _connector_rows(rows):
])
def _account_connector_list(rid):
from tools.connectors.managed import managed_client
# Whether each account-owned action of the Connectors page may create the guest identity.
_ACCOUNT_CREATES_GUEST = {"status": False, "connect": True}
return _ok(rid, {"available": True, "connectors": _connector_rows(managed_client().list_connectors())})
def _account_connector_list(rid):
from hermes_cli.free_tier_offer import note_connected_apps_seen
from tools.connectors.managed import managed_client
from tools.managed_tool_gateway import read_nous_access_token
rows = managed_client(creates_guest=False).list_connectors() if read_nous_access_token() else []
note_connected_apps_seen(rows)
return _ok(rid, {"available": True, "connectors": _connector_rows(rows)})
def _account_connector_connect(rid, request):
@@ -232,7 +240,7 @@ def _connector_rpc(rid, params, action):
with _account_scope(request):
if closed := _account_gate_closed(rid):
return _ok(rid, {"available": False, "connectors": []}) if action == "status" else closed
if setup_error := _account_identity_error(rid):
if _ACCOUNT_CREATES_GUEST[action] and (setup_error := _account_identity_error(rid)):
return setup_error
return _account_connector_list(rid) if action == "status" else _account_connector_connect(rid, request)
+17 -5
View File
@@ -14,20 +14,24 @@ method = _registry.method
_profile_scoped = _registry.profile_scoped
def _account_method(params_model=None, *, invalid="", invalid_reason=ConnectorErrorReason.invalid_params,
def _account_method(params_model=None, *, creates_guest, invalid="", invalid_reason=ConnectorErrorReason.invalid_params,
unavailable, unavailable_message):
"""``creates_guest``: whether this entry point may create the guest identity when the user has
none. The page's reads never do; they answer from what a caller without a token can see."""
def decorate(fn):
def handler(rid, params):
from pydantic import ValidationError
from tools.connectors.gateway.errors import GatewayAuthError
with _desktop_page_vars():
if gate_error := _account_gate_closed(rid) or _account_identity_error(rid):
if gate_error := _account_gate_closed(rid):
return gate_error
try:
request = params if params_model is None else params_model.model_validate(params)
except ValidationError:
return _connector_rpc_error(rid, 4000, invalid_reason, invalid)
if creates_guest and (identity_error := _account_identity_error(rid)):
return identity_error
try:
return fn(rid, request)
except GatewayAuthError as exc:
@@ -48,6 +52,7 @@ def _account_method(params_model=None, *, invalid="", invalid_reason=ConnectorEr
@_profile_scoped
@_account_method(
ConnectorToolsParams,
creates_guest=True,
invalid="slug and refresh are required parameters",
unavailable=ConnectorErrorReason.tools_unavailable,
unavailable_message="Connector tools are unavailable.",
@@ -71,16 +76,17 @@ def _(rid, request):
@method("connectors.catalog")
@_profile_scoped
@_account_method(
creates_guest=False,
unavailable=ConnectorErrorReason.catalog_unavailable,
unavailable_message="Connector catalog is unavailable.",
)
def _(rid, _params):
from tools.connectors.portal.client import PortalConnectorClient
from tools.managed_tool_gateway import read_nous_access_token
from tui_gateway.contracts.connectors import ConnectorsCatalogResult
client = PortalConnectorClient()
client.require_authentication()
catalog = client.catalog()
catalog = client.catalog() if read_nous_access_token() else client.public_catalog()
return _ok(rid, ConnectorsCatalogResult.model_validate(catalog, from_attributes=True).model_dump(mode="json"))
@@ -88,15 +94,17 @@ def _(rid, _params):
@_profile_scoped
@_account_method(
ConnectorAccountsParams,
creates_guest=False,
invalid="connector must be a slug.",
unavailable=ConnectorErrorReason.accounts_unavailable,
unavailable_message="Connector accounts are unavailable.",
)
def _(rid, request):
from tools.connectors.portal.client import PortalConnectorClient
from tools.managed_tool_gateway import read_nous_access_token
from tui_gateway.contracts.connectors import ConnectorAccountRow, ConnectorAccountsResult
accounts = PortalConnectorClient().list_accounts()
accounts = PortalConnectorClient().list_accounts() if read_nous_access_token() else []
rows = [account for account in accounts if request.connector is None or account["connector"] == request.connector]
result = ConnectorAccountsResult(accounts=[_account_row(account) for account in rows])
return _ok(rid, result.model_dump(mode="json"))
@@ -123,6 +131,7 @@ def _account_row(account):
@_profile_scoped
@_account_method(
ConnectorAccountsRenameParams,
creates_guest=True,
invalid="connection_id and a valid alias are required.",
unavailable=ConnectorErrorReason.accounts_unavailable,
unavailable_message="Connector accounts are unavailable.",
@@ -160,6 +169,7 @@ def _(rid, request):
@_profile_scoped
@_account_method(
ConnectorAccountsRemoveParams,
creates_guest=True,
invalid="connection_id is required.",
unavailable=ConnectorErrorReason.accounts_unavailable,
unavailable_message="Connector accounts are unavailable.",
@@ -192,6 +202,7 @@ def _(rid, request):
@method("connectors.policy.get")
@_profile_scoped
@_account_method(
creates_guest=False,
unavailable=ConnectorErrorReason.policy_unavailable,
unavailable_message="Connector policy is unavailable.",
)
@@ -222,6 +233,7 @@ def _(rid, _params):
@_profile_scoped
@_account_method(
ConnectorPolicySetParams,
creates_guest=True,
invalid="Connector parameters are invalid.",
unavailable=ConnectorErrorReason.policy_unavailable,
unavailable_message="Connector policy is unavailable.",
+9 -6
View File
@@ -26,7 +26,8 @@ def _(rid, params: dict) -> dict:
inference actually runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``.
``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity.
``nudge_due_in``: seconds until the sign-in offer after a finished task is due (install-wide,
``hermes_cli.free_tier_offer``); absent when none is pending.
``hermes_cli.free_tier_offer``); absent when none is pending. ``connector_nudge_due_in``: the
same for a guest without the trial that has connected an app.
A pure read: an identity is never created as a side effect of polling this (NS-845 Q1.2)."""
try:
@@ -39,8 +40,9 @@ def _(rid, params: dict) -> dict:
"notice_pending": bool(available and anon_auth.guest_notice_pending()),
"model": anon_auth.GUEST_MODEL, "label": anon_auth.FREE_TIER_LABEL}
from hermes_cli import free_tier_offer
if (nudge_due_in := free_tier_offer.offer_due_in()) is not None:
payload["nudge_due_in"] = nudge_due_in
for key, kind in (("nudge_due_in", free_tier_offer.TRIAL), ("connector_nudge_due_in", free_tier_offer.CONNECTOR)):
if (due_in := free_tier_offer.offer_due_in(kind)) is not None:
payload[key] = due_in
if enabled and not has_guest:
# Why there is no identity, when the last claim failed:
# ``{error, error_code, retryable, retry_after}`` (the mint memo's verdict).
@@ -103,11 +105,12 @@ def _(rid, params: dict) -> dict:
@method("free_tier.claim_nudge")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Claim the due sign-in offer. Claim before showing, so two windows never both show it:
``claimed`` is true for exactly one caller each time an offer comes due."""
"""Claim the due sign-in offer of ``kind`` (``trial`` or ``connector``). Claim before showing,
so two windows never both show it: ``claimed`` is true for exactly one caller each time an offer
comes due."""
try:
from hermes_cli import free_tier_offer
return _ok(rid, {"claimed": bool(free_tier_offer.claim_offer())})
return _ok(rid, {"claimed": bool(free_tier_offer.claim_offer(params.get("kind") or free_tier_offer.TRIAL))})
except Exception as e: # the RPC boundary: report it, never crash the dispatcher
logger.warning("free_tier.claim_nudge failed", exc_info=True)
return _err(rid, 5093, str(e))
@@ -183,9 +183,12 @@ tools:
```
Connectors need a Nous identity. You do not have to sign in for one: if you
have no Nous account, the first `manage_connections` action (or opening the
desktop Connectors page) creates a free Nous identity for you, on every
surface. It does not change your model provider: outside builds that ship
have no Nous account, the first `manage_connections` action (or connecting an
app from the desktop Connectors page) creates a free Nous identity for you, on
every surface. Opening the Connectors page creates none: until an identity
exists it lists the hosted apps from the Nous Portal's public catalog, which
needs no sign-in.
It does not change your model provider: outside builds that ship
the Nous free tier it is used for connectors and managed tools only. Until it exists, `tool_search` sends nothing to the
gateway and its result says that hosted connectors are not set up yet and
that `manage_connections` sets them up. To opt out, set: