From 7b0f11fc7b2370c92558e9d2ce4500efca50192d Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:08:27 +0530 Subject: [PATCH] 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. --- .../connectors/data/account-operations.ts | 9 +- .../connectors/data/queries-rules.test.tsx | 82 ++++++++ .../capabilities/connectors/data/queries.ts | 16 ++ .../hooks/desktop-onboarding-metrics.test.ts | 2 +- .../free-tier/sign-in-dialog.test.tsx | 23 ++- .../components/free-tier/sign-in-dialog.tsx | 5 +- apps/desktop/src/i18n/en_notices.ts | 9 +- apps/desktop/src/i18n/overlay-gaps.json | 2 + apps/desktop/src/i18n/types_notices.ts | 2 + .../desktop/src/store/free-tier-offer.test.ts | 20 +- .../src/store/free-tier-sign-in.test.ts | 4 +- apps/desktop/src/store/free-tier-sign-in.ts | 68 +++++-- apps/desktop/src/types/hermes.ts | 2 + apps/shared/src/gateway-contract.generated.ts | 7 +- apps/shared/src/gateway-contract.openrpc.json | 42 +++- hermes_cli/connectors_cmd.py | 6 +- hermes_cli/free_tier_offer.py | 94 ++++++--- locales/_keys.desktop.json | 2 + tests/hermes_cli/test_free_tier_offer.py | 20 ++ tests/tools/test_connector_wire_identity.py | 3 +- .../test_connectors_page_identity.py | 188 ++++++++++++++++++ ...st_legacy_fallback_ticket_profile_verbs.py | 4 +- tools/connectors/account.py | 2 +- tools/connectors/managed.py | 37 +++- tools/connectors/portal/client.py | 16 +- tools/connectors/run.py | 2 +- .../contracts/config_free_tier_control.py | 8 +- tui_gateway/methods_connectors.py | 20 +- tui_gateway/methods_connectors_account.py | 22 +- tui_gateway/methods_free_tier.py | 15 +- .../docs/user-guide/features/tool-search.md | 9 +- 31 files changed, 642 insertions(+), 99 deletions(-) create mode 100644 apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx create mode 100644 tests/tui_gateway/test_connectors_page_identity.py diff --git a/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts b/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts index 4e7bdbc8ea..7351a1b464 100644 --- a/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts +++ b/apps/desktop/src/app/capabilities/connectors/data/account-operations.ts @@ -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() + } } diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx b/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx new file mode 100644 index 0000000000..543d3af759 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/data/queries-rules.test.tsx @@ -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 }) => ( + {children} + ) + + return renderHook(() => useHostedConnectors('default'), { wrapper }) +} + +const asked = (request: ReturnType, 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) + }) +}) diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries.ts b/apps/desktop/src/app/capabilities/connectors/data/queries.ts index 593747b4cb..0355e7c3f1 100644 --- a/apps/desktop/src/app/capabilities/connectors/data/queries.ts +++ b/apps/desktop/src/app/capabilities/connectors/data/queries.ts @@ -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) }) diff --git a/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts b/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts index 65f981f460..0fb01f4c83 100644 --- a/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts +++ b/apps/desktop/src/app/contrib/hooks/desktop-onboarding-metrics.test.ts @@ -30,7 +30,7 @@ async function walk(...states: FreeTierSignInState[]): Promise { } 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(() => { diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx index ab598eec74..75f84794ee 100644 --- a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx @@ -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() + }) }) }) diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx index 8524abb288..7bd9537d03 100644 --- a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx @@ -145,7 +145,10 @@ export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProp !open && closeFreeTierSignIn()} open> {state.status === 'offer' && ( - +