Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/api/src/agents/create-agent-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -31,7 +31,8 @@ function nameFor(provider: AgentProvider, name: string | undefined): Result<stri
*/
export function createAgentLogin(orgId: string, providerId: string, givenName?: string): Promise<Result<{agent: AgentRow; password: string}, ApiError>> {
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);
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/agents/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '}));
Expand Down
30 changes: 28 additions & 2 deletions packages/integrations/src/agent-providers.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(
Expand All @@ -30,19 +49,26 @@ 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();
const readmeLogo = await Bun.file(
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();
});

Expand Down
28 changes: 25 additions & 3 deletions packages/integrations/src/agent-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type AgentProviderId =
| 'dot'
| 'poke'
| 'claude'
| 'claude-duet'
| 'claude-code'
| 'claude-desktop'
| 'hermes'
Expand All @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Expand All @@ -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));
}
1 change: 1 addition & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
AGENT_PROVIDERS,
CUSTOM_AGENT_NAME_MAX,
findAgentProvider,
loginProviderIdFor,
suggestAgentProvider,
} from './agent-providers';
export type {AgentProvider, AgentProviderId} from './agent-providers';
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 33 additions & 5 deletions packages/web/src/components/agent-setup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentLogin | null> = [];
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', () => {
Expand Down
31 changes: 25 additions & 6 deletions packages/web/src/components/agent-setup.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -73,7 +78,9 @@ export function AgentSetup({
const [error, setError] = useState<string | null>(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(
() => {
Expand Down Expand Up @@ -186,7 +193,7 @@ export function AgentSetup({
return (
<McpAgent
provider={chosen}
name={chosen.custom && !fixed ? null : (existing?.name ?? chosen.name)}
name={chosen.custom ? (fixed ? (existing?.name ?? chosen.name) : null) : chosen.name}
existing={existing}
actions={actions}
/>
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -347,7 +363,10 @@ function McpAgent({
<>
<StepHeading title={`Add Personal Agent Proxy to ${name ?? 'your agent'}`}>
{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.'}
</StepHeading>
{!reachable && <NotPublicCallout />}
{!PUBLIC && provider.custom && <NotPublicCallout custom />}
Expand Down
64 changes: 64 additions & 0 deletions packages/web/src/pages/connect-agent-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/// <reference types="bun" />

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: <ConnectAgentPage />,
},
],
{initialEntries: ['/agent-auth/connect']},
);

render(<RouterProvider router={router} />);
}

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();
});
});
4 changes: 3 additions & 1 deletion packages/web/src/pages/connect-agent-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading