From 6f4182bb28d89a6b592ced2547ad84edce4098b0 Mon Sep 17 00:00:00 2001 From: saltbo Date: Sat, 8 Aug 2026 12:56:51 -0400 Subject: [PATCH] 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. --- cmd/internal/openapi/client.gen.go | 13 +- e2e/responsive.spec.ts | 5 + .../gateways/agent-info.integration.test.ts | 151 ++++++++++++++++-- server/adapters/gateways/agent-info.ts | 56 ++++--- server/usecases/ports/audit.ts | 1 + shared/schemas/actors.ts | 1 + src/components/actor-identity.test.tsx | 5 + src/components/actor-identity.tsx | 52 ++++-- 8 files changed, 227 insertions(+), 57 deletions(-) diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index a357a521..9d6d0146 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -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. diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index 3e1d149c..8017124b 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -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() diff --git a/server/adapters/gateways/agent-info.integration.test.ts b/server/adapters/gateways/agent-info.integration.test.ts index 88b8a2d4..1f103d32 100644 --- a/server/adapters/gateways/agent-info.integration.test.ts +++ b/server/adapters/gateways/agent-info.integration.test.ts @@ -9,32 +9,38 @@ afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((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() diff --git a/server/adapters/gateways/agent-info.ts b/server/adapters/gateways/agent-info.ts index 6d4b002d..733e114d 100644 --- a/server/adapters/gateways/agent-info.ts +++ b/server/adapters/gateways/agent-info.ts @@ -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 = { value: T; expiresAt: number } @@ -79,26 +82,31 @@ async function loadAgentProfile( discoveryInflight: Map>, ): Promise { 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>, @@ -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>, ): Promise { - 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 { diff --git a/server/usecases/ports/audit.ts b/server/usecases/ports/audit.ts index fb2635fd..b6eec5ae 100644 --- a/server/usecases/ports/audit.ts +++ b/server/usecases/ports/audit.ts @@ -84,6 +84,7 @@ export interface ActorIdentity { export interface ActorProfile { name: string image: string | null + profileUrl?: string | null resolved: boolean } diff --git a/shared/schemas/actors.ts b/shared/schemas/actors.ts index 21959b89..220ab00d 100644 --- a/shared/schemas/actors.ts +++ b/shared/schemas/actors.ts @@ -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') diff --git a/src/components/actor-identity.test.tsx b/src/components/actor-identity.test.tsx index 9a6d1e79..3208f1a1 100644 --- a/src/components/actor-identity.test.tsx +++ b/src/components/actor-identity.test.tsx @@ -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 () => { diff --git a/src/components/actor-identity.tsx b/src/components/actor-identity.tsx index cf616ce1..2a725c97 100644 --- a/src/components/actor-identity.tsx +++ b/src/components/actor-identity.tsx @@ -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 -
- -
- - {actor.name} - - - - {t(`actors.type.${actor.type}`)} - - -
-
+ {(actor.ref || actor.issuer) && }
{actor.ref && ( @@ -125,3 +113,39 @@ export function ActorAvatarHoverCard({ actor, className }: Pick ) } + +function ActorProfileCardHeader({ actor }: { actor: ActorAttribution }) { + const { t } = useTranslation() + const content = ( + <> +
+ +
+ + {actor.name} + + + + {t(`actors.type.${actor.type}`)} + + +
+
+ {actor.profileUrl &&