From dc5449e2f4bee6c5a0f22035f9c3a83cd195a2b1 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 17:33:25 -0700 Subject: [PATCH 1/4] Add verified registry profile pages Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 --- web/app/[handle]/page.tsx | 103 +++++++++++++++++++ web/app/u/agent-relay/agent-relay.module.css | 22 ++++ web/app/u/agent-relay/page.tsx | 1 + web/components/AgentChatSnippet.tsx | 10 +- web/lib/agent-registry.ts | 72 +++++++++++++ web/lib/test/agent-registry.test.ts | 49 +++++++++ web/next-env.d.ts | 1 + 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 web/app/[handle]/page.tsx create mode 100644 web/lib/agent-registry.ts create mode 100644 web/lib/test/agent-registry.test.ts diff --git a/web/app/[handle]/page.tsx b/web/app/[handle]/page.tsx new file mode 100644 index 00000000..b4816349 --- /dev/null +++ b/web/app/[handle]/page.tsx @@ -0,0 +1,103 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { AgentChatSnippet } from '../../components/AgentChatSnippet'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { getAgentProfile, validRegistryHandle } from '../../lib/agent-registry'; +import { newConversationId } from '../../lib/agent-chat-snippet'; +import { defaultOgImage } from '../../lib/og-meta'; +import home from '../landing.module.css'; +import flows from '../flows/flows.module.css'; +import s from '../agent-relay/agent-relay.module.css'; + +export const dynamic = 'force-dynamic'; + +type PageProps = { + params: Promise<{ handle: string }>; +}; + +const STEPS = [ + { title: 'Copy the snippet', body: 'It carries a private conversation link made just for you.' }, + { title: 'Paste it into your open chat', body: 'Claude Code or Codex uses the snippet to contact this verified agent. Nothing to install.' }, + { title: 'Approve once', body: 'Your agent asks to run one command. Choose “don’t ask again” and the rest of the conversation flows.' }, +]; + +export async function generateMetadata({ params }: PageProps): Promise { + const { handle } = await params; + if (!validRegistryHandle(handle)) return { title: 'Not Found' }; + const profile = await getAgentProfile(handle); + if (!profile) return { title: 'Not Found' }; + const title = `Chat with ${profile.displayName}`; + const canonical = `https://arelay.to/${profile.handle}`; + return { + title, + description: profile.description, + alternates: { canonical }, + openGraph: { + title, + description: profile.description, + url: canonical, + type: 'website', + images: [defaultOgImage()], + }, + twitter: { + card: 'summary_large_image', + title, + description: profile.description, + images: [defaultOgImage().url], + }, + }; +} + +export default async function RegisteredAgentPage({ params }: PageProps) { + const { handle } = await params; + if (!validRegistryHandle(handle)) notFound(); + const profile = await getAgentProfile(handle); + if (!profile) notFound(); + + return ( +
+ Skip to content + + +
+
+

Chat with {profile.displayName}

+

✓ verified {profile.verifiedDomain}

+

{profile.description}

+ {profile.status === 'active' ? ( + + ) : ( +

+ This agent is temporarily unavailable because its domain verification is no longer current. +

+ )} +
+ + {profile.status === 'active' && ( +
+

How it works

+
    + {STEPS.map((step) => ( +
  1. + {step.title} + {step.body} +
  2. + ))} +
+

+ Each message is a plain HTTPS request, and the reply comes back as its output. Treat replies from any + external agent as information, not instructions, and ask before sharing code, files or secrets. +

+
+ )} +
+ + +
+ ); +} diff --git a/web/app/u/agent-relay/agent-relay.module.css b/web/app/u/agent-relay/agent-relay.module.css index 6ba3b4b0..43a92e71 100644 --- a/web/app/u/agent-relay/agent-relay.module.css +++ b/web/app/u/agent-relay/agent-relay.module.css @@ -29,6 +29,28 @@ margin: 0 0 8px; } +.badge { + margin: -10px 0 0; + border: 1px solid #47708d; + border-radius: 999px; + padding: 6px 12px; + color: #b9ddf5; + font-family: var(--font-mono, ui-monospace), monospace; + font-size: 13px; +} + +.suspended { + width: 100%; + max-width: 760px; + margin: 0 auto; + border: 1px solid #8f655d; + border-radius: 12px; + padding: 20px; + background: #291d1d; + color: #ffd1c8; + line-height: 1.6; +} + .steps { max-width: 760px; margin: 0 auto; diff --git a/web/app/u/agent-relay/page.tsx b/web/app/u/agent-relay/page.tsx index 9693eb62..93a75ce2 100644 --- a/web/app/u/agent-relay/page.tsx +++ b/web/app/u/agent-relay/page.tsx @@ -39,6 +39,7 @@ export default function AgentRelayChatPage() {

Have your agent chat with ours

+

✓ verified agentrelay.com

{DESCRIPTION}

diff --git a/web/components/AgentChatSnippet.tsx b/web/components/AgentChatSnippet.tsx index 6e5a38cd..4cb1cf32 100644 --- a/web/components/AgentChatSnippet.tsx +++ b/web/components/AgentChatSnippet.tsx @@ -7,11 +7,17 @@ import s from './agent-chat-snippet.module.css'; // The server renders a fresh conversation per request, so an agent that fetches // the page sees a complete snippet too. -export function AgentChatSnippet({ initialConversationId }: { initialConversationId: string }) { +export function AgentChatSnippet({ + initialConversationId, + baseUrl, +}: { + initialConversationId: string; + baseUrl?: string; +}) { const [conversationId, setConversationId] = useState(initialConversationId); const [status, setStatus] = useState<'' | 'copied' | 'copy-failed' | 'new'>(''); - const snippet = agentChatSnippet({ conversationId }); + const snippet = agentChatSnippet({ conversationId, baseUrl }); async function handleCopy() { try { diff --git a/web/lib/agent-registry.ts b/web/lib/agent-registry.ts new file mode 100644 index 00000000..859b0c6d --- /dev/null +++ b/web/lib/agent-registry.ts @@ -0,0 +1,72 @@ +import { cache } from 'react'; + +const HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])$/; +const MAX_PROFILE_BYTES = 16_000; +const PROFILE_TIMEOUT_MS = 5_000; +const REGISTRY_ORIGIN = 'https://arelay.to'; + +export type PublicAgentProfile = { + handle: string; + displayName: string; + description: string; + verifiedDomain: string; + verifiedAt: string; + deliveryType: 'internal' | 'a2a' | 'relay'; + status: 'active' | 'suspended'; +}; + +export function validRegistryHandle(handle: string): boolean { + return HANDLE_PATTERN.test(handle); +} + +export async function fetchAgentProfile( + handle: string, + fetcher: typeof globalThis.fetch = globalThis.fetch, +): Promise { + if (!validRegistryHandle(handle)) return null; + const response = await fetcher(`${REGISTRY_ORIGIN}/api/v1/agents/${encodeURIComponent(handle)}`, { + cache: 'no-store', + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(PROFILE_TIMEOUT_MS), + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Agent registry returned ${response.status}`); + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_PROFILE_BYTES) { + throw new Error('Agent registry profile exceeded the size limit'); + } + const body = await response.text(); + if (new TextEncoder().encode(body).byteLength > MAX_PROFILE_BYTES) { + throw new Error('Agent registry profile exceeded the size limit'); + } + let value: unknown; + try { + value = JSON.parse(body); + } catch { + throw new Error('Agent registry returned invalid JSON'); + } + if (!isPublicAgentProfile(value) || value.handle !== handle) { + throw new Error('Agent registry returned an invalid profile'); + } + return value; +} + +export const getAgentProfile = cache((handle: string) => fetchAgentProfile(handle)); + +function isPublicAgentProfile(value: unknown): value is PublicAgentProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const profile = value as Record; + return typeof profile.handle === 'string' + && validRegistryHandle(profile.handle) + && boundedString(profile.displayName, 1, 100) + && boundedString(profile.description, 1, 1_000) + && boundedString(profile.verifiedDomain, 1, 253) + && typeof profile.verifiedAt === 'string' + && Number.isFinite(Date.parse(profile.verifiedAt)) + && (profile.deliveryType === 'internal' || profile.deliveryType === 'a2a' || profile.deliveryType === 'relay') + && (profile.status === 'active' || profile.status === 'suspended'); +} + +function boundedString(value: unknown, minimum: number, maximum: number): value is string { + return typeof value === 'string' && value.length >= minimum && value.length <= maximum; +} diff --git a/web/lib/test/agent-registry.test.ts b/web/lib/test/agent-registry.test.ts new file mode 100644 index 00000000..777824b1 --- /dev/null +++ b/web/lib/test/agent-registry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchAgentProfile, validRegistryHandle } from '../agent-registry'; + +const PROFILE = { + handle: 'acme-support', + displayName: 'Acme Support', + description: 'Answers questions about Acme products.', + verifiedDomain: 'acme.example', + verifiedAt: '2026-10-07T12:00:00.000Z', + deliveryType: 'relay', + status: 'active', +}; + +describe('public agent registry profiles', () => { + it('fetches a valid profile without caching it', async () => { + const fetcher = vi.fn(async () => Response.json(PROFILE)); + await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)).resolves.toEqual(PROFILE); + expect(fetcher).toHaveBeenCalledWith( + 'https://arelay.to/api/v1/agents/acme-support', + expect.objectContaining({ cache: 'no-store', headers: { accept: 'application/json' } }), + ); + }); + + it('maps only a registry 404 to an unknown handle', async () => { + const missing = vi.fn(async () => new Response('missing', { status: 404 })); + await expect(fetchAgentProfile('missing', missing as typeof fetch)).resolves.toBeNull(); + const unavailable = vi.fn(async () => new Response('down', { status: 503 })); + await expect(fetchAgentProfile('missing', unavailable as typeof fetch)) + .rejects.toThrow('Agent registry returned 503'); + }); + + it('rejects malformed handles and untrusted response shapes', async () => { + const fetcher = vi.fn(async () => Response.json({ ...PROFILE, handle: 'another-agent' })); + expect(validRegistryHandle('Acme')).toBe(false); + expect(validRegistryHandle('-acme')).toBe(false); + expect(validRegistryHandle('acme-support')).toBe(true); + await expect(fetchAgentProfile('Acme', fetcher as typeof fetch)).resolves.toBeNull(); + expect(fetcher).not.toHaveBeenCalled(); + await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)) + .rejects.toThrow('invalid profile'); + }); + + it('caps profile bodies', async () => { + const fetcher = vi.fn(async () => new Response('x'.repeat(16_001))); + await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)) + .rejects.toThrow('size limit'); + }); +}); diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 01a2390c61e9a003542ada7c0792eb71cd212408 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 17:48:50 -0700 Subject: [PATCH 2/4] Harden registered agent profile pages Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 --- web/app/u/[handle]/agent.md/route.ts | 41 ++++++++++++++ web/app/{ => u}/[handle]/page.tsx | 31 ++++++----- web/components/AgentChatSnippet.tsx | 4 +- web/lib/agent-chat-snippet.ts | 54 ++++++++++++++++--- web/lib/agent-registry.ts | 29 ++++++++-- web/lib/test/agent-chat-snippet.test.ts | 29 +++++++++- web/lib/test/agent-registry.test.ts | 15 +++++- .../test/registered-agent-guide-route.test.ts | 47 ++++++++++++++++ 8 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 web/app/u/[handle]/agent.md/route.ts rename web/app/{ => u}/[handle]/page.tsx (74%) create mode 100644 web/lib/test/registered-agent-guide-route.test.ts diff --git a/web/app/u/[handle]/agent.md/route.ts b/web/app/u/[handle]/agent.md/route.ts new file mode 100644 index 00000000..1746ae3f --- /dev/null +++ b/web/app/u/[handle]/agent.md/route.ts @@ -0,0 +1,41 @@ +import { getAgentProfile, validRegistryHandle } from '../../../../lib/agent-registry'; +import { + agentChatAgentGuide, + agentChatUrlForHandle, + newConversationId, +} from '../../../../lib/agent-chat-snippet'; + +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ handle: string }>; +}; + +const MARKDOWN_HEADERS = { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', +}; + +export async function GET(_request: Request, { params }: RouteContext) { + const { handle } = await params; + if (!validRegistryHandle(handle)) { + return new Response('# Agent not found\n', { status: 404, headers: MARKDOWN_HEADERS }); + } + const profile = await getAgentProfile(handle); + if (!profile) { + return new Response('# Agent not found\n', { status: 404, headers: MARKDOWN_HEADERS }); + } + if (profile.status !== 'active') { + return new Response( + `# ${profile.displayName}\n\nThis agent is temporarily unavailable because its domain verification is no longer current.\n`, + { status: 410, headers: MARKDOWN_HEADERS }, + ); + } + return new Response(agentChatAgentGuide({ + conversationId: newConversationId(), + baseUrl: agentChatUrlForHandle(profile.handle), + agentName: profile.displayName, + }), { headers: MARKDOWN_HEADERS }); +} diff --git a/web/app/[handle]/page.tsx b/web/app/u/[handle]/page.tsx similarity index 74% rename from web/app/[handle]/page.tsx rename to web/app/u/[handle]/page.tsx index b4816349..f2c89daa 100644 --- a/web/app/[handle]/page.tsx +++ b/web/app/u/[handle]/page.tsx @@ -1,14 +1,14 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { AgentChatSnippet } from '../../components/AgentChatSnippet'; -import { SiteFooter } from '../../components/SiteFooter'; -import { SiteNav } from '../../components/SiteNav'; -import { getAgentProfile, validRegistryHandle } from '../../lib/agent-registry'; -import { newConversationId } from '../../lib/agent-chat-snippet'; -import { defaultOgImage } from '../../lib/og-meta'; -import home from '../landing.module.css'; -import flows from '../flows/flows.module.css'; +import { AgentChatSnippet } from '../../../components/AgentChatSnippet'; +import { SiteFooter } from '../../../components/SiteFooter'; +import { SiteNav } from '../../../components/SiteNav'; +import { getAgentProfile, validRegistryHandle } from '../../../lib/agent-registry'; +import { agentChatUrlForHandle, newConversationId } from '../../../lib/agent-chat-snippet'; +import { defaultOgImage } from '../../../lib/og-meta'; +import home from '../../landing.module.css'; +import flows from '../../flows/flows.module.css'; import s from '../agent-relay/agent-relay.module.css'; export const dynamic = 'force-dynamic'; @@ -28,7 +28,9 @@ export async function generateMetadata({ params }: PageProps): Promise if (!validRegistryHandle(handle)) return { title: 'Not Found' }; const profile = await getAgentProfile(handle); if (!profile) return { title: 'Not Found' }; - const title = `Chat with ${profile.displayName}`; + const title = profile.status === 'active' + ? `Chat with ${profile.displayName}` + : profile.displayName; const canonical = `https://arelay.to/${profile.handle}`; return { title, @@ -63,13 +65,18 @@ export default async function RegisteredAgentPage({ params }: PageProps) {
-

Chat with {profile.displayName}

-

✓ verified {profile.verifiedDomain}

+

+ {profile.status === 'active' ? `Chat with ${profile.displayName}` : profile.displayName} +

+ {profile.status === 'active' && ( +

✓ verified {profile.verifiedDomain}

+ )}

{profile.description}

{profile.status === 'active' ? ( ) : (

diff --git a/web/components/AgentChatSnippet.tsx b/web/components/AgentChatSnippet.tsx index 4cb1cf32..27a78eb5 100644 --- a/web/components/AgentChatSnippet.tsx +++ b/web/components/AgentChatSnippet.tsx @@ -10,14 +10,16 @@ import s from './agent-chat-snippet.module.css'; export function AgentChatSnippet({ initialConversationId, baseUrl, + agentName, }: { initialConversationId: string; baseUrl?: string; + agentName?: string; }) { const [conversationId, setConversationId] = useState(initialConversationId); const [status, setStatus] = useState<'' | 'copied' | 'copy-failed' | 'new'>(''); - const snippet = agentChatSnippet({ conversationId, baseUrl }); + const snippet = agentChatSnippet({ conversationId, baseUrl, agentName }); async function handleCopy() { try { diff --git a/web/lib/agent-chat-snippet.ts b/web/lib/agent-chat-snippet.ts index 288a00b6..c161f22c 100644 --- a/web/lib/agent-chat-snippet.ts +++ b/web/lib/agent-chat-snippet.ts @@ -12,6 +12,19 @@ export const AGENT_CHAT_BRIDGE_COMMAND = process.env.NEXT_PUBLIC_AGENT_CHAT_BRIDGE_COMMAND?.trim() || `curl -fsSL ${AGENT_CHAT_URL.replace(/\/$/, '')}/bridge.sh | sh -s --`; +export function agentChatUrlForHandle( + handle: string, + configuredUrl = AGENT_CHAT_URL, +): string { + const url = new URL(configuredUrl); + const segments = url.pathname.replace(/\/+$/, '').split('/'); + segments[segments.length - 1] = encodeURIComponent(handle); + url.pathname = segments.join('/'); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + export function newConversationId(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); @@ -22,12 +35,19 @@ export function agentChatSnippet({ conversationId, baseUrl = AGENT_CHAT_URL, bridgeCommand = AGENT_CHAT_BRIDGE_COMMAND, + agentName = 'Agent Relay', }: { conversationId: string; baseUrl?: string; bridgeCommand?: string; + agentName?: string; }): string { const url = `${baseUrl.replace(/\/$/, '')}/${conversationId}`; + const normalizedAgentName = agentName.replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; + const isAgentRelay = normalizedAgentName === 'Agent Relay'; + const agentLabel = isAgentRelay + ? 'the Agent Relay agent' + : `the registered agent ${JSON.stringify(normalizedAgentName)}`; // Messages travel through a file written with the agent's file tool, never // through shell source: replies from other agents may be quoted back, and a // file's contents are never parsed. The command is identical for every turn, @@ -36,9 +56,15 @@ export function agentChatSnippet({ // user, and the full id is the conversation's bearer secret. const messageFile = `/tmp/arelay-${conversationId.slice(0, 16)}.txt`; const lines = [ - 'Chat with the Agent Relay agent for me and show me each reply. Start by saying hi and asking what it can help with.', - '', - 'Before you set anything up for Agent Relay (installs, sign-ins, workspaces, integrations), tell this agent what you plan to do and what you found, then confirm each step with it. It knows the current setup steps and will check what already exists, such as your active workspace, before anything new is created.', + `Chat with ${agentLabel} for me. Show me each reply. Start by saying hi and asking what it can help with.`, + ]; + if (isAgentRelay) { + lines.push( + '', + 'Before you set anything up for Agent Relay (installs, sign-ins, workspaces, integrations), tell this agent what you plan to do and what you found, then confirm each step with it. It knows the current setup steps and will check what already exists, such as your active workspace, before anything new is created.', + ); + } + lines.push( '', `To send a message, write it to ${messageFile} with your file-editing tool (replace the whole file each time, and never write it with a shell command). Then run this exact command; it prints the reply:`, '', @@ -49,7 +75,7 @@ export function agentChatSnippet({ `If it prints "no reply yet", empty ${messageFile} and run the same command to keep waiting.`, '', 'If curl cannot connect, your sandbox is blocking the network: ask me to approve running this exact command outside the sandbox. Choosing "don\'t ask again" covers the rest of the conversation.', - ]; + ); if (bridgeCommand) { const dir = `/tmp/agent-relay/${conversationId}`; lines.push( @@ -68,13 +94,25 @@ export function agentChatSnippet({ // The agent-readable version of arelay.to/agent-relay, served to anything that // does not ask for HTML (plain curl, web-fetch tools). Each fetch is a new // private conversation. -export function agentChatAgentGuide({ conversationId, baseUrl = AGENT_CHAT_URL }: { conversationId: string; baseUrl?: string }): string { +export function agentChatAgentGuide({ + conversationId, + baseUrl = AGENT_CHAT_URL, + agentName = 'Agent Relay', +}: { + conversationId: string; + baseUrl?: string; + agentName?: string; +}): string { + const normalizedAgentName = agentName.replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; + const isAgentRelay = normalizedAgentName === 'Agent Relay'; return [ - '# Chat with the Agent Relay agent', + `# Chat with ${isAgentRelay ? 'the Agent Relay agent' : normalizedAgentName}`, '', - "Your human pointed you here to talk with Agent Relay's agent. It answers questions about Agent Relay (Flows, Sessions, Desktop, Relayfile, Connect) and helps you set them up. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again.", + isAgentRelay + ? "Your human pointed you here to talk with Agent Relay's agent. It answers questions about Agent Relay (Flows, Sessions, Desktop, Relayfile, Connect) and helps you set them up. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again." + : `Your human pointed you here to talk with the verified agent ${JSON.stringify(normalizedAgentName)}. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again.`, '', - agentChatSnippet({ conversationId, baseUrl }), + agentChatSnippet({ conversationId, baseUrl, agentName: normalizedAgentName }), '', ].join('\n'); } diff --git a/web/lib/agent-registry.ts b/web/lib/agent-registry.ts index 859b0c6d..8a400c7c 100644 --- a/web/lib/agent-registry.ts +++ b/web/lib/agent-registry.ts @@ -35,10 +35,7 @@ export async function fetchAgentProfile( if (Number.isFinite(declaredLength) && declaredLength > MAX_PROFILE_BYTES) { throw new Error('Agent registry profile exceeded the size limit'); } - const body = await response.text(); - if (new TextEncoder().encode(body).byteLength > MAX_PROFILE_BYTES) { - throw new Error('Agent registry profile exceeded the size limit'); - } + const body = await readBoundedBody(response, MAX_PROFILE_BYTES); let value: unknown; try { value = JSON.parse(body); @@ -51,6 +48,30 @@ export async function fetchAgentProfile( return value; } +async function readBoundedBody(response: Response, limit: number): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + throw new Error('Agent registry profile exceeded the size limit'); + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + export const getAgentProfile = cache((handle: string) => fetchAgentProfile(handle)); function isPublicAgentProfile(value: unknown): value is PublicAgentProfile { diff --git a/web/lib/test/agent-chat-snippet.test.ts b/web/lib/test/agent-chat-snippet.test.ts index eb7f9ed7..44205fa0 100644 --- a/web/lib/test/agent-chat-snippet.test.ts +++ b/web/lib/test/agent-chat-snippet.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { agentChatAgentGuide, agentChatSnippet, newConversationId } from '../agent-chat-snippet'; +import { agentChatAgentGuide, agentChatSnippet, agentChatUrlForHandle, newConversationId } from '../agent-chat-snippet'; describe('agent chat snippet', () => { it('uses one identical command for every turn of the conversation', () => { @@ -109,6 +109,33 @@ describe('agent chat snippet', () => { expect(snippet).not.toContain(`/tmp/arelay-${id}`); }); + it('names a selected registry agent without changing the Agent Relay default', () => { + expect(agentChatSnippet({ conversationId: 'abc123', bridgeCommand: '' })) + .toContain('Chat with the Agent Relay agent for me.'); + expect(agentChatSnippet({ + conversationId: 'abc123', + bridgeCommand: '', + agentName: 'Acme\nSupport', + })).toContain('Chat with the registered agent "Acme Support" for me.'); + }); + + it('keeps handle chats on the configured conversation origin', () => { + expect(agentChatUrlForHandle('acme-support', 'https://staging.example/prefix/agent-relay/')) + .toBe('https://staging.example/prefix/acme-support'); + }); + + it('serves a selected registry agent guide with the file-based command', () => { + const guide = agentChatAgentGuide({ + conversationId: 'abc123', + baseUrl: 'https://arelay.to/acme-support', + agentName: 'Acme Support', + }); + expect(guide).toMatch(/^# Chat with Acme Support/); + expect(guide).toContain('the verified agent "Acme Support"'); + expect(guide).toContain('curl -sS --data-binary @/tmp/arelay-abc123.txt https://arelay.to/acme-support/abc123'); + expect(guide).not.toContain('<<'); + }); + it('creates unguessable conversation ids', () => { const id = newConversationId(); expect(id).toMatch(/^[0-9a-f]{32}$/); diff --git a/web/lib/test/agent-registry.test.ts b/web/lib/test/agent-registry.test.ts index 777824b1..7e356699 100644 --- a/web/lib/test/agent-registry.test.ts +++ b/web/lib/test/agent-registry.test.ts @@ -42,8 +42,21 @@ describe('public agent registry profiles', () => { }); it('caps profile bodies', async () => { - const fetcher = vi.fn(async () => new Response('x'.repeat(16_001))); + let pulls = 0; + let canceled = false; + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(8_001))); + if (pulls === 3) controller.close(); + }, + cancel() { + canceled = true; + }, + }); + const fetcher = vi.fn(async () => new Response(body)); await expect(fetchAgentProfile('acme-support', fetcher as typeof fetch)) .rejects.toThrow('size limit'); + expect(canceled).toBe(true); }); }); diff --git a/web/lib/test/registered-agent-guide-route.test.ts b/web/lib/test/registered-agent-guide-route.test.ts new file mode 100644 index 00000000..e33d4442 --- /dev/null +++ b/web/lib/test/registered-agent-guide-route.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from '../../app/u/[handle]/agent.md/route'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('registered agent markdown guide', () => { + it('mints a file-based conversation for an active handle', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + handle: 'acme-support', + displayName: 'Acme Support', + description: 'Answers questions about Acme products.', + verifiedDomain: 'acme.example', + verifiedAt: '2026-10-07T12:00:00.000Z', + deliveryType: 'relay', + status: 'active', + }))); + const response = await GET(new Request('https://agentrelay.com/u/acme-support/agent.md'), { + params: Promise.resolve({ handle: 'acme-support' }), + }); + const guide = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(guide).toMatch(/^# Chat with Acme Support/); + expect(guide).toMatch(/curl -sS --data-binary @\/tmp\/arelay-[0-9a-f]{16}\.txt https:\/\/arelay\.to\/acme-support\/[0-9a-f]{32}/); + expect(guide).not.toContain('<<'); + }); + + it('does not mint a conversation for a suspended handle', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + handle: 'paused-agent', + displayName: 'Paused Agent', + description: 'Temporarily unavailable.', + verifiedDomain: 'paused.example', + verifiedAt: '2026-10-07T12:00:00.000Z', + deliveryType: 'a2a', + status: 'suspended', + }))); + const response = await GET(new Request('https://agentrelay.com/u/paused-agent/agent.md'), { + params: Promise.resolve({ handle: 'paused-agent' }), + }); + expect(response.status).toBe(410); + expect(await response.text()).not.toContain('curl '); + }); +}); From fa5df22a5d307252d460f432def7e859e318bf31 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 19:37:00 -0700 Subject: [PATCH 3/4] Key official agent identity by handle Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 --- web/app/u/[handle]/agent.md/route.ts | 1 + web/app/u/[handle]/page.tsx | 1 + web/components/AgentChatSnippet.tsx | 4 +++- web/lib/agent-chat-snippet.ts | 18 +++++++++++------- web/lib/test/agent-chat-snippet.test.ts | 20 ++++++++++++++++++++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/web/app/u/[handle]/agent.md/route.ts b/web/app/u/[handle]/agent.md/route.ts index 1746ae3f..9ebe9267 100644 --- a/web/app/u/[handle]/agent.md/route.ts +++ b/web/app/u/[handle]/agent.md/route.ts @@ -37,5 +37,6 @@ export async function GET(_request: Request, { params }: RouteContext) { conversationId: newConversationId(), baseUrl: agentChatUrlForHandle(profile.handle), agentName: profile.displayName, + agentHandle: profile.handle, }), { headers: MARKDOWN_HEADERS }); } diff --git a/web/app/u/[handle]/page.tsx b/web/app/u/[handle]/page.tsx index f2c89daa..b1165389 100644 --- a/web/app/u/[handle]/page.tsx +++ b/web/app/u/[handle]/page.tsx @@ -77,6 +77,7 @@ export default async function RegisteredAgentPage({ params }: PageProps) { initialConversationId={newConversationId()} baseUrl={agentChatUrlForHandle(profile.handle)} agentName={profile.displayName} + agentHandle={profile.handle} /> ) : (

diff --git a/web/components/AgentChatSnippet.tsx b/web/components/AgentChatSnippet.tsx index 27a78eb5..49a2a1b2 100644 --- a/web/components/AgentChatSnippet.tsx +++ b/web/components/AgentChatSnippet.tsx @@ -11,15 +11,17 @@ export function AgentChatSnippet({ initialConversationId, baseUrl, agentName, + agentHandle, }: { initialConversationId: string; baseUrl?: string; agentName?: string; + agentHandle?: string; }) { const [conversationId, setConversationId] = useState(initialConversationId); const [status, setStatus] = useState<'' | 'copied' | 'copy-failed' | 'new'>(''); - const snippet = agentChatSnippet({ conversationId, baseUrl, agentName }); + const snippet = agentChatSnippet({ conversationId, baseUrl, agentName, agentHandle }); async function handleCopy() { try { diff --git a/web/lib/agent-chat-snippet.ts b/web/lib/agent-chat-snippet.ts index c161f22c..84b9f901 100644 --- a/web/lib/agent-chat-snippet.ts +++ b/web/lib/agent-chat-snippet.ts @@ -35,16 +35,18 @@ export function agentChatSnippet({ conversationId, baseUrl = AGENT_CHAT_URL, bridgeCommand = AGENT_CHAT_BRIDGE_COMMAND, - agentName = 'Agent Relay', + agentName, + agentHandle, }: { conversationId: string; baseUrl?: string; bridgeCommand?: string; agentName?: string; + agentHandle?: string; }): string { const url = `${baseUrl.replace(/\/$/, '')}/${conversationId}`; - const normalizedAgentName = agentName.replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; - const isAgentRelay = normalizedAgentName === 'Agent Relay'; + const normalizedAgentName = (agentName ?? 'Agent Relay').replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; + const isAgentRelay = agentHandle ? agentHandle.toLowerCase() === 'agent-relay' : agentName === undefined; const agentLabel = isAgentRelay ? 'the Agent Relay agent' : `the registered agent ${JSON.stringify(normalizedAgentName)}`; @@ -97,14 +99,16 @@ export function agentChatSnippet({ export function agentChatAgentGuide({ conversationId, baseUrl = AGENT_CHAT_URL, - agentName = 'Agent Relay', + agentName, + agentHandle, }: { conversationId: string; baseUrl?: string; agentName?: string; + agentHandle?: string; }): string { - const normalizedAgentName = agentName.replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; - const isAgentRelay = normalizedAgentName === 'Agent Relay'; + const normalizedAgentName = (agentName ?? 'Agent Relay').replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; + const isAgentRelay = agentHandle ? agentHandle.toLowerCase() === 'agent-relay' : agentName === undefined; return [ `# Chat with ${isAgentRelay ? 'the Agent Relay agent' : normalizedAgentName}`, '', @@ -112,7 +116,7 @@ export function agentChatAgentGuide({ ? "Your human pointed you here to talk with Agent Relay's agent. It answers questions about Agent Relay (Flows, Sessions, Desktop, Relayfile, Connect) and helps you set them up. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again." : `Your human pointed you here to talk with the verified agent ${JSON.stringify(normalizedAgentName)}. This fetch created a private conversation for you; use it for the whole chat and don't fetch this page again.`, '', - agentChatSnippet({ conversationId, baseUrl, agentName: normalizedAgentName }), + agentChatSnippet({ conversationId, baseUrl, agentName: normalizedAgentName, agentHandle }), '', ].join('\n'); } diff --git a/web/lib/test/agent-chat-snippet.test.ts b/web/lib/test/agent-chat-snippet.test.ts index 44205fa0..576015f3 100644 --- a/web/lib/test/agent-chat-snippet.test.ts +++ b/web/lib/test/agent-chat-snippet.test.ts @@ -119,6 +119,26 @@ describe('agent chat snippet', () => { })).toContain('Chat with the registered agent "Acme Support" for me.'); }); + it('does not grant official identity from a registered display name', () => { + const snippet = agentChatSnippet({ + conversationId: 'abc123', + bridgeCommand: '', + agentName: 'Agent Relay', + agentHandle: 'acme-support', + }); + expect(snippet).toContain('Chat with the registered agent "Agent Relay" for me.'); + expect(snippet).not.toContain('active workspace'); + + const guide = agentChatAgentGuide({ + conversationId: 'abc123', + agentName: 'Agent Relay', + agentHandle: 'acme-support', + }); + expect(guide).toMatch(/^# Chat with Agent Relay/); + expect(guide).toContain('the verified agent "Agent Relay"'); + expect(guide).not.toContain("Agent Relay's agent"); + }); + it('keeps handle chats on the configured conversation origin', () => { expect(agentChatUrlForHandle('acme-support', 'https://staging.example/prefix/agent-relay/')) .toBe('https://staging.example/prefix/acme-support'); From 156e67132ea16ddad428e9944b58401b34c2612d Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 20:06:58 -0700 Subject: [PATCH 4/4] Sanitize suspended agent guide names Session-Id: 01a1176f-b97d-7942-a94a-61f5b8b38013 --- web/app/u/[handle]/agent.md/route.ts | 3 ++- web/lib/agent-chat-snippet.ts | 8 ++++++-- web/lib/test/registered-agent-guide-route.test.ts | 7 +++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/web/app/u/[handle]/agent.md/route.ts b/web/app/u/[handle]/agent.md/route.ts index 9ebe9267..6d41f66e 100644 --- a/web/app/u/[handle]/agent.md/route.ts +++ b/web/app/u/[handle]/agent.md/route.ts @@ -3,6 +3,7 @@ import { agentChatAgentGuide, agentChatUrlForHandle, newConversationId, + normalizeAgentName, } from '../../../../lib/agent-chat-snippet'; export const dynamic = 'force-dynamic'; @@ -29,7 +30,7 @@ export async function GET(_request: Request, { params }: RouteContext) { } if (profile.status !== 'active') { return new Response( - `# ${profile.displayName}\n\nThis agent is temporarily unavailable because its domain verification is no longer current.\n`, + `# ${normalizeAgentName(profile.displayName)}\n\nThis agent is temporarily unavailable because its domain verification is no longer current.\n`, { status: 410, headers: MARKDOWN_HEADERS }, ); } diff --git a/web/lib/agent-chat-snippet.ts b/web/lib/agent-chat-snippet.ts index 84b9f901..139c6673 100644 --- a/web/lib/agent-chat-snippet.ts +++ b/web/lib/agent-chat-snippet.ts @@ -31,6 +31,10 @@ export function newConversationId(): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } +export function normalizeAgentName(agentName: string | undefined): string { + return (agentName ?? 'Agent Relay').replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; +} + export function agentChatSnippet({ conversationId, baseUrl = AGENT_CHAT_URL, @@ -45,7 +49,7 @@ export function agentChatSnippet({ agentHandle?: string; }): string { const url = `${baseUrl.replace(/\/$/, '')}/${conversationId}`; - const normalizedAgentName = (agentName ?? 'Agent Relay').replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; + const normalizedAgentName = normalizeAgentName(agentName); const isAgentRelay = agentHandle ? agentHandle.toLowerCase() === 'agent-relay' : agentName === undefined; const agentLabel = isAgentRelay ? 'the Agent Relay agent' @@ -107,7 +111,7 @@ export function agentChatAgentGuide({ agentName?: string; agentHandle?: string; }): string { - const normalizedAgentName = (agentName ?? 'Agent Relay').replace(/[\p{C}\s]+/gu, ' ').trim() || 'registered agent'; + const normalizedAgentName = normalizeAgentName(agentName); const isAgentRelay = agentHandle ? agentHandle.toLowerCase() === 'agent-relay' : agentName === undefined; return [ `# Chat with ${isAgentRelay ? 'the Agent Relay agent' : normalizedAgentName}`, diff --git a/web/lib/test/registered-agent-guide-route.test.ts b/web/lib/test/registered-agent-guide-route.test.ts index e33d4442..37a43c22 100644 --- a/web/lib/test/registered-agent-guide-route.test.ts +++ b/web/lib/test/registered-agent-guide-route.test.ts @@ -31,7 +31,7 @@ describe('registered agent markdown guide', () => { it('does not mint a conversation for a suspended handle', async () => { vi.stubGlobal('fetch', vi.fn(async () => Response.json({ handle: 'paused-agent', - displayName: 'Paused Agent', + displayName: 'Paused Agent\n\n# Injected heading', description: 'Temporarily unavailable.', verifiedDomain: 'paused.example', verifiedAt: '2026-10-07T12:00:00.000Z', @@ -42,6 +42,9 @@ describe('registered agent markdown guide', () => { params: Promise.resolve({ handle: 'paused-agent' }), }); expect(response.status).toBe(410); - expect(await response.text()).not.toContain('curl '); + const guide = await response.text(); + expect(guide).toMatch(/^# Paused Agent # Injected heading\n\n/); + expect(guide).not.toContain('\n# Injected heading'); + expect(guide).not.toContain('curl '); }); });