mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface ActorIdentity {
|
||||
export interface ActorProfile {
|
||||
name: string
|
||||
image: string | null
|
||||
profileUrl?: string | null
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user