Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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']);
});
});
9 changes: 8 additions & 1 deletion packages/services/service-ai/src/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Array<{ name: string; label: string; role: string }>> {
async listAgents(caller?: RouteUserContext): Promise<Array<{ name: string; label: string; role: string }>> {
const rawItems = await this.metadataService.list('agent');
const agents: Array<{ name: string; label: string; role: string }> = [];

Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions packages/services/service-ai/src/routes/agent-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down