mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
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}
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -1089,6 +1089,8 @@
|
||||
"composer.wakeWordOff",
|
||||
"composer.wakeWordPausedVoice",
|
||||
"composer.wakingProfile",
|
||||
"connectorOffer.body",
|
||||
"connectorOffer.heading",
|
||||
"connectors.authorizedToolsUnavailable",
|
||||
"connectors.cancel",
|
||||
"connectors.checking",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user