fix(agents): follow public profile discovery contract

Discover Realmroot Agent profiles from OAuth authorization-server metadata, validate the public profile response, and link resolved audit actors to their public profile pages.
This commit is contained in:
saltbo
2026-08-08 12:56:51 -04:00
parent 600486b8ab
commit 6f4182bb28
8 changed files with 227 additions and 57 deletions
+7 -6
View File
@@ -4109,12 +4109,13 @@ type ActivityPage struct {
// ActorAttribution defines model for ActorAttribution.
type ActorAttribution struct {
Image *string `json:"image"`
Issuer *string `json:"issuer"`
Name string `json:"name"`
Ref *string `json:"ref"`
Resolved bool `json:"resolved"`
Type ActorAttributionType `json:"type"`
Image *string `json:"image"`
Issuer *string `json:"issuer"`
Name string `json:"name"`
ProfileUrl *string `json:"profileUrl,omitempty"`
Ref *string `json:"ref"`
Resolved bool `json:"resolved"`
Type ActorAttributionType `json:"type"`
}
// ActorAttributionType defines model for ActorAttribution.Type.
+5
View File
@@ -171,6 +171,7 @@ test.describe('File table responsive columns', () => {
issuer: actorIssuer,
name: actorName,
image: null,
profileUrl: 'https://identity.example.com/agents/agent-0123456789abcdef0123456789abcdef',
resolved: true,
}
}
@@ -182,6 +183,10 @@ test.describe('File table responsive columns', () => {
await row.getByRole('button', { name: `Created by: ${actorName}` }).hover()
const card = page.locator('[data-slot="hover-card-content"]')
await expect(card).toBeVisible()
await expect(card.getByRole('link')).toHaveAttribute(
'href',
'https://identity.example.com/agents/agent-0123456789abcdef0123456789abcdef',
)
const contained = await card.evaluate((element) => {
const cardRect = element.getBoundingClientRect()
@@ -9,32 +9,38 @@ afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))))
})
describe('Agent Info gateway', () => {
describe('Agent profile gateway', () => {
it('discovers and caches an Agent profile for a trusted issuer', async () => {
let discoveryRequests = 0
let agentInfoRequests = 0
let profileRequests = 0
const redirects: RequestRedirect[] = []
const { origin } = await listen((request, response) => {
if (request.url === '/api/auth/.well-known/openid-configuration') {
if (request.url === '/.well-known/oauth-authorization-server/api/auth') {
discoveryRequests += 1
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: `${origin}/api/auth/agentinfo` }),
JSON.stringify({
issuer: `${origin}/api/auth`,
agent_profile_uri_template: `${origin}/api/public/agents/{subject}`,
}),
)
return
}
if (request.url?.startsWith('/api/auth/agentinfo?')) {
agentInfoRequests += 1
const subject = new URL(request.url, origin).searchParams.get('sub')
if (request.url?.startsWith('/api/public/agents/')) {
profileRequests += 1
const subject = decodeURIComponent(request.url.slice('/api/public/agents/'.length))
response.setHeader('content-type', 'application/json')
response.setHeader('cache-control', 'public, max-age=300')
response.end(
JSON.stringify({
iss: `${origin}/api/auth`,
sub: subject,
type: 'agent',
view: 'summary',
issuer: `${origin}/api/auth`,
subject,
name: subject === 'agt_1' ? 'Mac Agent' : 'Second Agent',
picture: `${origin}/agent.svg`,
updated_at: 1,
createdAt: '2026-08-08T12:00:00.000Z',
updatedAt: '2026-08-08T12:00:00.000Z',
}),
)
return
@@ -56,11 +62,12 @@ describe('Agent Info gateway', () => {
expect(first.get(auditActorIdentityKey(identity))).toEqual({
name: 'Mac Agent',
image: `${origin}/agent.svg`,
profileUrl: `${origin}/agents/agt_1`,
resolved: true,
})
expect(second).toEqual(first)
expect(discoveryRequests).toBe(1)
expect(agentInfoRequests).toBe(2)
expect(profileRequests).toBe(2)
expect(redirects).toEqual(['manual', 'manual', 'manual'])
})
@@ -79,16 +86,68 @@ describe('Agent Info gateway', () => {
expect(requests).toBe(0)
})
it('rejects an Agent Info response for a different subject', async () => {
it('URL-encodes the verified Agent subject into the discovered profile template', async () => {
const { origin } = await listen((request, response) => {
response.setHeader('content-type', 'application/json')
if (request.url === '/api/auth/.well-known/openid-configuration') {
if (request.url === '/.well-known/oauth-authorization-server/api/auth') {
response.end(
JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: `${origin}/api/auth/agentinfo` }),
JSON.stringify({
issuer: `${origin}/api/auth`,
agent_profile_uri_template: `${origin}/api/public/agents/{subject}`,
}),
)
return
}
response.end(JSON.stringify({ iss: `${origin}/api/auth`, sub: 'agt_other', name: 'Wrong Agent' }))
if (request.url !== '/api/public/agents/agt_encoded%2Fvalue') {
response.statusCode = 404
response.end()
return
}
response.end(
JSON.stringify({
type: 'agent',
view: 'summary',
issuer: `${origin}/api/auth`,
subject: 'agt_encoded/value',
name: 'Encoded Agent',
picture: `${origin}/agent.svg`,
createdAt: '2026-08-08T12:00:00.000Z',
updatedAt: '2026-08-08T12:00:00.000Z',
}),
)
})
const gateway = createAgentInfoGateway()
const identity = { type: 'agent', ref: 'agt_encoded/value', issuer: `${origin}/api/auth` } as const
const profiles = await gateway.resolve([identity], new Set([origin]))
expect(profiles.get(auditActorIdentityKey(identity))?.name).toBe('Encoded Agent')
})
it('rejects an Agent profile response for a different subject', async () => {
const { origin } = await listen((request, response) => {
response.setHeader('content-type', 'application/json')
if (request.url === '/.well-known/oauth-authorization-server/api/auth') {
response.end(
JSON.stringify({
issuer: `${origin}/api/auth`,
agent_profile_uri_template: `${origin}/api/public/agents/{subject}`,
}),
)
return
}
response.end(
JSON.stringify({
type: 'agent',
view: 'summary',
issuer: `${origin}/api/auth`,
subject: 'agt_other',
name: 'Wrong Agent',
picture: `${origin}/agent.svg`,
createdAt: '2026-08-08T12:00:00.000Z',
updatedAt: '2026-08-08T12:00:00.000Z',
}),
)
})
const gateway = createAgentInfoGateway()
const identity = { type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` } as const
@@ -98,6 +157,41 @@ describe('Agent Info gateway', () => {
expect(profiles.size).toBe(0)
})
it('rejects an Agent profile response for a different issuer', async () => {
const { origin } = await listen((request, response) => {
response.setHeader('content-type', 'application/json')
if (request.url === '/.well-known/oauth-authorization-server/api/auth') {
response.end(
JSON.stringify({
issuer: `${origin}/api/auth`,
agent_profile_uri_template: `${origin}/api/public/agents/{subject}`,
}),
)
return
}
response.end(
JSON.stringify({
type: 'agent',
view: 'summary',
issuer: 'https://other.example/api/auth',
subject: 'agt_1',
name: 'Wrong Issuer Agent',
picture: `${origin}/agent.svg`,
createdAt: '2026-08-08T12:00:00.000Z',
updatedAt: '2026-08-08T12:00:00.000Z',
}),
)
})
const gateway = createAgentInfoGateway()
const profiles = await gateway.resolve(
[{ type: 'oauth', ref: 'agt_1', issuer: `${origin}/api/auth` }],
new Set([origin]),
)
expect(profiles.size).toBe(0)
})
it('rejects invalid discovery documents and ignores incomplete actor identities', async () => {
let requests = 0
const { origin } = await listen((_request, response) => {
@@ -121,11 +215,34 @@ describe('Agent Info gateway', () => {
expect(requests).toBe(1)
})
it('rejects Agent Info endpoints on a different origin', async () => {
it('rejects Agent profile templates on a different origin', async () => {
const { origin } = await listen((_request, response) => {
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({ issuer: `${origin}/api/auth`, agentinfo_endpoint: 'https://untrusted.example/agentinfo' }),
JSON.stringify({
issuer: `${origin}/api/auth`,
agent_profile_uri_template: 'https://untrusted.example/agents/{subject}',
}),
)
})
const gateway = createAgentInfoGateway()
const profiles = await gateway.resolve(
[{ type: 'agent', ref: 'agt-1', issuer: `${origin}/api/auth` }],
new Set([origin]),
)
expect(profiles.size).toBe(0)
})
it('rejects profile templates without exactly one subject expression', async () => {
const { origin } = await listen((_request, response) => {
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({
issuer: `${origin}/api/auth`,
agent_profile_uri_template: `${origin}/api/public/agents/static`,
}),
)
})
const gateway = createAgentInfoGateway()
+36 -20
View File
@@ -14,15 +14,18 @@ const MAX_CONCURRENT_REQUESTS = 8
const discoverySchema = z.object({
issuer: z.string().url(),
agentinfo_endpoint: z.string().url(),
agent_profile_uri_template: z.string().min(1),
})
const agentInfoSchema = z.object({
iss: z.string().url(),
sub: z.string().min(1),
const agentProfileSchema = z.object({
type: z.literal('agent'),
view: z.literal('summary'),
issuer: z.string().url(),
subject: z.string().min(1),
name: z.string().min(1),
picture: z.string().url().nullable().optional(),
updated_at: z.number().optional(),
picture: z.union([z.string().url(), z.string().regex(/^\/api\/assets\/[A-Za-z0-9_-]+$/)]),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
})
type CacheEntry<T> = { value: T; expiresAt: number }
@@ -79,26 +82,31 @@ async function loadAgentProfile(
discoveryInflight: Map<string, Promise<string | null>>,
): Promise<AuditActorProfile | null> {
try {
const endpoint = await agentInfoEndpoint(request, issuer, discoveryCache, discoveryInflight)
if (!endpoint) return null
const url = new URL(endpoint)
url.searchParams.set('sub', subject)
const template = await agentProfileUriTemplate(request, issuer, discoveryCache, discoveryInflight)
if (!template) return null
const url = expandAgentProfileUriTemplate(template, subject)
if (!url || url.origin !== issuer.origin) return null
const response = await request(url, {
headers: { Accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) return null
const parsed = agentInfoSchema.safeParse(await response.json())
if (!parsed.success || parsed.data.iss !== issuer.href.replace(/\/$/, '') || parsed.data.sub !== subject)
const parsed = agentProfileSchema.safeParse(await response.json())
if (!parsed.success || parsed.data.issuer !== issuer.href.replace(/\/$/, '') || parsed.data.subject !== subject)
return null
return { name: parsed.data.name, image: parsed.data.picture ?? null, resolved: true }
return {
name: parsed.data.name,
image: new URL(parsed.data.picture, url).href,
profileUrl: new URL(`/agents/${encodeURIComponent(subject)}`, url).href,
resolved: true,
}
} catch {
return null
}
}
async function agentInfoEndpoint(
async function agentProfileUriTemplate(
request: typeof fetch,
issuer: URL,
cache: Map<string, CacheEntry<string>>,
@@ -109,7 +117,7 @@ async function agentInfoEndpoint(
if (cached) return cached
const existing = inflight.get(issuerValue)
if (existing) return existing
const requestPromise = loadAgentInfoEndpoint(request, issuer, issuerValue, cache)
const requestPromise = loadAgentProfileUriTemplate(request, issuer, issuerValue, cache)
inflight.set(issuerValue, requestPromise)
try {
return await requestPromise
@@ -118,13 +126,14 @@ async function agentInfoEndpoint(
}
}
async function loadAgentInfoEndpoint(
async function loadAgentProfileUriTemplate(
request: typeof fetch,
issuer: URL,
issuerValue: string,
cache: Map<string, CacheEntry<string>>,
): Promise<string | null> {
const discoveryUrl = new URL(`${issuerValue}/.well-known/openid-configuration`)
const issuerPath = issuer.pathname === '/' ? '' : issuer.pathname.replace(/\/$/, '')
const discoveryUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuer.origin)
const response = await request(discoveryUrl, {
headers: { Accept: 'application/json' },
redirect: 'manual',
@@ -133,10 +142,17 @@ async function loadAgentInfoEndpoint(
if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) return null
const parsed = discoverySchema.safeParse(await response.json())
if (!parsed.success || parsed.data.issuer !== issuerValue) return null
const endpoint = parseSecureUrl(parsed.data.agentinfo_endpoint)
const template = parsed.data.agent_profile_uri_template
const endpoint = expandAgentProfileUriTemplate(template, 'subject')
if (!endpoint || endpoint.origin !== issuer.origin) return null
writeCache(cache, issuerValue, endpoint.href, DISCOVERY_TTL_MS)
return endpoint.href
writeCache(cache, issuerValue, template, DISCOVERY_TTL_MS)
return template
}
function expandAgentProfileUriTemplate(template: string, subject: string): URL | null {
const parts = template.split('{subject}')
if (parts.length !== 2 || parts.some((part) => part.includes('{') || part.includes('}'))) return null
return parseSecureUrl(`${parts[0]}${encodeURIComponent(subject)}${parts[1]}`)
}
function parseSecureUrl(value: string): URL | null {
+1
View File
@@ -84,6 +84,7 @@ export interface ActorIdentity {
export interface ActorProfile {
name: string
image: string | null
profileUrl?: string | null
resolved: boolean
}
+1
View File
@@ -18,6 +18,7 @@ export const actorAttributionSchema = z
issuer: z.string().nullable(),
name: z.string(),
image: z.string().nullable(),
profileUrl: z.string().url().nullable().optional(),
resolved: z.boolean(),
})
.openapi('ActorAttribution')
+5
View File
@@ -17,6 +17,7 @@ describe('ActorIdentity', () => {
issuer: 'https://realm.example.com',
name: 'Research Agent',
image: 'https://example.com/agent.png',
profileUrl: 'https://realm.example.com/agents/agent-1',
resolved: true,
}
@@ -59,6 +60,7 @@ describe('ActorAvatarHoverCard', () => {
issuer: 'https://realm.example.com',
name: 'Research Agent',
image: 'https://example.com/agent.png',
profileUrl: 'https://realm.example.com/agents/agent-1',
resolved: true,
}
@@ -72,6 +74,9 @@ describe('ActorAvatarHoverCard', () => {
expect(screen.getByText('actors.type.oauth')).toBeTruthy()
expect(screen.getByText('agent-1')).toBeTruthy()
expect(screen.getByText('https://realm.example.com')).toBeTruthy()
const profileLink = screen.getByRole('link')
expect(profileLink.getAttribute('href')).toBe('https://realm.example.com/agents/agent-1')
expect(profileLink.getAttribute('target')).toBe('_blank')
})
it('wraps long stable identifiers without hiding the full value', async () => {
+38 -14
View File
@@ -1,6 +1,6 @@
import type { ActorAttribution } from '@shared/schemas'
import type { AuditEvent } from '@shared/types'
import { Bot, CircleHelp, KeyRound, Monitor, UserRound } from 'lucide-react'
import { Bot, CircleHelp, ExternalLink, KeyRound, Monitor, UserRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
@@ -89,19 +89,7 @@ export function ActorAvatarHoverCard({ actor, className }: Pick<ActorIdentityPro
</button>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-64 max-w-[calc(100vw-2rem)] overflow-hidden">
<div className="flex w-full min-w-0 items-center gap-3 overflow-hidden">
<ActorAvatar actor={actor} size="lg" />
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 overflow-hidden">
<span className="block w-full min-w-0 whitespace-normal break-words text-sm font-semibold" data-actor-field>
{actor.name}
</span>
<Badge variant="secondary" className="max-w-full min-w-0">
<span className="min-w-0 truncate" data-actor-field>
{t(`actors.type.${actor.type}`)}
</span>
</Badge>
</div>
</div>
<ActorProfileCardHeader actor={actor} />
{(actor.ref || actor.issuer) && <Separator className="my-3" />}
<dl className="flex min-w-0 flex-col gap-2 overflow-hidden text-xs">
{actor.ref && (
@@ -125,3 +113,39 @@ export function ActorAvatarHoverCard({ actor, className }: Pick<ActorIdentityPro
</HoverCard>
)
}
function ActorProfileCardHeader({ actor }: { actor: ActorAttribution }) {
const { t } = useTranslation()
const content = (
<>
<div className="flex min-w-0 flex-1 items-center gap-3 overflow-hidden">
<ActorAvatar actor={actor} size="lg" />
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 overflow-hidden">
<span className="block w-full min-w-0 whitespace-normal break-words text-sm font-semibold" data-actor-field>
{actor.name}
</span>
<Badge variant="secondary" className="max-w-full min-w-0">
<span className="min-w-0 truncate" data-actor-field>
{t(`actors.type.${actor.type}`)}
</span>
</Badge>
</div>
</div>
{actor.profileUrl && <ExternalLink aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />}
</>
)
if (!actor.profileUrl) return <div className="flex w-full min-w-0 items-center gap-3 overflow-hidden">{content}</div>
return (
<a
href={actor.profileUrl}
target="_blank"
rel="noreferrer"
className="flex w-full min-w-0 items-center gap-3 overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => event.stopPropagation()}
>
{content}
</a>
)
}