Skip to content

Commit 75abedc

Browse files
os-zhuangclaude
andauthored
feat(service-ai): access-aware agent catalog so the UI hides agents you can't chat (#2320)
GET /api/v1/ai/agents (and listAgents) returned EVERY active agent regardless of the caller's per-agent access (`permissions`/`access`). The console keys its "Build with AI" / "Ask AI" surfaces off this catalog, so a user without the required permission still saw the buttons and only hit a 403 on click (dead-end UX) — notably with the per-user AI-seat gate (ADR-0068), where build/ask carry `permissions:['ai_seat']`. listAgents now takes an optional caller context and, when present, drops agents the caller can't access (same evaluateAgentAccess the chat route enforces, ADR- 0049). The list route passes req.user. No caller (internal default-agent resolution) → unfiltered, so existing behavior is unchanged. Ungated agents (no `permissions`) stay visible to everyone, so this is a no-op until an agent is actually gated — e.g. the AI-seat kill-switch is off. Net effect: the existing frontend "no build/ask in catalog → no AI surface" logic now hides AI for seat-less users with ZERO objectui changes; the signal is the catalog itself. +5 unit tests (no-caller unfiltered / seat-less hidden / seated-by-permission / seated-by-role / ungated-visible). service-ai suite green (408). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 60d48a0 commit 75abedc

3 files changed

Lines changed: 107 additions & 3 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// listAgents() access-aware catalog (ADR-0049 / ADR-0068).
4+
//
5+
// The runtime catalog (GET /api/v1/ai/agents) is what the console keys off to
6+
// decide whether to SHOW the in-UI AI surfaces ("Build with AI" / "Ask AI").
7+
// When a caller context is supplied, listAgents() hides agents the caller
8+
// cannot chat (per-agent `permissions`/`access` — e.g. the per-user AI-seat
9+
// gate), so a seat-less user never sees a button that would 403 on click. No
10+
// caller (internal default-agent resolution) -> unfiltered (unchanged).
11+
12+
import { describe, it, expect } from 'vitest';
13+
import type { Agent } from '@objectstack/spec/ai';
14+
import type { IMetadataService } from '@objectstack/spec/contracts';
15+
import { AgentRuntime } from '../agent-runtime.js';
16+
import { SkillRegistry } from '../skill-registry.js';
17+
import { registerAgentAlias } from '../agents/agent-aliases.js';
18+
19+
// Make `build`/`ask` recognised platform agents (mirrors the cloud plugin init).
20+
registerAgentAlias('metadata_assistant', 'build');
21+
22+
/** A platform agent gated behind the `ai_seat` permission. */
23+
const gated = (name: string, surface: 'build' | 'ask'): Agent =>
24+
({
25+
name,
26+
label: name,
27+
role: 'r',
28+
surface,
29+
instructions: 'x',
30+
model: { provider: 'openai', model: 'gpt-4', temperature: 0.2, maxTokens: 4096 },
31+
skills: [],
32+
active: true,
33+
visibility: 'global',
34+
permissions: ['ai_seat'], // the per-user gate
35+
_provenance: 'package', // intrinsic platform-agent signal (isPlatformAgentRecord)
36+
}) as any;
37+
38+
function runtimeListing(agents: Agent[]): AgentRuntime {
39+
const md = {
40+
list: async () => agents,
41+
get: async () => undefined,
42+
exists: async () => false,
43+
register: async () => {},
44+
unregister: async () => {},
45+
} as unknown as IMetadataService;
46+
return new AgentRuntime(md, new SkillRegistry(md));
47+
}
48+
49+
describe('listAgents() — access-aware catalog (ADR-0068)', () => {
50+
const agents = [gated('build', 'build'), gated('ask', 'ask')];
51+
52+
it('no caller -> unfiltered (internal/default-agent resolution unchanged)', async () => {
53+
const out = await runtimeListing(agents).listAgents();
54+
expect(out.map((a) => a.name).sort()).toEqual(['ask', 'build']);
55+
});
56+
57+
it('seat-less caller -> gated agents hidden (console renders no AI surface)', async () => {
58+
const out = await runtimeListing(agents).listAgents({
59+
userId: 'u',
60+
permissions: [],
61+
roles: [],
62+
} as any);
63+
expect(out).toEqual([]);
64+
});
65+
66+
it('seated caller (holds the ai_seat permission-set) -> gated agents visible', async () => {
67+
const out = await runtimeListing(agents).listAgents({
68+
userId: 'u',
69+
permissions: ['ai_seat'],
70+
roles: [],
71+
} as any);
72+
expect(out.map((a) => a.name).sort()).toEqual(['ask', 'build']);
73+
});
74+
75+
it('seat granted via a ROLE also unlocks (roles union permissions)', async () => {
76+
const out = await runtimeListing(agents).listAgents({
77+
userId: 'u',
78+
permissions: [],
79+
roles: ['ai_seat'],
80+
} as any);
81+
expect(out.map((a) => a.name).sort()).toEqual(['ask', 'build']);
82+
});
83+
84+
it('ungated agents (no permissions, e.g. kill-switch off) stay visible to everyone', async () => {
85+
const ungated = [{ ...gated('build', 'build'), permissions: undefined }] as any;
86+
const out = await runtimeListing(ungated).listAgents({
87+
userId: 'u',
88+
permissions: [],
89+
roles: [],
90+
} as any);
91+
expect(out.map((a: { name: string }) => a.name)).toEqual(['build']);
92+
});
93+
});

packages/services/service-ai/src/agent-runtime.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { SkillRegistry, type SkillContext } from './skill-registry.js';
1212
import { SchemaRetriever, type ObjectShape } from './schema-retriever.js';
1313
import { ASK_AGENT_NAME } from './agents/index.js';
1414
import { resolveAgentAlias, platformAgentNames } from './agents/agent-aliases.js';
15+
import { evaluateAgentAccess } from './routes/agent-access.js';
16+
import type { RouteUserContext } from './routes/ai-routes.js';
1517

1618
/**
1719
* True when an agent record is platform-owned and therefore belongs in the
@@ -107,7 +109,7 @@ export class AgentRuntime {
107109
* Returns a summary for each agent (name, label, role) suitable
108110
* for populating an agent selector dropdown in the UI.
109111
*/
110-
async listAgents(): Promise<Array<{ name: string; label: string; role: string }>> {
112+
async listAgents(caller?: RouteUserContext): Promise<Array<{ name: string; label: string; role: string }>> {
111113
const rawItems = await this.metadataService.list('agent');
112114
const agents: Array<{ name: string; label: string; role: string }> = [];
113115

@@ -126,6 +128,11 @@ export class AgentRuntime {
126128
const result = AgentSchema.safeParse(raw);
127129
if (!result.success || !result.data.active) continue;
128130
if (!isPlatformAgentRecord(raw, result.data.name, platform)) continue;
131+
// ADR-0049 / ADR-0068 — when a caller context is supplied, hide agents
132+
// they cannot access (per-agent `permissions`/`access`, e.g. the per-user
133+
// AI-seat gate) so the UI never lists an agent that would 403 on chat.
134+
// No caller (internal default-agent resolution) → unfiltered (unchanged).
135+
if (caller && !evaluateAgentAccess(result.data, caller).allowed) continue;
129136
agents.push({
130137
name: result.data.name,
131138
label: result.data.label,

packages/services/service-ai/src/routes/agent-routes.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,13 @@ export function buildAgentRoutes(
115115
description: 'List all active AI agents',
116116
auth: true,
117117
permissions: ['ai:chat'],
118-
handler: async () => {
118+
handler: async (req) => {
119119
try {
120-
const agents = await agentRuntime.listAgents();
120+
// Access-aware catalog: the caller only sees agents they may chat
121+
// (so the UI hides AI surfaces for seat-less users instead of
122+
// letting them click into a 403). req.user carries the resolved
123+
// permission-set names + roles (ADR-0049).
124+
const agents = await agentRuntime.listAgents(req.user);
121125
return { status: 200, body: { agents } };
122126
} catch (err) {
123127
logger.error(

0 commit comments

Comments
 (0)