diff --git a/packages/services/service-ai/src/__tests__/agent-list-access.test.ts b/packages/services/service-ai/src/__tests__/agent-list-access.test.ts new file mode 100644 index 0000000000..264c47891d --- /dev/null +++ b/packages/services/service-ai/src/__tests__/agent-list-access.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// listAgents() access-aware catalog (ADR-0049 / ADR-0068). +// +// The runtime catalog (GET /api/v1/ai/agents) is what the console keys off to +// decide whether to SHOW the in-UI AI surfaces ("Build with AI" / "Ask AI"). +// When a caller context is supplied, listAgents() hides agents the caller +// cannot chat (per-agent `permissions`/`access` — e.g. the per-user AI-seat +// gate), so a seat-less user never sees a button that would 403 on click. No +// caller (internal default-agent resolution) -> unfiltered (unchanged). + +import { describe, it, expect } from 'vitest'; +import type { Agent } from '@objectstack/spec/ai'; +import type { IMetadataService } from '@objectstack/spec/contracts'; +import { AgentRuntime } from '../agent-runtime.js'; +import { SkillRegistry } from '../skill-registry.js'; +import { registerAgentAlias } from '../agents/agent-aliases.js'; + +// Make `build`/`ask` recognised platform agents (mirrors the cloud plugin init). +registerAgentAlias('metadata_assistant', 'build'); + +/** A platform agent gated behind the `ai_seat` permission. */ +const gated = (name: string, surface: 'build' | 'ask'): Agent => + ({ + name, + label: name, + role: 'r', + surface, + instructions: 'x', + model: { provider: 'openai', model: 'gpt-4', temperature: 0.2, maxTokens: 4096 }, + skills: [], + active: true, + visibility: 'global', + permissions: ['ai_seat'], // the per-user gate + _provenance: 'package', // intrinsic platform-agent signal (isPlatformAgentRecord) + }) as any; + +function runtimeListing(agents: Agent[]): AgentRuntime { + const md = { + list: async () => agents, + get: async () => undefined, + exists: async () => false, + register: async () => {}, + unregister: async () => {}, + } as unknown as IMetadataService; + return new AgentRuntime(md, new SkillRegistry(md)); +} + +describe('listAgents() — access-aware catalog (ADR-0068)', () => { + const agents = [gated('build', 'build'), gated('ask', 'ask')]; + + it('no caller -> unfiltered (internal/default-agent resolution unchanged)', async () => { + const out = await runtimeListing(agents).listAgents(); + expect(out.map((a) => a.name).sort()).toEqual(['ask', 'build']); + }); + + it('seat-less caller -> gated agents hidden (console renders no AI surface)', async () => { + const out = await runtimeListing(agents).listAgents({ + userId: 'u', + permissions: [], + roles: [], + } as any); + expect(out).toEqual([]); + }); + + it('seated caller (holds the ai_seat permission-set) -> gated agents visible', async () => { + const out = await runtimeListing(agents).listAgents({ + userId: 'u', + permissions: ['ai_seat'], + roles: [], + } as any); + expect(out.map((a) => a.name).sort()).toEqual(['ask', 'build']); + }); + + it('seat granted via a ROLE also unlocks (roles union permissions)', async () => { + const out = await runtimeListing(agents).listAgents({ + userId: 'u', + permissions: [], + roles: ['ai_seat'], + } as any); + expect(out.map((a) => a.name).sort()).toEqual(['ask', 'build']); + }); + + it('ungated agents (no permissions, e.g. kill-switch off) stay visible to everyone', async () => { + const ungated = [{ ...gated('build', 'build'), permissions: undefined }] as any; + const out = await runtimeListing(ungated).listAgents({ + userId: 'u', + permissions: [], + roles: [], + } as any); + expect(out.map((a: { name: string }) => a.name)).toEqual(['build']); + }); +}); diff --git a/packages/services/service-ai/src/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index d67a7dcf80..81ad9f3813 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -12,6 +12,8 @@ import { SkillRegistry, type SkillContext } from './skill-registry.js'; import { SchemaRetriever, type ObjectShape } from './schema-retriever.js'; import { ASK_AGENT_NAME } from './agents/index.js'; import { resolveAgentAlias, platformAgentNames } from './agents/agent-aliases.js'; +import { evaluateAgentAccess } from './routes/agent-access.js'; +import type { RouteUserContext } from './routes/ai-routes.js'; /** * True when an agent record is platform-owned and therefore belongs in the @@ -107,7 +109,7 @@ export class AgentRuntime { * Returns a summary for each agent (name, label, role) suitable * for populating an agent selector dropdown in the UI. */ - async listAgents(): Promise> { + async listAgents(caller?: RouteUserContext): Promise> { const rawItems = await this.metadataService.list('agent'); const agents: Array<{ name: string; label: string; role: string }> = []; @@ -126,6 +128,11 @@ export class AgentRuntime { const result = AgentSchema.safeParse(raw); if (!result.success || !result.data.active) continue; if (!isPlatformAgentRecord(raw, result.data.name, platform)) continue; + // ADR-0049 / ADR-0068 — when a caller context is supplied, hide agents + // they cannot access (per-agent `permissions`/`access`, e.g. the per-user + // AI-seat gate) so the UI never lists an agent that would 403 on chat. + // No caller (internal default-agent resolution) → unfiltered (unchanged). + if (caller && !evaluateAgentAccess(result.data, caller).allowed) continue; agents.push({ name: result.data.name, label: result.data.label, diff --git a/packages/services/service-ai/src/routes/agent-routes.ts b/packages/services/service-ai/src/routes/agent-routes.ts index e96758b7f7..ba43636ef8 100644 --- a/packages/services/service-ai/src/routes/agent-routes.ts +++ b/packages/services/service-ai/src/routes/agent-routes.ts @@ -115,9 +115,13 @@ export function buildAgentRoutes( description: 'List all active AI agents', auth: true, permissions: ['ai:chat'], - handler: async () => { + handler: async (req) => { try { - const agents = await agentRuntime.listAgents(); + // Access-aware catalog: the caller only sees agents they may chat + // (so the UI hides AI surfaces for seat-less users instead of + // letting them click into a 403). req.user carries the resolved + // permission-set names + roles (ADR-0049). + const agents = await agentRuntime.listAgents(req.user); return { status: 200, body: { agents } }; } catch (err) { logger.error(