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
19 changes: 9 additions & 10 deletions agentos/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Home as HomeIcon, Activity, Shield, Boxes, FlaskConical } from "lucide-react";
import { Home as HomeIcon, Activity, Shield, Boxes, FlaskConical, Settings as SettingsIcon } from "lucide-react";
import { NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { HomePage } from "./components/HomePage.tsx";
import { PoliciesPage } from "./components/PoliciesPage.tsx";
import { EvalsPage } from "./components/EvalsPage.tsx";
import { ObservabilityTab } from "./components/observability/ObservabilityTab.tsx";
import { RegistryPage } from "./components/RegistryPage.tsx";
import { AgentDashboard } from "./components/AgentDashboard.tsx";
import { SettingsPage } from "./components/SettingsPage.tsx";
import { Separator } from "./components/ui/separator.tsx";
import { useAgents } from "./context/AgentsContext.tsx";
import { cn } from "./lib/cn.ts";
Expand All @@ -20,6 +21,7 @@ export default function App() {
<Route path="observability" element={<ObservabilityTab />} />
<Route path="policies" element={<PoliciesPage />} />
<Route path="evals" element={<EvalsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="agents/:id" element={<AgentDashboard />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Route>
Expand Down Expand Up @@ -56,6 +58,10 @@ function Layout() {

<div className="flex-1" />

{/* System-level config lives at the bottom of the rail. */}
<nav className="px-2 pb-2 space-y-1">
<RailLink to="/settings" icon={SettingsIcon} label="Settings" />
</nav>
<Separator />
<div className="px-4 py-3 text-[10px] text-muted-foreground/70">agentos.clawagent.sh</div>
</aside>
Expand All @@ -72,15 +78,8 @@ function Layout() {
// keep their existing prop contracts and need no router awareness. ──

function HomeRoute() {
const { agents } = useAgents();
const navigate = useNavigate();
return (
<HomePage
agents={agents}
onLaunch={(agentId, message) => navigate(`/agents/${encodeURIComponent(agentId)}`, { state: { message } })}
onOpenDashboard={() => navigate(agents[0] ? `/agents/${encodeURIComponent(agents[0].id)}` : "/registry")}
/>
);
// HomePage is self-sufficient (reads agents/auth from context, navigates itself).
return <HomePage />;
}

function RegistryRoute() {
Expand Down
181 changes: 171 additions & 10 deletions agentos/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ export interface Agent {
origin?: "in-memory" | "registry";
registeredBy?: string | null;
lastSeen?: string | null;
/** True when the agent is archived: kept (with all its history) but refused
* by every execution path. The UI lists archived agents in a separate,
* greyed section and disables chat/run for them. Unset/false ⇒ active. */
archived?: boolean;
archivedAt?: string | null;
/** Owning group (visibility) + owning user id (mutate/delete). */
ownerGroup?: string | null;
ownerUser?: string | null;
}

export interface RegisterAgentInput {
Expand All @@ -51,6 +59,8 @@ export interface RegisterAgentInput {
source?: string;
model?: string;
registeredBy?: string;
/** The group the new agent belongs to (one of the creator's groups). */
ownerGroup?: string;
}

/** Result of `displaySource(agent.source)`. Drives `<SourceBadge>` rendering. */
Expand Down Expand Up @@ -219,29 +229,134 @@ export interface NewSchedule {
minuteUtc?: number;
}

// API keys — minted + stored (hashed) by the server; the plaintext is returned
// exactly once on create. List/responses are redacted (prefix + last4 only).
export interface ApiKey {
_id: string;
prefix: string;
last4: string;
label: string;
/** Display label of the group/role the key acts as. */
group?: string | null;
/** Roles the key inherits → resolved to permissions via the role map. */
roleIds?: string[];
scopes?: string[]; // DEPRECATED
createdBy: string;
createdAt: string;
expiresAt?: string | null;
lastUsedAt?: string | null;
revoked: boolean;
revokedAt?: string | null;
}

// Current principal, from GET /me. Drives the SPA's permission gating.
export interface Me {
id: string; // principal id (Keycloak sub) — compare to resource ownerUser
user: string;
displayName?: string | null;
source: "oidc" | "api-key" | "cookie" | "dev";
kind: "user" | "service";
roles: string[];
groups: string[];
permissions: string[];
}

// Editable role → permission map (Settings → Roles).
export interface Role {
_id: string;
description: string;
permissions: string[];
builtin: boolean;
updatedAt?: string;
}
export interface PermissionDef {
key: string;
description: string;
}

// Groups — read-only, sourced from Keycloak (Okta/Keycloak own groups + membership).
export interface Group {
id: string;
name: string;
path: string;
}
export interface GroupMember {
id: string;
username: string | null;
email: string | null;
name: string | null;
roles: string[];
}

// ── Auth: reactive token refresh ─────────────────────────────────────────────
// The BFF session cookie is short-lived (it tracks the Keycloak access-token
// expiry, ~5 min). When a dashboard request 401s we silently POST /auth/refresh
// (which rotates the server-held refresh token and re-signs the cookie) and
// replay the original request once. All concurrent 401s share ONE in-flight
// refresh — refresh tokens rotate and can be spent only once, so a stampede
// would invalidate itself. On a hard refresh failure the session is truly gone:
// we notify AuthContext (→ SSO sign-in screen).

let refreshInFlight: Promise<boolean> | null = null;
let onAuthLost: (() => void) | null = null;

/** AuthContext registers a callback here to flip to the anonymous/login state
* when the refresh token is dead (idle timeout / revocation / logout). */
export function setAuthLostHandler(fn: (() => void) | null): void {
onAuthLost = fn;
}

function tryRefresh(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = fetch(`/api/v1/auth/refresh`, {
method: "POST",
headers: { accept: "application/json" },
credentials: "include",
})
.then((r) => r.ok)
.catch(() => false)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}

/** fetch against the dashboard API with credentials. On 401, refresh once and
* replay; if refresh fails, signal auth-lost and return the 401 response. */
async function authedFetch(path: string, init: RequestInit): Promise<Response> {
const url = `/api/v1${path}`;
const opts: RequestInit = { credentials: "include", ...init };
let r = await fetch(url, opts);
if (r.status === 401) {
const ok = await tryRefresh();
if (ok) {
r = await fetch(url, opts);
} else {
onAuthLost?.();
}
}
return r;
}

async function getJSON<T>(path: string): Promise<T> {
const r = await fetch(`/api${path}`, {
headers: { accept: "application/json" },
credentials: "include",
});
const r = await authedFetch(path, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`${path} → ${r.status}`);
return r.json() as Promise<T>;
}
async function postJSON<T>(path: string, body: unknown): Promise<T> {
const r = await fetch(`/api${path}`, {
const r = await authedFetch(path, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
credentials: "include",
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`${path} → ${r.status}`);
return r.json() as Promise<T>;
}
async function reqJSON<T>(method: string, path: string, body?: unknown): Promise<T> {
const r = await fetch(`/api${path}`, {
const r = await authedFetch(path, {
method,
headers: { "content-type": "application/json", accept: "application/json" },
credentials: "include",
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
if (!r.ok) throw new Error(`${path} → ${r.status}`);
Expand Down Expand Up @@ -345,6 +460,13 @@ export const api = {
reqJSON<DeleteResult>("DELETE", `/agents/${encodeURIComponent(agentId)}`),
patchAgent: (agentId: string, fields: Partial<Omit<RegisterAgentInput, "name">>) =>
reqJSON<{ ok: boolean }>("PATCH", `/agents/${encodeURIComponent(agentId)}`, fields),
archiveAgent: (agentId: string) =>
reqJSON<{ ok: boolean; disposed: number; schedulesDisabled: number; warnings: string[] }>(
"POST",
`/agents/${encodeURIComponent(agentId)}/archive`,
),
unarchiveAgent: (agentId: string) =>
reqJSON<{ ok: boolean }>("POST", `/agents/${encodeURIComponent(agentId)}/unarchive`),
logs: (agentId?: string, limit = 100) =>
getJSON<{ logs: LogEntry[] }>(`/logs?limit=${limit}${agentId ? `&agentId=${encodeURIComponent(agentId)}` : ""}`).then((d) => d.logs),
sessions: (agentId?: string, limit = 100) =>
Expand All @@ -363,9 +485,9 @@ export const api = {
logWebTurn: (entry: { bot: string; sessionId: string; query: string; reply: string; ok: boolean }) =>
postJSON<{ ok: boolean }>("/logs", { ...entry, requester: "web" }),
// SSE chat — caller reads the stream. Path goes through the same /api proxy.
chatStreamUrl: (sandboxId: string) => `/api/sandboxes/${encodeURIComponent(sandboxId)}/chat`,
chatStreamUrl: (sandboxId: string) => `/api/v1/sandboxes/${encodeURIComponent(sandboxId)}/chat`,
// SSE one-shot run (deepagents). Server builds the /run body from {message}.
runStreamUrl: (agentId: string) => `/api/agents/${encodeURIComponent(agentId)}/run`,
runStreamUrl: (agentId: string) => `/api/v1/agents/${encodeURIComponent(agentId)}/run`,
// Schedules
schedules: (agentId?: string) =>
getJSON<{ schedules: Schedule[] }>(`/schedules${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ""}`).then((d) => d.schedules),
Expand Down Expand Up @@ -397,6 +519,45 @@ export const api = {
deleteOpaPolicy: (id: string) =>
reqJSON<{ success?: boolean }>("DELETE", `/opa-policies/${encodeURIComponent(id)}`),

// Current principal + session.
auth: {
me: () => getJSON<Me>("/me"),
logout: () => postJSON<{ ok: boolean; logoutUrl?: string }>("/logout", {}),
loginUrl: () => "/api/v1/auth/login",
},

// Roles — editable role→permission map + the permission catalog.
roles: {
list: () => getJSON<{ roles: Role[] }>("/roles").then((d) => d.roles),
permissions: () => getJSON<{ permissions: PermissionDef[] }>("/permissions").then((d) => d.permissions),
create: (body: { name: string; description?: string; permissions: string[] }) =>
postJSON<{ role: Role }>("/roles", body).then((d) => d.role),
update: (id: string, body: { description?: string; permissions?: string[] }) =>
reqJSON<{ role: Role }>("PUT", `/roles/${encodeURIComponent(id)}`, body).then((d) => d.role),
remove: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/roles/${encodeURIComponent(id)}`),
},

// Groups — read-only window into Keycloak (creation/membership live in Okta/KC).
groups: {
list: () => getJSON<{ groups: Group[] }>("/groups").then((d) => d.groups),
members: (id: string) =>
getJSON<{ members: GroupMember[]; truncated: boolean }>(`/groups/${encodeURIComponent(id)}/members`),
},

// API keys — mint (plaintext returned once), list (redacted), revoke. A key is
// minted bound to a group/role and inherits its permissions.
apiKeys: {
list: () => getJSON<{ apiKeys: ApiKey[] }>("/api-keys").then((d) => d.apiKeys),
create: (label: string, opts?: { expiresAt?: string | null; group?: string | null; roleIds?: string[] }) =>
postJSON<{ key: string; apiKey: ApiKey }>("/api-keys", {
label,
...(opts?.expiresAt ? { expiresAt: opts.expiresAt } : {}),
...(opts?.group ? { group: opts.group } : {}),
...(opts?.roleIds?.length ? { roleIds: opts.roleIds } : {}),
}),
revoke: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/api-keys/${encodeURIComponent(id)}`),
},

// Evals — suite CRUD + run trigger + run readback.
evals: {
listSuites: () => getJSON<{ suites: EvalSuite[] }>("/evals/suites").then((d) => d.suites),
Expand Down
74 changes: 60 additions & 14 deletions agentos/src/components/AgentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2 } from "lucide-react";
import { GitBranch, FolderTree, Code2, MessageSquare, Activity, Clock, ExternalLink, Trash2, Archive, ArchiveRestore } from "lucide-react";
import { type Agent, displaySource } from "../api.ts";
import { Badge } from "./ui/badge.tsx";
import { cn } from "../lib/cn.ts";
Expand Down Expand Up @@ -36,12 +36,18 @@ export function AgentCard({
selected,
onClick,
onDelete,
onArchive,
onUnarchive,
}: {
agent: Agent;
selected: boolean;
onClick: () => void;
/** When provided, a trash affordance shows on hover (used by the registry). */
onDelete?: () => void;
/** When provided (active agents), an archive affordance shows on hover. */
onArchive?: () => void;
/** When provided (archived agents), an unarchive affordance shows on hover. */
onUnarchive?: () => void;
}) {
// Show the registered name exactly as typed — never derive it from the
// source URL. The source/repo still renders on its own line below.
Expand All @@ -58,24 +64,59 @@ export function AgentCard({
className={cn(
"group relative w-full text-left rounded-xl border bg-background transition overflow-hidden shadow-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
a.archived && "opacity-60 saturate-50",
selected
? "border-primary/50 ring-1 ring-primary/30 shadow-md shadow-primary/10"
: "border-border/60 hover:border-border hover:bg-muted/30 hover:shadow-md",
)}
>
{onDelete && (
<span
role="button"
tabIndex={0}
title="Delete agent"
aria-label={`Delete ${a.name}`}
onClick={(e) => { e.stopPropagation(); onDelete(); }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onDelete(); }
}}
className="absolute right-2 top-2 z-10 h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
{(onDelete || onArchive || onUnarchive) && (
<span className="absolute right-2 top-2 z-10 flex items-center gap-1">
{onArchive && (
<span
role="button"
tabIndex={0}
title="Archive agent"
aria-label={`Archive ${a.name}`}
onClick={(e) => { e.stopPropagation(); onArchive(); }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onArchive(); }
}}
className="h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-amber-500/10 hover:text-amber-500 transition-opacity cursor-pointer"
>
<Archive className="h-3.5 w-3.5" />
</span>
)}
{onUnarchive && (
<span
role="button"
tabIndex={0}
title="Unarchive agent"
aria-label={`Unarchive ${a.name}`}
onClick={(e) => { e.stopPropagation(); onUnarchive(); }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onUnarchive(); }
}}
className="h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-emerald-500/10 hover:text-emerald-500 transition-opacity cursor-pointer"
>
<ArchiveRestore className="h-3.5 w-3.5" />
</span>
)}
{onDelete && (
<span
role="button"
tabIndex={0}
title="Delete agent"
aria-label={`Delete ${a.name}`}
onClick={(e) => { e.stopPropagation(); onDelete(); }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onDelete(); }
}}
className="h-6 w-6 grid place-items-center rounded-md text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)}

Expand Down Expand Up @@ -118,6 +159,11 @@ export function AgentCard({
1-shot
</Badge>
)}
{a.archived && (
<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">
archived
</Badge>
)}
</div>
<div className="mt-0.5 text-[10.5px] text-muted-foreground/80 font-mono truncate" title={a.harness}>
{a.harness}
Expand Down
Loading
Loading