diff --git a/packages/api/src/agents/create-agent-login.ts b/packages/api/src/agents/create-agent-login.ts index dbf3b087..54137dba 100644 --- a/packages/api/src/agents/create-agent-login.ts +++ b/packages/api/src/agents/create-agent-login.ts @@ -2,7 +2,7 @@ import {Err, Ok, type Result} from 'ts-results-es'; import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; import {createAgent, isProviderConflict, isUsernameConflict, type AgentRow} from '@proxy/db/agent'; -import {CUSTOM_AGENT_NAME_MAX, findAgentProvider, type AgentProvider} from '@proxy/integrations'; +import {CUSTOM_AGENT_NAME_MAX, findAgentProvider, loginProviderIdFor, type AgentProvider} from '@proxy/integrations'; import {generatePassword, generateUsername} from './credentials'; @@ -31,7 +31,8 @@ function nameFor(provider: AgentProvider, name: string | undefined): Result> { return Do(async ($) => { - const provider = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`))); + const product = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`))); + const provider = $(requirePresent(findAgentProvider(loginProviderIdFor(product)), ApiErr.parseError(`Unknown login provider for ${providerId}`))); const name = $(nameFor(provider, givenName)); const password = generatePassword(); const passwordHash = await Bun.password.hash(password); diff --git a/packages/api/src/agents/route.test.ts b/packages/api/src/agents/route.test.ts index 3b65315c..14e332e8 100644 --- a/packages/api/src/agents/route.test.ts +++ b/packages/api/src/agents/route.test.ts @@ -92,6 +92,16 @@ describe('handleCreateAgentRoute', () => { expect(result.unwrapErr().kind).toBe('parse_error'); }); + test('stores Claude Duet under its shared Claude login', async () => { + const {handleCreateAgentRoute} = await import('./route'); + await handleCreateAgentRoute(makeRequest({}, {providerId: 'claude-duet'})); + + expect(createAgent.mock.calls[0]?.[0]).toMatchObject({ + providerId: 'claude', + name: 'Claude', + }); + }); + test('names a custom agent as the person did, and any number of them', async () => { const {handleCreateAgentRoute} = await import('./route'); await handleCreateAgentRoute(makeRequest({}, {providerId: 'custom', name: ' Cursor '})); diff --git a/packages/integrations/src/agent-providers.test.ts b/packages/integrations/src/agent-providers.test.ts index 848ce4d1..0477cc64 100644 --- a/packages/integrations/src/agent-providers.test.ts +++ b/packages/integrations/src/agent-providers.test.ts @@ -1,6 +1,11 @@ import {describe, expect, test} from 'bun:test'; -import {AGENT_PROVIDERS, findAgentProvider, suggestAgentProvider} from './agent-providers'; +import { + AGENT_PROVIDERS, + findAgentProvider, + loginProviderIdFor, + suggestAgentProvider, +} from './agent-providers'; describe('agent providers', () => { test('have unique ids', () => { @@ -9,6 +14,20 @@ describe('agent providers', () => { ); }); + test('shared login providers are direct, valid account logins', () => { + for (const product of AGENT_PROVIDERS) { + if (!product.loginProviderId) { + continue; + } + + const loginProvider = findAgentProvider(product.loginProviderId); + expect(loginProvider).toBeDefined(); + expect(loginProvider?.id).not.toBe(product.id); + expect(loginProvider?.custom).not.toBe(true); + expect(loginProvider?.loginProviderId).toBeUndefined(); + } + }); + test('each has its favicon bundled with the web app', async () => { for (const provider of AGENT_PROVIDERS.filter((candidate) => !candidate.custom)) { expect( @@ -30,7 +49,10 @@ describe('agent providers', () => { } }); - test('uses the same Claude Duet logo on the website and in the README', async () => { + test('uses the same Claude Duet logo in the app, on the website and in the README', async () => { + const appLogo = await Bun.file( + new URL('../../web/public/agent-providers/claude-duet.png', import.meta.url), + ).bytes(); const websiteLogo = await Bun.file( new URL('../../website/public/logos/claude-duet.png', import.meta.url), ).bytes(); @@ -38,11 +60,15 @@ describe('agent providers', () => { new URL('../../../docs/assets/agents/claude-duet.png', import.meta.url), ).bytes(); + expect(appLogo).toEqual(readmeLogo); expect(websiteLogo).toEqual(readmeLogo); }); test('finds a provider by id', () => { expect(findAgentProvider('dot')?.name).toBe('Dots'); + const duet = findAgentProvider('claude-duet'); + expect(duet?.name).toBe('Claude Duet'); + expect(duet && loginProviderIdFor(duet)).toBe('claude'); expect(findAgentProvider('other')).toBeUndefined(); }); diff --git a/packages/integrations/src/agent-providers.ts b/packages/integrations/src/agent-providers.ts index 8c3f0a49..f83e6ed6 100644 --- a/packages/integrations/src/agent-providers.ts +++ b/packages/integrations/src/agent-providers.ts @@ -5,6 +5,7 @@ export type AgentProviderId = | 'dot' | 'poke' | 'claude' + | 'claude-duet' | 'claude-code' | 'claude-desktop' | 'hermes' @@ -15,6 +16,8 @@ export type AgentProvider = { id: AgentProviderId; name: string; aliases?: string[]; + /** A distinct product that uses another provider's account-wide agent login. */ + loginProviderId?: AgentProviderId; company: string; /** * How the agent gets its access: `mcp` adds Personal Agent Proxy as a connector and signs in @@ -40,7 +43,9 @@ export type AgentProvider = { faviconUrl?: string; }; -// The agents an organization can make a login for: one each, and any number of custom ones. +// The agent products a person can connect. Most have one login each; a product with a +// loginProviderId shares that provider's account-wide login. An organization can add any number +// of custom agents. export const AGENT_PROVIDERS: AgentProvider[] = [ { id: 'grok-bot', @@ -87,12 +92,20 @@ export const AGENT_PROVIDERS: AgentProvider[] = [ { id: 'claude', name: 'Claude', - aliases: ['Claude Duet'], company: 'Anthropic', connects: 'mcp', local: false, faviconUrl: '/agent-providers/claude.png', }, + { + id: 'claude-duet', + name: 'Claude Duet', + loginProviderId: 'claude', + company: 'Anthropic', + connects: 'mcp', + local: false, + faviconUrl: '/agent-providers/claude-duet.png', + }, { id: 'claude-code', name: 'Claude Code', @@ -140,6 +153,11 @@ export function findAgentProvider(id: string): AgentProvider | undefined { return AGENT_PROVIDERS.find((provider) => provider.id === id); } +/** The provider id stored for a product's login. Some products share one account-wide login. */ +export function loginProviderIdFor(provider: AgentProvider): AgentProviderId { + return provider.loginProviderId ?? provider.id; +} + /** The longest name a custom agent can be given. */ export const CUSTOM_AGENT_NAME_MAX = 60; @@ -163,5 +181,9 @@ export function suggestAgentProvider(clientName: string): AgentProvider | undefi best = {provider, length: candidate.length}; } } - return best?.provider; + if (!best) { + return undefined; + } + + return findAgentProvider(loginProviderIdFor(best.provider)); } diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 0d222458..ca8d4683 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -2,6 +2,7 @@ export { AGENT_PROVIDERS, CUSTOM_AGENT_NAME_MAX, findAgentProvider, + loginProviderIdFor, suggestAgentProvider, } from './agent-providers'; export type {AgentProvider, AgentProviderId} from './agent-providers'; diff --git a/packages/web/public/agent-providers/claude-duet.png b/packages/web/public/agent-providers/claude-duet.png new file mode 100644 index 00000000..effc3b0b Binary files /dev/null and b/packages/web/public/agent-providers/claude-duet.png differ diff --git a/packages/web/src/components/agent-setup.test.tsx b/packages/web/src/components/agent-setup.test.tsx index 5ad50031..63fec3f0 100644 --- a/packages/web/src/components/agent-setup.test.tsx +++ b/packages/web/src/components/agent-setup.test.tsx @@ -123,19 +123,47 @@ describe('AgentSetup', () => { expect(screen.getByText(/Claude Code opens Personal Agent Proxy to sign in/)).toBeDefined(); }); - test('adds Claude and Claude Duet through their shared Claude account connector', () => { + test('offers Claude and Claude Duet as separate products', () => { + renderAgentChoice(); + + expect(screen.getByRole('button', {name: 'Claude Needs a public https address'})).toBeDefined(); + expect( + screen.getByRole('button', {name: 'Claude Duet Needs a public https address'}), + ).toBeDefined(); + }); + + test('adds Claude through its Claude account connector', () => { renderAgentSetup('claude'); - expect(screen.getByText(/Claude Duet uses the same connectors as Claude/)).toBeDefined(); expect(screen.getByRole('link', {name: 'Open Claude'}).getAttribute('href')).toBe( 'https://claude.ai/customize/connectors', ); expect(screen.getByText(/choose Register automatically under OAuth client/)).toBeDefined(); + expect(screen.getByText(/Choose Claude when asked which agent it works as/)).toBeDefined(); + }); + + test('adds Claude Duet as its own product through the shared Claude login', () => { + const claude: AgentLogin = { + id: 'agent-1', + providerId: 'claude', + name: 'Claude', + username: 'claude-k7q2', + createdAt: '2026-10-01T00:00:00Z', + lastActiveAt: null, + revokedAt: null, + grants: [], + }; + const continued: Array = []; + renderAgentSetup('claude-duet', [claude], (agent) => continued.push(agent)); + + expect(screen.getByText('Add Personal Agent Proxy to Claude Duet')).toBeDefined(); expect( - screen.getByText( - /That login gives Claude and Claude Duet the same access and activity history/, - ), + screen.getByText(/Claude Duet uses the connectors in your Claude account/), ).toBeDefined(); + expect(screen.getByText(/Claude Duet shares that login/)).toBeDefined(); + + fireEvent.keyDown(window, {key: 'Enter', metaKey: true}); + expect(continued).toEqual([claude]); }); test('adds a custom MCP agent, however many there are already', () => { diff --git a/packages/web/src/components/agent-setup.tsx b/packages/web/src/components/agent-setup.tsx index 0bead87d..875368d2 100644 --- a/packages/web/src/components/agent-setup.tsx +++ b/packages/web/src/components/agent-setup.tsx @@ -1,4 +1,9 @@ -import {findAgentProvider, type AgentProvider, type AgentProviderId} from '@proxy/integrations'; +import { + findAgentProvider, + loginProviderIdFor, + type AgentProvider, + type AgentProviderId, +} from '@proxy/integrations'; import {CheckIcon, LoaderCircleIcon} from 'lucide-react'; import {useEffect, useState, type ReactNode} from 'react'; import {useRevalidator, useSearchParams} from 'react-router'; @@ -73,7 +78,9 @@ export function AgentSetup({ const [error, setError] = useState(null); const chosen = chosenId ? findAgentProvider(chosenId) : undefined; const existingFor = (provider: AgentProvider) => - live.find((agent) => agent.providerId === provider.id && !known.has(agent.id)) ?? null; + live.find( + (agent) => agent.providerId === loginProviderIdFor(provider) && !known.has(agent.id), + ) ?? null; const existing = chosen ? existingFor(chosen) : null; useCommandEnter( () => { @@ -186,7 +193,7 @@ export function AgentSetup({ return ( @@ -269,14 +276,23 @@ const MCP_STEPS: Partial< > > = { claude: { + where: 'In Claude, open Customize, then Connectors, and choose Add custom connector.', + url: 'https://claude.ai/customize/connectors', + openLabel: 'Claude', + afterAdd: + 'Name the connector, paste the server address, and continue. Choose Sign in now, then choose Register automatically under OAuth client.', + authorize: + 'Claude opens Personal Agent Proxy to sign in. Choose Claude when asked which agent it works as.', + }, + 'claude-duet': { where: - 'In Claude, open Customize, then Connectors, and choose Add custom connector. Claude Duet uses the same connectors as Claude.', + 'Claude Duet uses the connectors in your Claude account. In Claude, open Customize, then Connectors, and choose Add custom connector.', url: 'https://claude.ai/customize/connectors', openLabel: 'Claude', afterAdd: 'Name the connector, paste the server address, and continue. Choose Sign in now, then choose Register automatically under OAuth client.', authorize: - 'Claude opens Personal Agent Proxy to sign in. Choose Claude when asked which agent it works as. That login gives Claude and Claude Duet the same access and activity history.', + 'Claude opens Personal Agent Proxy to sign in. Choose Claude when asked which agent it works as. Claude Duet shares that login, its access, and its activity history.', }, 'claude-code': { where: 'Run the command above in a terminal.', @@ -347,7 +363,10 @@ function McpAgent({ <> {provider.custom && !name ? 'Any agent that speaks MCP' : subject} works through Personal - Agent Proxy as a connector, signed in with its own login. + Agent Proxy as a connector + {provider.loginProviderId + ? `, sharing the ${findAgentProvider(provider.loginProviderId)?.name ?? 'account'} login.` + : ', signed in with its own login.'} {!reachable && } {!PUBLIC && provider.custom && } diff --git a/packages/web/src/pages/connect-agent-page.test.tsx b/packages/web/src/pages/connect-agent-page.test.tsx new file mode 100644 index 00000000..5bccd0ac --- /dev/null +++ b/packages/web/src/pages/connect-agent-page.test.tsx @@ -0,0 +1,64 @@ +/// + +import {afterEach, describe, expect, test} from 'bun:test'; +import {cleanup, render, screen} from '@testing-library/react'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import type {AgentLogin} from '@/lib/types'; +import {ConnectAgentPage} from '@/pages/connect-agent-page'; + +afterEach(() => { + cleanup(); +}); + +const claude: AgentLogin = { + id: 'agent-1', + providerId: 'claude', + name: 'Claude', + username: 'claude-k7q2', + createdAt: '2026-10-01T00:00:00Z', + lastActiveAt: null, + revokedAt: null, + grants: [], +}; + +function renderConsent(agents: AgentLogin[]) { + const router = createMemoryRouter( + [ + { + path: '/agent-auth/connect', + loader: () => ({ + kind: 'ok', + request: 'sealed-request', + details: { + clientName: 'Claude Duet', + redirectHost: 'claude.ai', + suggestedProviderId: 'claude', + }, + agents, + }), + element: , + }, + ], + {initialEntries: ['/agent-auth/connect']}, + ); + + render(); +} + +describe('ConnectAgentPage', () => { + test('connects Claude Duet through an existing Claude login', async () => { + renderConsent([claude]); + + const existing = await screen.findByRole('radio', {name: 'Claude Anthropic'}); + expect(existing).toHaveProperty('checked', true); + expect(screen.queryByRole('radio', {name: /Claude Duet/})).toBeNull(); + }); + + test('offers one canonical Claude login when Claude Duet has none yet', async () => { + renderConsent([]); + + const newClaude = await screen.findByRole('radio', {name: 'Claude New login'}); + expect(newClaude).toHaveProperty('checked', true); + expect(screen.queryByRole('radio', {name: /Claude Duet/})).toBeNull(); + }); +}); diff --git a/packages/web/src/pages/connect-agent-page.tsx b/packages/web/src/pages/connect-agent-page.tsx index c8939010..30a27a8f 100644 --- a/packages/web/src/pages/connect-agent-page.tsx +++ b/packages/web/src/pages/connect-agent-page.tsx @@ -64,7 +64,9 @@ function Consent({ consent: {allow: true, agentId: agent.id} as const, })), ...AGENT_PROVIDERS.filter( - (provider) => provider.custom || !agents.some((agent) => agent.providerId === provider.id), + (provider) => + provider.loginProviderId === undefined && + (provider.custom || !agents.some((agent) => agent.providerId === provider.id)), ).map((provider) => ({ key: `new-${provider.id}`, providerId: provider.id, diff --git a/packages/web/src/pages/new-agent-page.test.tsx b/packages/web/src/pages/new-agent-page.test.tsx new file mode 100644 index 00000000..ee4548ee --- /dev/null +++ b/packages/web/src/pages/new-agent-page.test.tsx @@ -0,0 +1,75 @@ +/// + +import {afterEach, describe, expect, test} from 'bun:test'; +import {cleanup, render, screen} from '@testing-library/react'; +import {TooltipProvider} from '@proxy/ui/components/tooltip'; +import {ThemeProvider} from '@proxy/ui/components/theme-provider'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import {HumanSide} from '@/components/human-side'; +import type {AgentLogin} from '@/lib/types'; +import {NewAgentPage} from '@/pages/new-agent-page'; + +afterEach(() => { + cleanup(); +}); + +function claudeLogin(revokedAt: string | null): AgentLogin { + return { + id: 'agent-1', + providerId: 'claude', + name: 'Claude', + username: 'claude-k7q2', + createdAt: '2026-10-01T00:00:00Z', + lastActiveAt: null, + revokedAt, + grants: [], + }; +} + +function renderNewAgentPage(agents: AgentLogin[]) { + const router = createMemoryRouter( + [ + { + id: 'human', + loader: () => ({ + user: {id: 'user-1', email: 'person@example.com', name: null, image: null}, + onboarded: true, + }), + element: , + children: [ + { + path: '/agents/new', + loader: () => ({agents}), + element: , + }, + ], + }, + ], + {initialEntries: ['/agents/new']}, + ); + + return render( + + + + + , + ); +} + +describe('NewAgentPage', () => { + test('offers Claude Duet separately when its shared Claude login is connected', async () => { + renderNewAgentPage([claudeLogin(null)]); + + expect(screen.queryByRole('button', {name: /^Claude Connected$/})).toBeNull(); + const duet = await screen.findByRole('button', {name: 'Claude Duet Connected'}); + expect(duet.querySelector('img')?.getAttribute('src')).toBe('/agent-providers/claude-duet.png'); + }); + + test('does not offer Claude Duet until its revoked shared login is restored', async () => { + renderNewAgentPage([claudeLogin('2026-10-21T00:00:00Z')]); + + expect(await screen.findByText('Which agent do you use?')).toBeDefined(); + expect(screen.queryByRole('button', {name: /Claude Duet/})).toBeNull(); + }); +}); diff --git a/packages/web/src/pages/new-agent-page.tsx b/packages/web/src/pages/new-agent-page.tsx index cc4dbb6d..b5896ce4 100644 --- a/packages/web/src/pages/new-agent-page.tsx +++ b/packages/web/src/pages/new-agent-page.tsx @@ -1,18 +1,25 @@ -import {AGENT_PROVIDERS} from '@proxy/integrations'; +import {AGENT_PROVIDERS, loginProviderIdFor} from '@proxy/integrations'; import {useLoaderData, useNavigate} from 'react-router'; import {AgentSetup} from '@/components/agent-setup'; import {AppShell, PageTitle} from '@/components/app-shell'; import {BackButton} from '@/components/back-button'; import type {agentsLoader} from '@/loaders'; -// A new agent, set up as in the welcome flow: chosen from the companies without a login yet, or a -// custom MCP agent, then handed its login or added as a connector, and on to its page. +// A new agent, set up as in the welcome flow: chosen from the products without a login yet, a +// product that shares another's login, or a custom MCP agent, then handed its login or added as a +// connector, and on to its page. export function NewAgentPage() { const {agents} = useLoaderData(); const navigate = useNavigate(); - const providers = AGENT_PROVIDERS.filter( - (provider) => provider.custom || !agents.some((agent) => agent.providerId === provider.id), - ); + const providers = AGENT_PROVIDERS.filter((provider) => { + if (provider.loginProviderId !== undefined) { + return !agents.some( + (agent) => agent.providerId === loginProviderIdFor(provider) && agent.revokedAt !== null, + ); + } + + return provider.custom || !agents.some((agent) => agent.providerId === provider.id); + }); return (