From f49ea09544d7eb9ca78f47432072c7e7f818d862 Mon Sep 17 00:00:00 2001 From: Abhi Bhat Date: Sat, 6 Jun 2026 05:07:58 +0530 Subject: [PATCH] feat(agentos): OIDC/Keycloak auth, DB-backed RBAC, ownership, refresh, new home Replace the shared-password gate with a full auth/authorization stack and restructure the server routes. Authentication (BFF): - OIDC code+PKCE flow via Keycloak (Okta federated); tokens stay server-side, browser gets an httpOnly signed session-snapshot cookie. jose JWKS verify. - Reactive token refresh: short session cookie + server-held rotating refresh token; POST /auth/refresh re-signs the session on 401. SPA does single-flight refresh-and-retry, falling back to SSO sign-in when the refresh token is dead. Authorization (DB-backed RBAC): - Code-defined permission catalog + roles collection (role -> permissions, editable in Settings -> Roles), resolvePermissions + authorize(perm) per route. - AGENTOS_DEFAULT_ROLE / AGENTOS_BOOTSTRAP_ADMINS / AGENTOS_DEV_AUTH. Ownership / tenancy: - Resources stamped with ownerGroup + ownerUser; strict canRead/canWrite hard isolation (creator or owner-group; admins see all). Groups: read-only Settings -> Groups sourced from Keycloak Admin API. API keys: carry roleIds (capability) and group (tenancy) orthogonally, with a mint-time escalation guard; harness introspection echoes both. Routes: buildApp() composition, versioned /agentos/api/v1/* dashboard, nested agents router, trust-boundary groups (service / dashboard / observability), asyncHandler + standardized error envelope. SPA: - AuthContext + useAuth; SSO-only LoginPage; design-system fix (body bg/fg). - New personalized-workspace home (KPI tiles + agents/sessions/schedules). - RBAC-gate every create/delete control across registry, sessions, schedules, policies, evals, and API keys. --- agentos/src/App.tsx | 19 +- agentos/src/api.ts | 181 ++++- agentos/src/components/AgentCard.tsx | 74 +- agentos/src/components/AgentDashboard.tsx | 6 +- agentos/src/components/AuthGate.tsx | 39 +- agentos/src/components/ChatTab.tsx | 12 +- agentos/src/components/EvalsPage.tsx | 73 +- agentos/src/components/HomePage.tsx | 714 ++++++++---------- agentos/src/components/LoginPage.tsx | 119 +-- agentos/src/components/PoliciesPage.tsx | 102 ++- agentos/src/components/RegisterAgentForm.tsx | 39 +- agentos/src/components/RegistryPage.tsx | 93 ++- agentos/src/components/SchedulesTab.tsx | 63 +- agentos/src/components/SettingsPage.tsx | 77 ++ agentos/src/components/WorkspaceTab.tsx | 44 +- .../components/observability/AgentFilter.tsx | 46 ++ .../observability/ObservabilityTab.tsx | 16 +- .../components/settings/ApiKeysSection.tsx | 330 ++++++++ .../src/components/settings/GroupsSection.tsx | 141 ++++ .../src/components/settings/RolesSection.tsx | 262 +++++++ agentos/src/context/AuthContext.tsx | 85 +++ agentos/src/hooks/useAssignableGroups.ts | Bin 0 -> 1339 bytes agentos/src/index.css | 4 + agentos/src/main.tsx | 13 +- examples/computeragent-server.ts | 105 ++- examples/introspection-auth.test.ts | 74 ++ examples/introspection-auth.ts | 125 +++ packages/agentos-server/package.json | 1 + packages/agentos-server/src/agent-defs.ts | 70 +- packages/agentos-server/src/app.smoke.test.ts | 66 ++ packages/agentos-server/src/app.ts | 52 ++ .../agentos-server/src/auth-refresh.test.ts | 31 + packages/agentos-server/src/auth.ts | 66 ++ .../src/auth/authenticate.test.ts | 113 +++ .../agentos-server/src/auth/authenticate.ts | 107 +++ .../agentos-server/src/auth/authorize.test.ts | 116 +++ packages/agentos-server/src/auth/authorize.ts | 96 +++ .../src/auth/keycloak-admin.test.ts | 61 ++ .../agentos-server/src/auth/keycloak-admin.ts | 101 +++ packages/agentos-server/src/auth/oidc.test.ts | 49 ++ packages/agentos-server/src/auth/oidc.ts | 176 +++++ .../agentos-server/src/auth/ownership.test.ts | 72 ++ packages/agentos-server/src/auth/ownership.ts | 59 ++ .../agentos-server/src/auth/permissions.ts | 44 ++ packages/agentos-server/src/auth/principal.ts | 32 + packages/agentos-server/src/fields.ts | 38 + .../agentos-server/src/http/async-handler.ts | 11 + .../agentos-server/src/http/error-handler.ts | 24 + packages/agentos-server/src/http/errors.ts | 27 + packages/agentos-server/src/index.ts | 122 +-- .../src/introspection-auth.test.ts | 48 ++ .../agentos-server/src/introspection-auth.ts | 29 + packages/agentos-server/src/mongo.ts | 11 + .../agentos-server/src/query.scope.test.ts | 132 ++++ packages/agentos-server/src/query.ts | Bin 17706 -> 20591 bytes packages/agentos-server/src/routes/agents.ts | 95 ++- .../agentos-server/src/routes/api-keys.ts | 102 +++ packages/agentos-server/src/routes/auth.ts | 228 +++++- packages/agentos-server/src/routes/chat.ts | 18 +- .../agentos-server/src/routes/completion.ts | 3 +- .../agentos-server/src/routes/dashboard.ts | 58 ++ packages/agentos-server/src/routes/evals.ts | 49 +- packages/agentos-server/src/routes/groups.ts | 71 ++ .../src/routes/keys-introspect.ts | 30 + packages/agentos-server/src/routes/logs.ts | 16 +- .../src/routes/obs-dashboard.ts | 19 +- .../agentos-server/src/routes/obs-fields.ts | 85 ++- .../agentos-server/src/routes/obs-traces.ts | 55 +- packages/agentos-server/src/routes/obs.ts | 30 + .../agentos-server/src/routes/policies.ts | 28 +- packages/agentos-server/src/routes/roles.ts | 74 ++ packages/agentos-server/src/routes/run.ts | 11 +- .../agentos-server/src/routes/schedules.ts | 27 +- .../agentos-server/src/routes/sessions.ts | 25 +- packages/agentos-server/src/scheduler.ts | 6 + .../src/stores/agent-log-store.ts | 3 + .../src/stores/api-key-store.test.ts | 133 ++++ .../src/stores/api-key-store.ts | 156 ++++ .../src/stores/role-store.test.ts | 99 +++ .../agentos-server/src/stores/role-store.ts | 145 ++++ .../src/stores/schedule-store.ts | 7 + .../otel-audit-sink.identity.test.ts | 161 ++++ .../src/audit-sink/otel-audit-sink.test.ts | 4 + .../src/audit-sink/otel-audit-sink.ts | 76 +- packages/observability/src/index.ts | 1 + .../observability/src/semantic/attributes.ts | 17 + pnpm-lock.yaml | 69 +- 87 files changed, 5559 insertions(+), 952 deletions(-) create mode 100644 agentos/src/components/SettingsPage.tsx create mode 100644 agentos/src/components/observability/AgentFilter.tsx create mode 100644 agentos/src/components/settings/ApiKeysSection.tsx create mode 100644 agentos/src/components/settings/GroupsSection.tsx create mode 100644 agentos/src/components/settings/RolesSection.tsx create mode 100644 agentos/src/context/AuthContext.tsx create mode 100644 agentos/src/hooks/useAssignableGroups.ts create mode 100644 examples/introspection-auth.test.ts create mode 100644 examples/introspection-auth.ts create mode 100644 packages/agentos-server/src/app.smoke.test.ts create mode 100644 packages/agentos-server/src/app.ts create mode 100644 packages/agentos-server/src/auth-refresh.test.ts create mode 100644 packages/agentos-server/src/auth/authenticate.test.ts create mode 100644 packages/agentos-server/src/auth/authenticate.ts create mode 100644 packages/agentos-server/src/auth/authorize.test.ts create mode 100644 packages/agentos-server/src/auth/authorize.ts create mode 100644 packages/agentos-server/src/auth/keycloak-admin.test.ts create mode 100644 packages/agentos-server/src/auth/keycloak-admin.ts create mode 100644 packages/agentos-server/src/auth/oidc.test.ts create mode 100644 packages/agentos-server/src/auth/oidc.ts create mode 100644 packages/agentos-server/src/auth/ownership.test.ts create mode 100644 packages/agentos-server/src/auth/ownership.ts create mode 100644 packages/agentos-server/src/auth/permissions.ts create mode 100644 packages/agentos-server/src/auth/principal.ts create mode 100644 packages/agentos-server/src/http/async-handler.ts create mode 100644 packages/agentos-server/src/http/error-handler.ts create mode 100644 packages/agentos-server/src/http/errors.ts create mode 100644 packages/agentos-server/src/introspection-auth.test.ts create mode 100644 packages/agentos-server/src/introspection-auth.ts create mode 100644 packages/agentos-server/src/query.scope.test.ts create mode 100644 packages/agentos-server/src/routes/api-keys.ts create mode 100644 packages/agentos-server/src/routes/dashboard.ts create mode 100644 packages/agentos-server/src/routes/groups.ts create mode 100644 packages/agentos-server/src/routes/keys-introspect.ts create mode 100644 packages/agentos-server/src/routes/obs.ts create mode 100644 packages/agentos-server/src/routes/roles.ts create mode 100644 packages/agentos-server/src/stores/api-key-store.test.ts create mode 100644 packages/agentos-server/src/stores/api-key-store.ts create mode 100644 packages/agentos-server/src/stores/role-store.test.ts create mode 100644 packages/agentos-server/src/stores/role-store.ts create mode 100644 packages/observability/src/audit-sink/otel-audit-sink.identity.test.ts diff --git a/agentos/src/App.tsx b/agentos/src/App.tsx index d9e3354..8d3bccb 100644 --- a/agentos/src/App.tsx +++ b/agentos/src/App.tsx @@ -1,4 +1,4 @@ -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"; @@ -6,6 +6,7 @@ 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"; @@ -20,6 +21,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> @@ -56,6 +58,10 @@ function Layout() {
+ {/* System-level config lives at the bottom of the rail. */} +
agentos.clawagent.sh
@@ -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 ( - 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 ; } function RegistryRoute() { diff --git a/agentos/src/api.ts b/agentos/src/api.ts index b9eaf92..e358b24 100644 --- a/agentos/src/api.ts +++ b/agentos/src/api.ts @@ -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 { @@ -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 `` rendering. */ @@ -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 | 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 { + 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 { + 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(path: string): Promise { - 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; } async function postJSON(path: string, body: unknown): Promise { - 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; } async function reqJSON(method: string, path: string, body?: unknown): Promise { - 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}`); @@ -345,6 +460,13 @@ export const api = { reqJSON("DELETE", `/agents/${encodeURIComponent(agentId)}`), patchAgent: (agentId: string, fields: Partial>) => 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) => @@ -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), @@ -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"), + 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), diff --git a/agentos/src/components/AgentCard.tsx b/agentos/src/components/AgentCard.tsx index 372afd3..4a8221a 100644 --- a/agentos/src/components/AgentCard.tsx +++ b/agentos/src/components/AgentCard.tsx @@ -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"; @@ -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. @@ -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 && ( - { 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" - > - + {(onDelete || onArchive || onUnarchive) && ( + + {onArchive && ( + { 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" + > + + + )} + {onUnarchive && ( + { 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" + > + + + )} + {onDelete && ( + { 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" + > + + + )} )} @@ -118,6 +159,11 @@ export function AgentCard({ 1-shot )} + {a.archived && ( + + archived + + )}
{a.harness} diff --git a/agentos/src/components/AgentDashboard.tsx b/agentos/src/components/AgentDashboard.tsx index 71bb943..8656482 100644 --- a/agentos/src/components/AgentDashboard.tsx +++ b/agentos/src/components/AgentDashboard.tsx @@ -82,6 +82,9 @@ export function AgentDashboard() { {!agent.sandboxCapable && ( one-shot · no memory across turns )} + {agent.archived && ( + archived · cannot run until unarchived + )} } actions={ @@ -103,7 +106,8 @@ export function AgentDashboard() { agentName={agent.name} sandboxCapable={agent.sandboxCapable} liveChatCapable={agent.liveChatCapable !== false} - initialMessage={launchMessage} + archived={agent.archived === true} + initialMessage={agent.archived ? null : launchMessage} onConsumedInitial={() => setLaunchMessage(null)} /> )} diff --git a/agentos/src/components/AuthGate.tsx b/agentos/src/components/AuthGate.tsx index a986dd3..619c3c8 100644 --- a/agentos/src/components/AuthGate.tsx +++ b/agentos/src/components/AuthGate.tsx @@ -1,41 +1,14 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { LoginPage } from "./LoginPage.tsx"; +import { useAuth } from "../context/AuthContext.tsx"; import { Loader2 } from "lucide-react"; -type State = - | { kind: "checking" } - | { kind: "anon" } - | { kind: "auth"; user: string }; - /** - * Wraps the app. On mount, calls /api/me to determine whether the user is - * already authenticated (cookie or backwards-compat Basic). If anonymous, - * shows the LoginPage. The cookie is httpOnly so we ALWAYS round-trip to - * the server for the truth — never trust localStorage for auth state. + * Gates the app on the auth state from AuthContext (GET /me). While checking, + * shows a spinner; anonymous → the SSO sign-in screen; authenticated → the app. */ export function AuthGate({ children }: { children: ReactNode }) { - const [state, setState] = useState({ kind: "checking" }); - - useEffect(() => { - void check(); - }, []); - - async function check() { - setState({ kind: "checking" }); - try { - const res = await fetch("/api/me", { credentials: "include" }); - if (res.ok) { - const data = (await res.json()) as { user: string }; - setState({ kind: "auth", user: data.user }); - } else { - setState({ kind: "anon" }); - } - } catch { - // Backend unreachable — treat as anon so the login form gives a clear - // error rather than spinning forever. - setState({ kind: "anon" }); - } - } + const { state } = useAuth(); if (state.kind === "checking") { return ( @@ -45,7 +18,7 @@ export function AuthGate({ children }: { children: ReactNode }) { ); } if (state.kind === "anon") { - return void check()} />; + return ; } return <>{children}; } diff --git a/agentos/src/components/ChatTab.tsx b/agentos/src/components/ChatTab.tsx index e60edb9..edf1681 100644 --- a/agentos/src/components/ChatTab.tsx +++ b/agentos/src/components/ChatTab.tsx @@ -33,6 +33,7 @@ export function ChatTab({ initialMessage, onConsumedInitial, onSessionStarted, + disabled = false, }: { /** Registry ObjectId — used to address the agent in API calls. */ agentId: string; @@ -46,6 +47,9 @@ export function ChatTab({ /** Fired once a sandbox boots and a sessionId is established (new or resumed), * so the parent can refresh / highlight the session list. */ onSessionStarted?: (sessionId: string) => void; + /** When true the composer is locked (e.g. the agent is archived). Sending is + * refused server-side regardless; this just makes the UI state explicit. */ + disabled?: boolean; }) { const [sandboxId, setSandboxId] = useState(null); const [sessionId, setSessionId] = useState(null); @@ -119,6 +123,7 @@ export function ChatTab({ } async function send(textArg?: string) { + if (disabled) return; const text = (textArg ?? input).trim(); if (!text || busy) return; @@ -251,14 +256,15 @@ export function ChatTab({ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - send(); + if (!disabled) send(); } }} - placeholder={`Message ${agentName}… (Enter to send, Shift+Enter for newline)`} + disabled={disabled} + placeholder={disabled ? "This agent is archived — unarchive it to chat." : `Message ${agentName}… (Enter to send, Shift+Enter for newline)`} rows={2} className="flex-1 resize-none rounded-xl bg-card px-3.5 py-2.5" /> - diff --git a/agentos/src/components/EvalsPage.tsx b/agentos/src/components/EvalsPage.tsx index 461f7d5..fdec793 100644 --- a/agentos/src/components/EvalsPage.tsx +++ b/agentos/src/components/EvalsPage.tsx @@ -13,12 +13,17 @@ import { type JudgeDef, } from "../api.ts"; import { useAgents } from "../context/AgentsContext.tsx"; +import { useAuth } from "../context/AuthContext.tsx"; import { cn } from "../lib/cn.ts"; import { SimDashboard } from "./SimDashboard.tsx"; type RightView = { kind: "empty" } | { kind: "dashboard" } | { kind: "suite"; id: string } | { kind: "edit"; suite: EvalSuite | null } | { kind: "run"; id: string }; export function EvalsPage() { + const { can } = useAuth(); + // RBAC (UX gating; server authorize() is the boundary). Evals use a single + // write permission covering create / edit / run / delete of suites. + const canWrite = can("evals:write"); const [suites, setSuites] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); @@ -40,12 +45,14 @@ export function EvalsPage() { Agent Simulation Engine
- + {canWrite && ( + + )}
-
- - -
+ {canWrite && ( +
+ + +
+ )} {/* Cases preview */} @@ -231,11 +244,13 @@ function SuiteDetail({ -
- -
+ {canWrite && ( +
+ +
+ )} ); } @@ -500,10 +515,12 @@ function initialJudges(s: EvalSuite | null | undefined): JudgeDef[] { function SuiteEditor({ initial, + canWrite, onCancel, onSaved, }: { initial: EvalSuite | null; + canWrite: boolean; onCancel: () => void; onSaved: (s: EvalSuite) => void; }) { @@ -714,10 +731,12 @@ function SuiteEditor({
- - + {canWrite && ( + + )} +
); diff --git a/agentos/src/components/HomePage.tsx b/agentos/src/components/HomePage.tsx index 2359cb3..c756879 100644 --- a/agentos/src/components/HomePage.tsx +++ b/agentos/src/components/HomePage.tsx @@ -1,422 +1,374 @@ -import { useEffect, useState } from "react"; -import { Sparkles, ArrowUp, Plus, Mic, Check } from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "./ui/button.tsx"; -import { Textarea } from "./ui/textarea.tsx"; +/** + * Home — a personalized SaaS workspace dashboard (no chat hero, no greeting). + * A KPI stat row up top, then your agents + recent sessions + upcoming runs. + * All widgets are read-scoped by the server (you only see what your + * groups/ownership allow) and RBAC-gated in the UI. + */ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Boxes, + Activity, + FlaskConical, + ArrowRight, + Clock, + MessageSquare, + Radio, + CalendarClock, +} from "lucide-react"; +import { api, type Schedule, type SessionSummary } from "../api.ts"; +import { useAgents } from "../context/AgentsContext.tsx"; +import { useAuth } from "../context/AuthContext.tsx"; +import { RegisterAgentForm } from "./RegisterAgentForm.tsx"; import { Card } from "./ui/card.tsx"; import { Badge } from "./ui/badge.tsx"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx"; -import { api, type Agent } from "../api.ts"; -import { streamChat, streamCompletion } from "../sse.ts"; -import { cn } from "../lib/cn.ts"; +import { Button } from "./ui/button.tsx"; -interface ChatTurn { - role: "user" | "assistant"; - text: string; +function firstName(me: { displayName?: string | null; user?: string } | null): string { + if (me?.displayName) return me.displayName.split(" ")[0]!; + const local = me?.user?.split("@")[0] ?? ""; + const token = local.split(/[._-]/)[0] ?? ""; + return token ? token.charAt(0).toUpperCase() + token.slice(1) : "there"; } -export type Framework = "gitagent" | "claude-code" | "deep-agent" | "auto"; - -interface FrameworkDef { - id: Framework; - name: string; - desc: string; - logo?: string; - agent: string | null; +function rel(iso: string | null | undefined): string { + if (!iso) return "—"; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return "—"; + const s = Math.round((Date.now() - t) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; } -const FRAMEWORKS: FrameworkDef[] = [ - { id: "gitagent", name: "GitAgent", desc: "Code-aware agent on gitclaw", logo: "/logos/gitagent.png", agent: "gitagent" }, - { id: "claude-code", name: "Claude Code", desc: "Anthropic code-native agent", logo: "/logos/claude.svg", agent: "claude-code" }, - { id: "deep-agent", name: "Deep Agent", desc: "LangGraph deep agent · one-shot", logo: "/logos/langchain.svg", agent: "deep-agent" }, - { id: "auto", name: "Auto", desc: "Let AgentOS pick", logo: "/logos/auto.svg", agent: "gitagent" }, -]; +function until(iso: string | null | undefined): string { + if (!iso) return "—"; + const t = new Date(iso).getTime(); + if (Number.isNaN(t)) return "—"; + const s = Math.round((t - Date.now()) / 1000); + if (s <= 0) return "due"; + if (s < 3600) return `in ${Math.floor(s / 60)}m`; + if (s < 86400) return `in ${Math.floor(s / 3600)}h`; + return `in ${Math.floor(s / 86400)}d`; +} -function FrameworkIcon({ f, size = 32 }: { f: FrameworkDef; size?: number }) { +// ── KPI stat tile ────────────────────────────────────────────────────── +function Stat({ + icon: Icon, + label, + value, + hint, + accent = "text-muted-foreground", + onClick, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: number | string; + hint?: string; + accent?: string; + onClick?: () => void; +}) { return ( - - {f.logo && ( - {f.name} - )} - +
+ {label} + + + +
+
{value}
+ {hint &&
{hint}
} + ); } -function greeting(): string { - const h = new Date().getHours(); - if (h < 12) return "Good morning"; - if (h < 17) return "Good afternoon"; - return "Good evening"; -} - -export function HomePage({ - onLaunch, - onOpenDashboard, - agents, +// ── List section card ────────────────────────────────────────────────── +function SectionCard({ + title, + icon: Icon, + action, + children, }: { - onLaunch: (agent: string, message: string) => void; - onOpenDashboard: () => void; - agents: Agent[]; + title: string; + icon: React.ComponentType<{ className?: string }>; + action?: React.ReactNode; + children: React.ReactNode; }) { - const [framework, setFramework] = useState("auto"); - const [prompt, setPrompt] = useState(""); - const [pickerOpen, setPickerOpen] = useState(false); - const [messages, setMessages] = useState([]); - const [busy, setBusy] = useState(false); - // Warm sandbox for the home chat — created on first turn, reused after so the - // agent keeps conversation memory across turns (same as the dashboard chat). - const [sandboxId, setSandboxId] = useState(null); - const [sessionId, setSessionId] = useState(null); - // Greeting follows the user's local browser time and refreshes each minute so - // it stays correct if the page is left open across a morning/afternoon/evening - // boundary. - const [word, setWord] = useState(greeting); - useEffect(() => { - const id = setInterval(() => setWord(greeting()), 60_000); - return () => clearInterval(id); - }, []); - - const selected = FRAMEWORKS.find((f) => f.id === framework)!; - - // Switching the agent runtime starts a fresh sandbox on the next turn. - useEffect(() => { - setSandboxId(null); - setSessionId(null); - }, [framework]); - - // Explicit framework → its mapped agent. sandboxCapable comes from the - // registry when the agent is known, else inferred (deepagents run one-shot). - // liveChatCapable additionally checks the source can be resolved — library- - // mode agents (Python harness) have a non-resolvable source and so cannot - // start a live chat, even though their harness is sandboxable. - const resolveTarget = (): { - id: string | null; - name: string; - sandboxCapable: boolean; - liveChatCapable: boolean; - } => { - const name = selected.agent ?? agents[0]?.name ?? "gitagent"; - const found = agents.find((a) => a.name === name); - const sandboxCap = found ? found.sandboxCapable : selected.id !== "deep-agent"; - const liveCap = found ? found.liveChatCapable !== false : sandboxCap; - return { id: found?.id ?? null, name, sandboxCapable: sandboxCap, liveChatCapable: liveCap }; - }; - - const submit = async () => { - const msg = prompt.trim(); - if (!msg || busy) return; - setPrompt(""); - const history: ChatTurn[] = [...messages, { role: "user", text: msg }]; - const assistantIdx = history.length; - setMessages([...history, { role: "assistant", text: "" }]); - setBusy(true); - - const setAssistant = (text: string) => - setMessages((cur) => { - const next = [...cur]; - if (next[assistantIdx]) next[assistantIdx] = { role: "assistant", text }; - return next; - }); - - // Auto → agent-less Claude completion (direct /api/completion proxy). No - // sandbox, no specific bot — predictable plain Claude chat. The explicit - // runtime picks below run as real agents through the harness instead. - if (framework === "auto") { - try { - await streamCompletion( - history.map((m) => ({ role: m.role, content: m.text })), - { onText: setAssistant, onError: (e) => setAssistant(`⚠️ ${e}`), onDone: () => {} }, - ); - } catch (e) { - setAssistant(`⚠️ ${String(e)}`); - } finally { - setBusy(false); - } - return; - } - - // Explicit runtime → run as a REAL agent through the ComputerAgent server: - // boot/reuse a harness sandbox (or one-shot /run for deepagents) and stream. - try { - const target = resolveTarget(); - let streamUrl: string; - let turnSession = sessionId; + return ( + +
+
+ {title} +
+ {action} +
+
{children}
+
+ ); +} - if (!target.liveChatCapable && target.sandboxCapable) { - // Library-mode agent: sandbox-capable harness but source isn't - // resolvable into a workdir (Python harness, etc.). Surface a - // friendly explanation instead of POSTing to /chat-sandbox where - // the server would 400 with LIBRARY_AGENT_NO_LIVE_CHAT. - setMessages((cur) => [ - ...cur, - { - role: "assistant", - text: - `${target.name} is a library-mode agent — it runs in its host ` + - `process (e.g. a Python SDK). AgentOS shows its telemetry but ` + - `can't start a new live chat from here. Invoke it from your code.`, - }, - ]); - setBusy(false); - return; - } - if (!target.id) { - setAssistant(`⚠️ "${target.name}" isn't a registered agent yet — register it first.`); - setBusy(false); - return; - } - if (target.sandboxCapable) { - let sb = sandboxId; - if (!sb) { - const created = await api.chatSandbox(target.id); - sb = created.sandboxId; - turnSession = created.sessionId; - setSandboxId(created.sandboxId); - setSessionId(created.sessionId); - } - streamUrl = api.chatStreamUrl(sb); - } else { - // One-shot agents (deepagents): no warm sandbox, no cross-turn memory. - streamUrl = api.runStreamUrl(target.id); - } +const Empty = ({ text }: { text: string }) => ( +
{text}
+); - await streamChat(streamUrl, msg, { - onText: setAssistant, - onError: (e) => setAssistant(`⚠️ ${e}`), - onDone: (final) => { - if (turnSession && final) { - api - .logWebTurn({ bot: target.name, sessionId: turnSession, query: msg, reply: final, ok: true }) - .catch(() => {}); - } - }, - }); - } catch (e) { - setAssistant(`⚠️ ${String(e)}`); - } finally { - setBusy(false); - } - }; +export function HomePage() { + const { agents, reload } = useAgents(); + const { me, can } = useAuth(); + const navigate = useNavigate(); - // The prompt composer — reused in the landing hero and pinned to the bottom - // of the chat window once a conversation starts. - const promptCard = (rows: number) => ( - -
- - - - - -
- {FRAMEWORKS.map((f) => ( - - ))} -
-
-
-
+ const [sessions, setSessions] = useState([]); + const [schedules, setSchedules] = useState([]); -