Skip to content

Commit 0dc8e78

Browse files
feat(agentos): CAS API-key capability checks, obs group/actor filters, group-claim fallback
API-key capability enforcement (ComputerAgent server): - introspection now resolves a key's roleIds -> effective permissions (keys-introspect.ts, via exported resolveEffectivePermissions); the CAS verifier captures them and gates each route (GET -> agents:read, execute/mutate -> agents:run), returning 403 on an under-privileged key (e.g. a viewer key hitting /run). Basic-auth (the agentos loopback) stays full-trust since agentos already enforced RBAC + ownership. Back-compat: a response with no `permissions` field (old AgentOS) is allowed and logged once, so a rolling upgrade doesn't lock keys out. Observability group + actor filters: - new Group + Actor dropdowns beside Agent, built on a reusable, RBAC-scoped FieldValueFilter (AgentFilter is now a thin wrapper). Applied to the Traces explorer (implicit group_id/actor_id eq-filters on the filters[] array) and the Dashboard (group/actor params on /dashboard, both ClickHouse + NRQL). - obs-fields bypasses the otel_field_values materialized view for the non-materialized identity fields (group_id/owner_id/actor_id) so admins get values; group_id/owner_id/actor_id registered as query fields front + back. Group-claim fallback + agent card refresh: - when the OIDC access token carries no `groups` claim, fall back to the Keycloak Admin API (listUserGroups / withGroups) so group-scoped RBAC still works. Best-effort; leaves groups empty on failure. - AgentCard shows model + owner-group chips and a live-status pill; registry grid spacing tweaked.
1 parent f49ea09 commit 0dc8e78

18 files changed

Lines changed: 495 additions & 89 deletions

agentos/src/components/AgentCard.tsx

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2, Archive, ArchiveRestore } from "lucide-react";
1+
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2, Archive, ArchiveRestore, Users2, Cpu, ArrowUpRight } from "lucide-react";
22
import { type Agent, displaySource } from "../api.ts";
33
import { Badge } from "./ui/badge.tsx";
44
import { cn } from "../lib/cn.ts";
@@ -19,17 +19,28 @@ function timeAgo(iso: string | null): string {
1919
return `${Math.floor(s / 86400)}d`;
2020
}
2121

22+
/** Compact, human model label: drop a provider prefix ("anthropic:…") and the
23+
* "claude-" vendor prefix so "claude-haiku-4-5" → "haiku-4-5". */
24+
function shortModel(model: string | null): string | null {
25+
if (!model) return null;
26+
const afterProvider = model.includes(":") ? model.slice(model.lastIndexOf(":") + 1) : model;
27+
return afterProvider.replace(/^claude-/, "");
28+
}
29+
2230
/**
23-
* Single agent card for the rail.
31+
* Single agent card for the registry grid (Refined layout).
2432
*
25-
* Two zones with a hairline divider between them:
26-
* Top: avatar (harness logo on white) + name + status pill + LIB/1-shot chip
27-
* source line: owner/repo with kind glyph + clickable external icon
28-
* Bottom: stat row — sessions / logs / lastActivity, each with icon
33+
* Top: avatar (harness logo / gradient initial) + live status dot
34+
* name + quiet badges (lib / 1-shot / archived)
35+
* status pill (Live ×N / Idle) · harness
36+
* chips: model · owner group
37+
* Source: owner/repo with kind glyph + external-link icon
38+
* Footer: sessions / logs / last activity
2939
*
30-
* Click anywhere on the card opens the agent's workspace (chat). The
31-
* external-link icon is the only nested clickable; stopPropagation
32-
* prevents card-click bubbling.
40+
* The whole card is a button that opens the agent's workspace. Nested
41+
* affordances (hover actions, source link) stopPropagation. Hover actions are
42+
* passed in by the parent only when the caller is permitted (RBAC), so absence
43+
* of a handler hides the control.
3344
*/
3445
export function AgentCard({
3546
agent: a,
@@ -57,19 +68,22 @@ export function AgentCard({
5768
const initial = displayName.charAt(0).toUpperCase();
5869
const isLive = a.activeSandboxes > 0;
5970
const sd = displaySource(a.source);
71+
const model = shortModel(a.model);
6072

6173
return (
6274
<button
6375
onClick={onClick}
6476
className={cn(
6577
"group relative w-full text-left rounded-xl border bg-background transition overflow-hidden shadow-sm",
6678
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
67-
a.archived && "opacity-60 saturate-50",
79+
a.archived && "opacity-70 saturate-[0.6]",
6880
selected
6981
? "border-primary/50 ring-1 ring-primary/30 shadow-md shadow-primary/10"
7082
: "border-border/60 hover:border-border hover:bg-muted/30 hover:shadow-md",
7183
)}
7284
>
85+
{/* Hover affordances — top-right. Each is rendered only when the parent
86+
supplies a handler (RBAC-gated). */}
7387
{(onDelete || onArchive || onUnarchive) && (
7488
<span className="absolute right-2 top-2 z-10 flex items-center gap-1">
7589
{onArchive && (
@@ -135,17 +149,17 @@ export function AgentCard({
135149
</span>
136150
)}
137151
<span
138-
title={isLive ? `${a.activeSandboxes} live` : "idle"}
152+
title={isLive ? `${a.activeSandboxes} live sandbox(es)` : "idle"}
139153
className={cn(
140154
"absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-card",
141155
isLive ? "bg-emerald-400" : "bg-muted-foreground/40",
142156
)}
143157
/>
144158
</div>
145159

146-
{/* Name + badges */}
160+
{/* Name + badges + meta */}
147161
<div className="min-w-0 flex-1">
148-
<div className="flex items-center gap-1.5 min-w-0">
162+
<div className="flex items-center gap-1.5 min-w-0 pr-6">
149163
<span className="font-semibold text-[13.5px] text-foreground truncate" title={displayName}>
150164
{displayName}
151165
</span>
@@ -159,15 +173,46 @@ export function AgentCard({
159173
1-shot
160174
</Badge>
161175
)}
162-
{a.archived && (
163-
<Badge variant="outline" className="shrink-0 h-4 text-[9px] uppercase tracking-wider px-1.5 border-muted-foreground/40 text-muted-foreground" title="archived — cannot run until unarchived">
164-
archived
165-
</Badge>
166-
)}
167176
</div>
168-
<div className="mt-0.5 text-[10.5px] text-muted-foreground/80 font-mono truncate" title={a.harness}>
169-
{a.harness}
177+
178+
{/* Status line: live/idle pill · harness */}
179+
<div className="mt-1 flex items-center gap-1.5 min-w-0 text-[11px]">
180+
<span
181+
className={cn(
182+
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-medium",
183+
a.archived
184+
? "bg-muted text-muted-foreground"
185+
: isLive
186+
? "bg-emerald-500/15 text-emerald-400"
187+
: "bg-muted text-muted-foreground",
188+
)}
189+
>
190+
{!a.archived && isLive && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />}
191+
{a.archived ? "Archived" : isLive ? `Live${a.activeSandboxes > 1 ? ` ×${a.activeSandboxes}` : ""}` : "Idle"}
192+
</span>
193+
<span className="text-muted-foreground/50">·</span>
194+
<span className="font-mono text-muted-foreground/80 truncate" title={a.harness}>
195+
{a.harness}
196+
</span>
170197
</div>
198+
199+
{/* Chips: model · owner group */}
200+
{(model || a.ownerGroup) && (
201+
<div className="mt-1.5 flex flex-wrap items-center gap-1">
202+
{model && (
203+
<Badge variant="outline" className="h-4 gap-1 px-1.5 text-[10px] font-mono text-muted-foreground" title={a.model ?? undefined}>
204+
<Cpu className="h-2.5 w-2.5" />
205+
{model}
206+
</Badge>
207+
)}
208+
{a.ownerGroup && (
209+
<Badge variant="outline" className="h-4 gap-1 px-1.5 text-[10px] border-primary/30 text-primary/90" title={`Owner group: ${a.ownerGroup}`}>
210+
<Users2 className="h-2.5 w-2.5" />
211+
{a.ownerGroup}
212+
</Badge>
213+
)}
214+
</div>
215+
)}
171216
</div>
172217
</div>
173218

@@ -199,16 +244,20 @@ export function AgentCard({
199244
<div className="h-px bg-border/60 mx-3" />
200245

201246
{/* FOOTER */}
202-
<div className="px-3 py-1.5 flex items-center gap-2.5 text-[10.5px] text-muted-foreground">
247+
<div className="px-3 py-1.5 flex items-center gap-3 text-[10.5px] text-muted-foreground">
203248
<span className="inline-flex items-center gap-1" title={`${a.sessionCount} session${a.sessionCount === 1 ? "" : "s"}`}>
204-
<MessageSquare className="h-3 w-3" /> <span className="font-mono">{a.sessionCount}</span>
249+
<MessageSquare className="h-3 w-3" /> <span className="font-mono tabular-nums">{a.sessionCount}</span> sessions
205250
</span>
206251
<span className="inline-flex items-center gap-1" title={`${a.logCount} log${a.logCount === 1 ? "" : "s"}`}>
207-
<Activity className="h-3 w-3" /> <span className="font-mono">{a.logCount}</span>
252+
<Activity className="h-3 w-3" /> <span className="font-mono tabular-nums">{a.logCount}</span>
208253
</span>
209254
<span className="ml-auto inline-flex items-center gap-1" title={a.lastActivity ?? a.lastSeen ?? "no activity"}>
210255
<Clock className="h-3 w-3" /> {timeAgo(a.lastActivity ?? a.lastSeen ?? null)}
211256
</span>
257+
{/* "Open" cue — appears on hover so the click target reads as actionable. */}
258+
<span className="inline-flex items-center gap-0.5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden>
259+
<ArrowUpRight className="h-3 w-3" />
260+
</span>
212261
</div>
213262
</button>
214263
);

agentos/src/components/RegistryPage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export function RegistryPage({
167167
{err && <div className="text-xs text-destructive">{err}</div>}
168168

169169
{!loaded && !err && (
170-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
170+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
171171
{Array.from({ length: 8 }).map((_, i) => (
172172
<Skeleton key={i} className="h-32 w-full" />
173173
))}
@@ -192,7 +192,7 @@ export function RegistryPage({
192192
{grouped.hosted.length > 0 && (
193193
<section className="space-y-3">
194194
<SectionLabel name="Hosted" count={grouped.hosted.length} />
195-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
195+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
196196
{grouped.hosted.map((a) => (
197197
<AgentCard
198198
key={a.id}
@@ -210,7 +210,7 @@ export function RegistryPage({
210210
{grouped.library.length > 0 && (
211211
<section className="space-y-3">
212212
<SectionLabel name="Library" count={grouped.library.length} />
213-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
213+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
214214
{grouped.library.map((a) => (
215215
<AgentCard
216216
key={a.id}
@@ -228,7 +228,7 @@ export function RegistryPage({
228228
{grouped.archived.length > 0 && (
229229
<section className="space-y-3">
230230
<SectionLabel name="Archived" count={grouped.archived.length} />
231-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
231+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
232232
{grouped.archived.map((a) => (
233233
<AgentCard
234234
key={a.id}
Lines changed: 12 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
// Agent selector for the Observability views. Sources its options from the
2-
// live `/v1/fields/agent/values` endpoint (FACET on gen_ai.agent.name), so it
3-
// lists exactly the agents that have emitted telemetry. Empty value = all agents.
1+
// Agent selector for the Observability views. Thin wrapper over the generic
2+
// FieldValueFilter, bound to the `agent` field (FACET on gen_ai.agent.name,
3+
// RBAC-scoped server-side). Empty value = all agents.
44

5-
import { X } from "lucide-react";
6-
import { obsApi } from "../../obs-api.ts";
7-
import { Combobox, type ComboOption } from "../ui/combobox.tsx";
8-
import { Button } from "../ui/button.tsx";
9-
10-
const loadAgents = (): Promise<ComboOption[]> =>
11-
obsApi
12-
.fieldValues("agent", 100)
13-
.then((rows) => rows.map((r) => ({ value: r.value, count: r.count })));
5+
import { FieldValueFilter } from "./FieldValueFilter.tsx";
146

157
export function AgentFilter({
168
value,
@@ -20,27 +12,13 @@ export function AgentFilter({
2012
onChange: (v: string) => void;
2113
}) {
2214
return (
23-
<div className="flex items-center gap-1">
24-
<Combobox
25-
value={value}
26-
onValueChange={onChange}
27-
loadOptions={loadAgents}
28-
loadOptionsKey="agent"
29-
placeholder="All agents"
30-
emptyMessage="No agents seen yet."
31-
className="w-[180px]"
32-
/>
33-
{value && (
34-
<Button
35-
variant="ghost"
36-
size="icon"
37-
className="h-8 w-8 text-muted-foreground hover:text-foreground"
38-
onClick={() => onChange("")}
39-
aria-label="Clear agent filter"
40-
>
41-
<X className="h-3.5 w-3.5" />
42-
</Button>
43-
)}
44-
</div>
15+
<FieldValueFilter
16+
field="agent"
17+
value={value}
18+
onChange={onChange}
19+
placeholder="All agents"
20+
emptyMessage="No agents seen yet."
21+
clearLabel="Clear agent filter"
22+
/>
4523
);
4624
}

agentos/src/components/observability/Dashboard.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,34 @@ import {
2424
// reference even when ChartContainer (which uses it internally) is the only call site.
2525
void _RC;
2626

27-
export function Dashboard({ agent, from, to }: { agent?: string; from: string; to: string }) {
27+
export function Dashboard({
28+
agent,
29+
group,
30+
actor,
31+
from,
32+
to,
33+
}: {
34+
agent?: string;
35+
group?: string;
36+
actor?: string;
37+
from: string;
38+
to: string;
39+
}) {
2840
const [data, setData] = useState<DashboardData | null>(null);
2941
const [err, setErr] = useState<string | null>(null);
3042
const [loading, setLoading] = useState(true);
3143

3244
useEffect(() => {
3345
setLoading(true);
3446
obsApi
35-
.dashboard({ agent, from, to })
47+
.dashboard({ agent, group, actor, from, to })
3648
.then((d) => {
3749
setData(d);
3850
setErr(null);
3951
})
4052
.catch((e) => setErr(String(e)))
4153
.finally(() => setLoading(false));
42-
}, [agent, from, to]);
54+
}, [agent, group, actor, from, to]);
4355

4456
if (err) return <div className="p-6 text-sm text-destructive">{err}</div>;
4557
if (loading || !data) {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Generic selector for the Observability views. Sources its options from the
2+
// live `/v1/fields/:field/values` endpoint, which is RBAC-scoped server-side
3+
// (ownerScopeFor) — so the dropdown lists exactly the values the caller may see
4+
// for that field (agent name, owning group, actor, …). Empty value = no filter.
5+
6+
import { X } from "lucide-react";
7+
import { obsApi } from "../../obs-api.ts";
8+
import { Combobox, type ComboOption } from "../ui/combobox.tsx";
9+
import { Button } from "../ui/button.tsx";
10+
11+
export function FieldValueFilter({
12+
field,
13+
value,
14+
onChange,
15+
placeholder,
16+
emptyMessage,
17+
width = "w-[180px]",
18+
clearLabel,
19+
}: {
20+
/** Backend field key, e.g. "agent" | "group_id" | "actor_id". */
21+
field: string;
22+
value: string;
23+
onChange: (v: string) => void;
24+
placeholder: string;
25+
emptyMessage: string;
26+
width?: string;
27+
clearLabel?: string;
28+
}) {
29+
const loadOptions = (): Promise<ComboOption[]> =>
30+
obsApi
31+
.fieldValues(field, 100)
32+
.then((rows) => rows.map((r) => ({ value: r.value, count: r.count })));
33+
34+
return (
35+
<div className="flex items-center gap-1">
36+
<Combobox
37+
value={value}
38+
onValueChange={onChange}
39+
loadOptions={loadOptions}
40+
loadOptionsKey={field}
41+
placeholder={placeholder}
42+
emptyMessage={emptyMessage}
43+
className={width}
44+
/>
45+
{value && (
46+
<Button
47+
variant="ghost"
48+
size="icon"
49+
className="h-8 w-8 text-muted-foreground hover:text-foreground"
50+
onClick={() => onChange("")}
51+
aria-label={clearLabel ?? `Clear ${field} filter`}
52+
>
53+
<X className="h-3.5 w-3.5" />
54+
</Button>
55+
)}
56+
</div>
57+
);
58+
}

0 commit comments

Comments
 (0)