diff --git a/.gitignore b/.gitignore index a7f10ac..48c3303 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist/ .DS_Store .env .env.local +# Env files holding secrets (e.g. keycloak.env — KC admin pass + client secret). +*.env coverage/ .vscode/ .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 967f7f0..f96c2e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,12 +92,17 @@ Optional GitHub repo **variables** (build-time baked into the SPA bundle): The Mongo cluster is the source of truth for AgentOS. **Only the `agentos-server` connects to Mongo.** As of the post-0.2.1 dev build, the Python SDK no longer writes Mongo directly — it POSTs telemetry to the server's ingest endpoint (`AgentOSHttpSink` → `POST /agentos/api/ingest/events`), and the server owns all writes. (The old `AgentRegistrySink` + `MongoMessageSink` and the `motor` dep were removed — see the Python SDK history below.) **Collections (database = the server's `MONGO_DATABASE`):** -- `agent_registry` — one doc per registered agent (the server writes `source.type="library"` for harness-mode agents → AgentOS UI hides the chat-sandbox button for those, see commit `8d829b8`) +- `agent_registry` — one doc per registered agent (the server writes `source.type="library"` for harness-mode agents → AgentOS UI hides the chat-sandbox button for those, see commit `8d829b8`). Also carries **ownership** (`ownerGroup`/`ownerUser`, see §2.6b) and **GAP source sync** (`sourceSha`/`sourceSyncedAt` — the commit SHA the SDK last loaded; the `session_started` projection updates it and logs drift, see §2.6c). - `agent_logs` — one doc per conversation (one `ComputerAgent` instance = one log row, multi-turn collapses correctly since the 0.2.0 session-id refactor) - `sessions` — ordered chat transcript (one doc per session_id, entries appended in order; **`session_started` is the sole creator** of the doc, so a dropped/reordered start can't stub it) - `chat_sessions` — the session-index row (`{_id, agent, createdAt, lastMessageAt}`) the dashboard's session list + per-agent `sessionCount`/`lastActivity` read. The server projection writes this so library-mode sessions show up (the old Python sink omitted it). - `agent_messages` — per-event audit trail (every assistant_message / tool_use / tool_result lands here) - `slack_threads` — Slack-bot chat-channel state only; **not** written by the ingest projection (it was dead/legacy for library agents). +- `roles` — the DB-backed RBAC map (`{_id: , permissions[], builtin}`), editable in Settings→Roles; seeded with `agentos-admin`/`-editor`/`-viewer` (§2.6b). +- `api_keys` — AgentOS-issued service keys (`cak_…`), stored hashed; each carries `roleIds` (capability) + `group` (tenancy). Validated by the harness via introspection; permissions resolve from the same `roles` map (§2.6b). +- `git_credentials` — group-scoped git PATs (encrypted at rest), one per `(ownerGroup, host)`, used by the SDK to clone private GAP repos (§2.6c). + +Resources stamped with `ownerGroup`/`ownerUser` are **hard-isolated**: a non-admin sees only their own or their group's; admins (`*`) see all (§2.6b). **Credentials required:** - On the **SDK** side: `AGENTOS_INGEST_URL` (e.g. `https:///agentos/api/ingest/events`) + optional `AGENTOS_INGEST_TOKEN` (sent as `Authorization: Bearer …`). No Mongo creds. @@ -107,6 +112,45 @@ The Mongo cluster is the source of truth for AgentOS. **Only the `agentos-server --- +### 2.6b AgentOS authentication + RBAC (Okta → Keycloak → BFF) + +> The shared-password gate is gone. AgentOS now does real SSO + DB-backed RBAC + group ownership. Code lives under `packages/agentos-server/src/auth/`. + +**Authentication — Okta federated by Keycloak, BFF session.** The app speaks only OIDC to Keycloak (which brokers Okta). `agentos-server` is the confidential `agent-os-server-client`: it runs Authorization Code + PKCE server-side (`auth/oidc.ts`, `routes/auth.ts`), verifies tokens via JWKS (`jose`), and sets an **httpOnly `agentos_session` cookie** carrying a signed principal snapshot — no token ever reaches the browser. The SPA is SSO-only (`LoginPage`). + +**Token refresh (reactive).** The session cookie tracks the (short) access-token expiry; the server also holds a rotating refresh token in `agentos_refresh`. On a `401`, the SPA silently `POST /auth/refresh` (single-flight) and replays; a dead refresh token → SSO sign-in. So you stay logged in while active and only re-auth after Keycloak's SSO idle/max timeout. + +**Authorization — DB-backed roles.** Keycloak emits role *names* (`realm_access.roles`) + `groups`; AgentOS owns what each role *can do* via the `roles` collection (editable in Settings→Roles). `authenticate → resolvePermissions → authorize(perm)` gates every dashboard route. Permission catalog is code-defined (`auth/permissions.ts`). + +**Three guards / trust boundaries** (`app.ts`): SERVICE `/agentos/api/ingest/*` (`requireIngestAuth`, fails open) + `/agentos/api/keys/*` (`requireIntrospectionAuth`, fails closed); DASHBOARD `/agentos/api/v1/*` (`authenticate`); OBS `/v1/*`. `cak_` API keys authenticate at the dashboard boundary too (→ service principal with `groups=[key.group]`). + +**Groups = read-only from Keycloak Admin API** (Settings→Groups). If a user's token lacks the `groups` claim, the server backfills groups from the Admin API at login/refresh (`auth/keycloak-admin.ts:listUserGroups`). + +**Required env (server):** +- `KEYCLOAK_ISSUER_URL` = `https:///realms/` (e.g. realm `computer-agent`) +- `OIDC_CLIENT_ID` + `OIDC_CLIENT_SECRET` (confidential client); optional `OIDC_AUDIENCE`, `OIDC_REDIRECT_URI`, `OIDC_POST_LOGOUT_URI`, `OIDC_ROLES_CLAIM`/`OIDC_GROUPS_CLAIM` +- `AGENTOS_SESSION_SECRET` (HMAC for the signed cookies — **stable in prod**) +- `AGENTOS_DEFAULT_ROLE` (e.g. `agentos-viewer`) — fallback when the token has no AgentOS role +- `AGENTOS_BOOTSTRAP_ADMINS` (comma-sep emails granted `*` before role lookup — first-admin bring-up; remove after) +- `AGENTOS_DEV_AUTH=1` — **local only** dev bypass injecting an admin principal; never in deployed envs +- `KEYCLOAK_ADMIN_CLIENT_ID`/`SECRET` (defaults to the OIDC client) — service account needs `view-realm`/`view-users` for the Groups view + group backfill + +> **Provisioning:** `pnpm --filter @computeragent/agentos-server provision:keycloak` (`scripts/provision-keycloak.mjs`) idempotently creates the realm, the three realm roles, the OIDC client (+ secret), the **Group Membership** mapper, and the service-account roles. Run with `DRY_RUN=1` first. Needs a Keycloak master-admin user/pass (used once, never stored). + +### 2.6c Git credentials (private GAP repos) + SHA sync + +> So the SDK can clone **private** GAP repos. Code: `auth/.../crypto/secret-box.ts`, `stores/git-credential-store.ts`, `routes/git-credentials.ts`; SDK side in `computeragent-py` (`harness/git_credential_client.py`, `substrates/local.py`). + +- **Store.** A PAT is owned by a **group** and scoped to one **host** — one per `(ownerGroup, host)` in `git_credentials`, **AES-256-GCM encrypted at rest**. Managed in Settings→Git Credentials (perms `git-credentials:read`/`:manage`). The secret is write-only (never returned). +- **Resolve.** The SDK calls `POST /agentos/api/v1/git-credentials/resolve` with its `cak_` key; the server returns the decrypted PAT for the key's group + the repo host (strictly group-scoped, no admin bypass). The SDK injects it via `GIT_CONFIG_*`/`http..extraHeader` so the token never lands in `argv`/URL; SSH URLs pass through. Miss/401 → unauthenticated clone fallback (public repos unaffected). +- **SHA sync.** After cloning, the SDK runs `git rev-parse HEAD` and reports it as `agent_sha` on `session_started`; the projection writes `sourceSha`/`sourceSyncedAt` on the registry doc and logs any change. (Reactive — recorded on each run; the SDK already re-clones fresh, so the running agent is never stale.) + +**Required env:** +- Server: `AGENTOS_CREDENTIALS_KEY` (base64 of 32 random bytes; **fail-closed** — credentials CRUD/resolve 503 without it). Optional `AGENTOS_CREDENTIALS_KEY_OLD` for rotation. +- SDK: `AGENTOS_API_URL` (e.g. `https:///agentos/api/v1`) + the same `cak_` key it already uses (`COMPUTERAGENT_HARNESS_TOKEN` / `AGENTOS_INGEST_TOKEN`). The key's role must include `git-credentials:read`. + +--- + ### 2.4 OpenTelemetry / New Relic Every harness run emits GenAI-semconv spans + metrics through `OtelSink`. With env vars set, the sink ships out of process; without them it falls back to the console exporter. @@ -276,9 +320,21 @@ OPENAI_API_KEY=sk-... # On the SDK (library/worker) side: AGENTOS_INGEST_URL=https:///agentos/api/ingest/events AGENTOS_INGEST_TOKEN= # optional; must match the server's +AGENTOS_API_URL=https:///agentos/api/v1 # for private-GAP credential resolve (§2.6c) +COMPUTERAGENT_HARNESS_TOKEN=cak_... # the AgentOS API key the SDK presents (role needs git-credentials:read) # On the agentos-server side (NOT the SDK): MONGO_URL=mongodb+srv://user:pass@cluster.mongodb.net MONGO_DATABASE=computeragent +# AgentOS auth / RBAC (§2.6b) — SSO via Keycloak (Okta brokered), DB-backed roles: +KEYCLOAK_ISSUER_URL=https:///realms/computer-agent +OIDC_CLIENT_ID=agent-os-server-client +OIDC_CLIENT_SECRET= +AGENTOS_SESSION_SECRET= +AGENTOS_DEFAULT_ROLE=agentos-viewer +# AGENTOS_BOOTSTRAP_ADMINS=you@org.com # first-admin bring-up; remove after +# AGENTOS_DEV_AUTH=1 # LOCAL ONLY — admin bypass, never deployed +# Git credentials at rest (§2.6c): +AGENTOS_CREDENTIALS_KEY= # OTel → New Relic OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net @@ -359,9 +415,12 @@ pnpm build && pnpm start # node dist/index.js | `CORS_ORIGIN` | empty | Comma-separated origins allowed to call the API (set to your SPA origin) | | `NODE_ENV` | — | `production` enables secure cookies + tightens defaults | | `COOKIE_SECURE` | derived from `NODE_ENV` | Force `true` / `false` explicitly | -| `AGENTOS_SESSION_SECRET` | random per boot | Cookie-session secret. **Set to a stable value in prod** or sessions are invalidated on restart | -| `API_AUTH_USER` + `API_AUTH_PASS` | unset | Basic-auth gate on the API. When unset the API is open (relies on network policy) | +| `AGENTOS_SESSION_SECRET` | random per boot | HMAC secret for the signed BFF cookies (`agentos_session`/`agentos_refresh`). **Set to a stable value in prod** or every session is invalidated on restart | +| `API_AUTH_USER` + `API_AUTH_PASS` | unset | **Legacy** — no longer gates the dashboard (SSO does, §2.6b). Now only used to build the Basic header for outbound loopback calls to the harness (`caAuthHeader`) | | `AGENTOS_INGEST_TOKEN` | unset | Bearer token guarding `POST /agentos/api/ingest/events` (the Python SDK's telemetry ingest). When unset the route is **open** (anonymous writes to registry/logs/sessions) — set it on any network-exposed pod. The SDK must send the same value as `AGENTOS_INGEST_TOKEN`. | +| **Auth / RBAC** (§2.6b) | — | `KEYCLOAK_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (+ optional `OIDC_AUDIENCE`/`OIDC_REDIRECT_URI`/`OIDC_POST_LOGOUT_URI`/`OIDC_ROLES_CLAIM`/`OIDC_GROUPS_CLAIM`); `AGENTOS_DEFAULT_ROLE`, `AGENTOS_BOOTSTRAP_ADMINS`, `AGENTOS_DEV_AUTH=1` (local only); `KEYCLOAK_ADMIN_CLIENT_ID`/`SECRET` for the Groups view + group backfill. Provision with `pnpm provision:keycloak`. | +| **Git credentials** (§2.6c) | unset | `AGENTOS_CREDENTIALS_KEY` (base64 32B; **fail-closed** for credentials CRUD/resolve) + optional `AGENTOS_CREDENTIALS_KEY_OLD` for rotation. | +| `AGENTOS_API_KEY_PEPPER` / `AGENTOS_INTROSPECTION_SECRET` | unset | HMAC pepper for `api_keys` hashing; shared secret guarding `/agentos/api/keys/introspect` (harness↔server) | | `AGENTOS_RUNTIME` | unset | Default substrate name used by the "Register agent" form (`local` / `bwrap` / `e2b` / `vzvm`) | | `AGENTOS_SEED_DEFAULT` | unset | Set to `1` to auto-seed a default agent into the registry on first boot | | `AGENTOS_DEFAULT_SOURCE` | `github.com/shreyas-lyzr/general-agent` | Used by the seed agent | @@ -433,6 +492,10 @@ export ANTHROPIC_API_KEY=sk-ant-... export TRACE_BACKEND=newrelic export NEW_RELIC_USER_API_KEY=NRAK-... export NEW_RELIC_ACCOUNT_ID=1234567 +export AGENTOS_DEV_AUTH=1 # local only — admin principal, no Keycloak needed (§2.6b) +# To exercise real SSO/RBAC locally instead, drop AGENTOS_DEV_AUTH and set the +# KEYCLOAK_ISSUER_URL / OIDC_* vars (run `pnpm provision:keycloak` first). +# For git-credentials locally: export AGENTOS_CREDENTIALS_KEY=$(openssl rand -base64 32) cd packages/agentos-server && pnpm dev # Terminal 3 — SPA @@ -470,6 +533,8 @@ Chronological from earliest to latest. Each entry has the commit ref where relev | `2756b9a` | `agentos-server`: dashboard API extracted into its own Express service; whole stack dockerized. | | `af47a08` | `engine-claude-agent-sdk`: set `IS_SANDBOX=1` for the spawned Claude CLI (skips first-run telemetry prompts and treats the host as a sandbox). | | `8d829b8` | `agentos`: introduced derived `liveChatCapable` field. SDK writes `source.type="library"` to `agent_registry` for harness-mode agents; UI checks `liveChatCapable` and hides the chat-sandbox button for those. Also strips model prefixes at every Mongo write site. | +| `feat/agentos-auth-rbac-refresh` (pushed) | **AgentOS auth + RBAC overhaul (§2.6b).** Replaced the shared password with Okta→Keycloak OIDC (BFF, httpOnly cookie, `jose` JWKS), DB-backed roles (`roles` collection, Settings→Roles), `ownerGroup`/`ownerUser` hard isolation, reactive token refresh (`/auth/refresh`, rotating refresh cookie), read-only Groups view + Admin-API group backfill, `buildApp()` route restructure into versioned `/agentos/api/v1/*` + trust-boundary groups. SPA: AuthContext + `can()`-gated controls, SSO LoginPage, personalized-workspace home, refined agent cards (3/row). Added `scripts/provision-keycloak.mjs` (`pnpm provision:keycloak`). | +| (same branch) | **Private-GAP git credentials + SHA sync (§2.6c).** `git_credentials` collection (AES-256-GCM at rest, one per `(ownerGroup,host)`), `POST /git-credentials/resolve` for the SDK's `cak_` key, `git-credentials:read`/`:manage` perms. SDK (`computeragent-py`): resolve client + `GIT_CONFIG_*` header injection (token never in argv) + `git rev-parse HEAD` capture → `agent_sha` → registry `sourceSha`/`sourceSyncedAt`. | ### Cross-cutting fixes worth knowing @@ -518,6 +583,13 @@ kustomize edit set image \ | PyPI publish pipeline | `computer-agent-python-sdk/.github/workflows/publish.yml` | | SPA build args | `agentos/Dockerfile` + workflow `VITE_*` vars | | Mongo collections written by SDK | `computeragent-py/src/computeragent/telemetry/sinks/agentos.py` | +| AgentOS auth / OIDC / BFF + refresh | `packages/agentos-server/src/auth/{oidc,authenticate,authorize,ownership,keycloak-admin}.ts`, `routes/auth.ts` | +| Permission catalog + role seeds | `packages/agentos-server/src/auth/permissions.ts`, `stores/role-store.ts` | +| Route composition / trust boundaries | `packages/agentos-server/src/app.ts`, `routes/dashboard.ts` | +| Git-credential store + resolve endpoint | `packages/agentos-server/src/crypto/secret-box.ts`, `stores/git-credential-store.ts`, `routes/git-credentials.ts` | +| SDK private-repo clone (PAT + SHA) | `computeragent-py/src/computeragent/harness/git_credential_client.py`, `substrates/local.py` | +| Keycloak provisioning script | `packages/agentos-server/scripts/provision-keycloak.mjs` (`pnpm provision:keycloak`) | | Migration recipe (NordAssist QA) | `lyzr-experiments/NORDASSIST_MIGRATION.md` | | In-progress 0.2.1 plan | `~/.claude/plans/hey-i-need-the-idempotent-pretzel.md` | +| GAP-auth + SHA-sync plan | `~/.claude/plans/reflective-mapping-lovelace.md` | diff --git a/agentos/src/api.ts b/agentos/src/api.ts index e358b24..b92a92d 100644 --- a/agentos/src/api.ts +++ b/agentos/src/api.ts @@ -249,6 +249,23 @@ export interface ApiKey { revokedAt?: string | null; } +/** A group-scoped git credential (PAT). The secret is never returned — only + * metadata. One credential per (ownerGroup, host). */ +export interface GitCredential { + _id: string; + host: string; + ownerGroup: string; + ownerUser: string; + label: string; + username?: string | null; + last4: string; + hasSecret: true; + createdBy: string; + createdAt: string; + updatedAt: string; + rotatedAt?: 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 @@ -558,6 +575,13 @@ export const api = { revoke: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/api-keys/${encodeURIComponent(id)}`), }, + gitCredentials: { + list: () => getJSON<{ credentials: GitCredential[] }>("/git-credentials").then((d) => d.credentials), + create: (body: { host: string; group?: string; label: string; token: string; username?: string }) => + postJSON<{ credential: GitCredential }>("/git-credentials", body), + remove: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/git-credentials/${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/SettingsPage.tsx b/agentos/src/components/SettingsPage.tsx index f6e2ac7..63789e3 100644 --- a/agentos/src/components/SettingsPage.tsx +++ b/agentos/src/components/SettingsPage.tsx @@ -3,11 +3,12 @@ * Tabs are gated by the signed-in principal's permissions: "API Keys" needs * keys:read, "Roles" needs roles:manage. A sign-out control lives at the bottom. */ -import { KeyRound, ShieldCheck, Users2, LogOut } from "lucide-react"; +import { KeyRound, ShieldCheck, Users2, LogOut, GitBranch } from "lucide-react"; import { PageHeader } from "./composite/PageHeader.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs.tsx"; import { Button } from "./ui/button.tsx"; import { ApiKeysSection } from "./settings/ApiKeysSection.tsx"; +import { GitCredentialsSection } from "./settings/GitCredentialsSection.tsx"; import { RolesSection } from "./settings/RolesSection.tsx"; import { GroupsSection } from "./settings/GroupsSection.tsx"; import { useAuth } from "../context/AuthContext.tsx"; @@ -15,15 +16,16 @@ import { useAuth } from "../context/AuthContext.tsx"; export function SettingsPage() { const { can, me, logout } = useAuth(); const showKeys = can("keys:read"); + const showGitCreds = can("git-credentials:read"); const showRoles = can("roles:manage"); const showGroups = can("groups:read"); - const defaultTab = showKeys ? "api-keys" : showRoles ? "roles" : showGroups ? "groups" : "none"; + const defaultTab = showKeys ? "api-keys" : showGitCreds ? "git-credentials" : showRoles ? "roles" : showGroups ? "groups" : "none"; return (
- {showKeys || showRoles || showGroups ? ( + {showKeys || showGitCreds || showRoles || showGroups ? ( {showKeys && ( @@ -31,6 +33,11 @@ export function SettingsPage() { API Keys )} + {showGitCreds && ( + + Git Credentials + + )} {showRoles && ( Roles @@ -47,6 +54,11 @@ export function SettingsPage() { )} + {showGitCreds && ( + + + + )} {showRoles && ( diff --git a/agentos/src/components/settings/GitCredentialsSection.tsx b/agentos/src/components/settings/GitCredentialsSection.tsx new file mode 100644 index 0000000..7fd61ec --- /dev/null +++ b/agentos/src/components/settings/GitCredentialsSection.tsx @@ -0,0 +1,229 @@ +/** + * Git Credentials settings section — store the PATs the ComputerAgent SDK uses + * to clone PRIVATE GAP repos. A credential is owned by a GROUP (team) and scoped + * to one HOST (one PAT per group+host). The secret is encrypted server-side and + * is WRITE-ONLY — never shown again after you save it (you already hold the PAT). + * + * Rendered inside SettingsPage's "Git Credentials" tab. Gated by + * `git-credentials:read` (tab) / `git-credentials:manage` (create+delete). + */ +import { useEffect, useState } from "react"; +import { KeyRound, Trash2, GitBranch } from "lucide-react"; +import { toast } from "sonner"; +import { api, type GitCredential } from "../../api.ts"; +import { useAuth } from "../../context/AuthContext.tsx"; +import { useAssignableGroups } from "../../hooks/useAssignableGroups.ts"; +import { Button } from "../ui/button.tsx"; +import { Input } from "../ui/input.tsx"; +import { Label } from "../ui/label.tsx"; +import { Badge } from "../ui/badge.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog.tsx"; + +function fmt(d?: string | null): string { + if (!d) return "—"; + const t = new Date(d); + return Number.isNaN(t.getTime()) ? "—" : t.toLocaleString(); +} + +export function GitCredentialsSection() { + const { can } = useAuth(); + const canManage = can("git-credentials:manage"); + const [creds, setCreds] = useState([]); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + // Create form + const [host, setHost] = useState("github.com"); + const [label, setLabel] = useState(""); + const [token, setToken] = useState(""); + const [username, setUsername] = useState(""); + const [group, setGroup] = useState(""); + const [creating, setCreating] = useState(false); + + const groupChoices = useAssignableGroups(); + useEffect(() => { + if (!group && groupChoices.length) setGroup(groupChoices[0]!); + }, [groupChoices, group]); + + const [pendingDelete, setPendingDelete] = useState(null); + + const load = () => { + setLoading(true); + setErr(null); + api.gitCredentials + .list() + .then(setCreds) + .catch((e) => setErr(String(e))) + .finally(() => setLoading(false)); + }; + useEffect(load, []); + + const create = async () => { + if (!host.trim()) return toast.error("Host is required (e.g. github.com)."); + if (!label.trim()) return toast.error("Give the credential a label."); + if (!token.trim()) return toast.error("Paste the PAT."); + if (!group) return toast.error("Pick the owning group."); + setCreating(true); + try { + await api.gitCredentials.create({ + host: host.trim(), + label: label.trim(), + token: token.trim(), + group, + ...(username.trim() ? { username: username.trim() } : {}), + }); + toast.success("Credential saved."); + setLabel(""); + setToken(""); + setUsername(""); + load(); + } catch (e) { + toast.error(`Save failed: ${String(e)}`); + } finally { + setCreating(false); + } + }; + + const confirmDelete = async () => { + if (!pendingDelete) return; + try { + await api.gitCredentials.remove(pendingDelete._id); + toast.success(`Deleted "${pendingDelete.label}".`); + setPendingDelete(null); + load(); + } catch (e) { + toast.error(`Delete failed: ${String(e)}`); + } + }; + + return ( +
+

+ Personal access tokens the SDK uses to clone private GAP repos. A + credential is owned by a group and scoped to one host — one PAT per group per host. Tokens are encrypted at + rest and never shown again after you save them; saving again for the same + group + host rotates the token. +

+ + {/* Create — only for managers. */} + {canManage && ( +
+
+ Add a credential +
+
+
+ + setHost(e.target.value)} /> +
+
+ + +
+
+ + setLabel(e.target.value)} /> +
+
+
+
+ + setToken(e.target.value)} /> +
+
+ + setUsername(e.target.value)} /> +
+ +
+

+ GitHub fine-grained/classic tokens use the default x-access-token username; + GitLab uses oauth2, Bitbucket your username. +

+
+ )} + + {/* List */} +
+
+ Credentials {creds.length > 0 && · {creds.length}} +
+ {err &&
{err}
} + {loading ? ( +
Loading…
+ ) : creds.length === 0 ? ( +
No credentials yet.{canManage ? " Add one above." : ""}
+ ) : ( +
+ {creds.map((c) => ( +
+ + + +
+
+ {c.label} + {c.host} + group: {c.ownerGroup} +
+
+ {c.username || "x-access-token"} · •••{c.last4} +
+
+
+
added {fmt(c.createdAt)} · by {c.createdBy}
+
{c.rotatedAt ? `rotated ${fmt(c.rotatedAt)}` : `updated ${fmt(c.updatedAt)}`}
+
+ {canManage && ( + + )} +
+ ))} +
+ )} +
+ + !o && setPendingDelete(null)}> + + + Delete credential? + + Removes the {pendingDelete?.host} token for group{" "} + {pendingDelete?.ownerGroup}. Agents that clone private repos on this + host will fail to authenticate until a new credential is added. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/packages/agentos-server/package.json b/packages/agentos-server/package.json index 160d29d..333079c 100644 --- a/packages/agentos-server/package.json +++ b/packages/agentos-server/package.json @@ -13,6 +13,7 @@ "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", + "provision:keycloak": "node scripts/provision-keycloak.mjs", "clean": "rm -rf dist .turbo *.tsbuildinfo" }, "dependencies": { diff --git a/packages/agentos-server/scripts/provision-keycloak.mjs b/packages/agentos-server/scripts/provision-keycloak.mjs new file mode 100644 index 0000000..ec246bb --- /dev/null +++ b/packages/agentos-server/scripts/provision-keycloak.mjs @@ -0,0 +1,438 @@ +// Idempotent Keycloak provisioning for AgentOS. +// +// Creates (or updates, re-runnable) everything AgentOS needs in a Keycloak +// realm so nothing has to be clicked together by hand: +// +// 1. the realm (REALM, created if missing) +// 2. realm roles agentos-admin / -editor / -viewer +// 3. the OIDC client (confidential, BFF) CLIENT_ID, with a pinned secret, +// standard flow + service accounts, redirect URIs / web origins +// 4. protocol mappers on the client Group Membership -> `groups` +// (+ optional audience mapper) (realm roles ride the default +// `roles` client scope already) +// 5. service-account realm-management roles view-realm / view-users / view-clients +// so the server's Admin-API fallback (groups view + group enrichment) works +// 6. (optional) groups GROUPS_JSON, each optionally role-mapped +// 7. (optional) bootstrap admin BOOTSTRAP_ADMIN_EMAIL -> agentos-admin +// 8. (optional) test users USERS_JSON (local testing) +// 9. (optional) Okta IdP federation OKTA_ISSUER/CLIENT_ID/CLIENT_SECRET +// +// Auth: a Keycloak master-realm admin (password grant via the built-in +// `admin-cli` client). This is the bootstrap credential — it is NOT stored. +// +// ── Usage ──────────────────────────────────────────────────────────────────── +// KEYCLOAK_URL=https://keycloak.test.studio.lyzr.ai \ +// KC_ADMIN_USER=admin KC_ADMIN_PASSWORD=secret \ +// REALM=computer-agent \ +// CLIENT_ID=agent-os-server-client \ +// CLIENT_SECRET=yL2WJtHVa0qINSOy0AUEIO40KY4FcPyW \ +// REDIRECT_URIS=http://localhost:8788/agentos/api/v1/auth/callback,http://localhost:5173/* \ +// node packages/agentos-server/scripts/provision-keycloak.mjs +// +// # preview without writing: +// DRY_RUN=1 ... node packages/agentos-server/scripts/provision-keycloak.mjs +// +// Re-running is safe: each object is checked and created-or-updated, never +// duplicated. + +// ── Config ─────────────────────────────────────────────────────────────────── +const KC = (process.env.KEYCLOAK_URL || "").replace(/\/+$/, ""); +const ADMIN_REALM = process.env.KC_ADMIN_REALM || "master"; +const ADMIN_CLIENT = process.env.KC_ADMIN_CLIENT || "admin-cli"; +const ADMIN_USER = process.env.KC_ADMIN_USER || ""; +const ADMIN_PASS = process.env.KC_ADMIN_PASSWORD || ""; + +const REALM = process.env.REALM || "computer-agent"; +const CLIENT_ID = process.env.CLIENT_ID || "agent-os-server-client"; +const CLIENT_SECRET = process.env.CLIENT_SECRET || ""; // pin one, or leave blank to keep/generate +const CLIENT_NAME = process.env.CLIENT_NAME || "AgentOS Server (BFF)"; + +const REDIRECT_URIS = (process.env.REDIRECT_URIS || + "http://localhost:8788/agentos/api/v1/auth/callback,http://localhost:5173/*") + .split(",").map((s) => s.trim()).filter(Boolean); +const WEB_ORIGINS = (process.env.WEB_ORIGINS || "+").split(",").map((s) => s.trim()).filter(Boolean); + +const GROUPS_FULL_PATH = (process.env.GROUPS_FULL_PATH ?? "true") === "true"; +const AUDIENCE = process.env.OIDC_AUDIENCE || ""; // optional audience mapper + +const DRY_RUN = process.env.DRY_RUN === "1"; + +// Optional extras. +const GROUPS_JSON = process.env.GROUPS_JSON || ""; // e.g. '[{"name":"Platform"},{"name":"agentos-admins","roles":["agentos-admin"]}]' +const BOOTSTRAP_ADMIN_EMAIL = process.env.BOOTSTRAP_ADMIN_EMAIL || ""; +const USERS_JSON = process.env.USERS_JSON || ""; // e.g. '[{"email":"a@b.com","password":"x","groups":["Platform"],"roles":["agentos-viewer"]}]' +const OKTA_ISSUER = (process.env.OKTA_ISSUER || "").replace(/\/+$/, ""); +const OKTA_CLIENT_ID = process.env.OKTA_CLIENT_ID || ""; +const OKTA_CLIENT_SECRET = process.env.OKTA_CLIENT_SECRET || ""; + +// The three roles AgentOS seeds into its `roles` collection. Keycloak only needs +// the NAMES to match; the permission sets live (and are editable) in AgentOS. +const REALM_ROLES = [ + { name: "agentos-admin", description: "AgentOS: full access to everything." }, + { name: "agentos-editor", description: "AgentOS: manage and run agents, schedules, evals." }, + { name: "agentos-viewer", description: "AgentOS: read-only access." }, +]; + +// realm-management client roles granted to the BFF client's service account, so +// the server can read groups/members/user-groups via the Admin API. +const SERVICE_ACCOUNT_ROLES = ["view-realm", "view-users", "view-clients"]; + +if (!KC || !ADMIN_USER || !ADMIN_PASS) { + console.error("✗ KEYCLOAK_URL, KC_ADMIN_USER and KC_ADMIN_PASSWORD are required."); + process.exit(1); +} + +// ── Logging helpers ────────────────────────────────────────────────────────── +const log = { + step: (m) => console.log(`\n▸ ${m}`), + ok: (m) => console.log(` ✓ ${m}`), + add: (m) => console.log(` + ${m}`), + upd: (m) => console.log(` ~ ${m}`), + skip: (m) => console.log(` · ${m}`), + warn: (m) => console.warn(` ! ${m}`), +}; +const dry = (m) => DRY_RUN && console.log(` (dry-run) would ${m}`); + +// ── Admin REST client ──────────────────────────────────────────────────────── +let token = ""; + +async function getAdminToken() { + const body = new URLSearchParams({ + grant_type: "password", + client_id: ADMIN_CLIENT, + username: ADMIN_USER, + password: ADMIN_PASS, + }); + const r = await fetch(`${KC}/realms/${ADMIN_REALM}/protocol/openid-connect/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + if (!r.ok) { + const d = await r.text().catch(() => ""); + throw new Error(`admin login failed: ${r.status} ${d.slice(0, 300)}`); + } + token = (await r.json()).access_token; +} + +// Returns parsed JSON, the Location header (for POST creates), or null (204). +async function kc(method, path, body) { + const r = await fetch(`${KC}/admin/realms${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body !== undefined ? { "content-type": "application/json" } : {}), + accept: "application/json", + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + if (!r.ok) { + const d = await r.text().catch(() => ""); + const err = new Error(`${method} ${path} → ${r.status} ${d.slice(0, 300)}`); + err.status = r.status; + throw err; + } + if (r.status === 204) return { location: r.headers.get("location") }; + const text = await r.text(); + const json = text ? JSON.parse(text) : null; + return { json, location: r.headers.get("location") }; +} + +const idFromLocation = (loc) => (loc ? loc.split("/").pop() : null); + +// ── 1. Realm ─────────────────────────────────────────────────────────────── +async function ensureRealm() { + log.step(`Realm "${REALM}"`); + try { + await kc("GET", `/${REALM}`); + log.ok("exists"); + } catch (e) { + if (e.status !== 404) throw e; + if (DRY_RUN) return dry(`create realm ${REALM}`); + await kc("POST", ``, { realm: REALM, enabled: true, displayName: "ComputerAgent" }); + log.add("created"); + } +} + +// ── 2. Realm roles ─────────────────────────────────────────────────────────── +async function ensureRealmRoles() { + log.step("Realm roles"); + for (const role of REALM_ROLES) { + try { + await kc("GET", `/${REALM}/roles/${encodeURIComponent(role.name)}`); + log.ok(role.name); + } catch (e) { + if (e.status !== 404) throw e; + if (DRY_RUN) { dry(`create role ${role.name}`); continue; } + await kc("POST", `/${REALM}/roles`, role); + log.add(role.name); + } + } +} + +// ── 3. OIDC client ─────────────────────────────────────────────────────────── +async function ensureClient() { + log.step(`OIDC client "${CLIENT_ID}"`); + const found = (await kc("GET", `/${REALM}/clients?clientId=${encodeURIComponent(CLIENT_ID)}`)).json; + const desired = { + clientId: CLIENT_ID, + name: CLIENT_NAME, + protocol: "openid-connect", + enabled: true, + publicClient: false, // confidential — has a secret + standardFlowEnabled: true, // authorization code (BFF) + directAccessGrantsEnabled: false, + serviceAccountsEnabled: true, // for the Admin-API fallback + redirectUris: REDIRECT_URIS, + webOrigins: WEB_ORIGINS, + fullScopeAllowed: true, + attributes: { "post.logout.redirect.uris": "+" }, + ...(CLIENT_SECRET ? { secret: CLIENT_SECRET } : {}), + }; + + let uuid; + if (found && found.length) { + uuid = found[0].id; + if (DRY_RUN) { dry(`update client ${CLIENT_ID}`); } + else { await kc("PUT", `/${REALM}/clients/${uuid}`, { ...found[0], ...desired }); log.upd("updated"); } + } else if (DRY_RUN) { + dry(`create client ${CLIENT_ID}`); + return null; + } else { + const res = await kc("POST", `/${REALM}/clients`, desired); + uuid = idFromLocation(res.location); + log.add("created"); + } + + if (uuid && !DRY_RUN) { + const sec = (await kc("GET", `/${REALM}/clients/${uuid}/client-secret`)).json; + log.ok(`client secret: ${CLIENT_SECRET ? "set as provided" : sec?.value ?? "(unknown)"}`); + if (!CLIENT_SECRET && sec?.value) log.warn(`copy this into OIDC_CLIENT_SECRET → ${sec.value}`); + } + return uuid; +} + +// ── 4. Protocol mappers ────────────────────────────────────────────────────── +async function ensureMappers(clientUuid) { + log.step("Protocol mappers"); + if (!clientUuid) return dry("add groups (+ audience) mappers"); + const existing = (await kc("GET", `/${REALM}/clients/${clientUuid}/protocol-mappers/models`)).json || []; + const has = (name) => existing.some((m) => m.name === name); + + const mappers = [ + { + name: "groups", + protocol: "openid-connect", + protocolMapper: "oidc-group-membership-mapper", + config: { + "claim.name": "groups", + "full.path": String(GROUPS_FULL_PATH), + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + }, + }, + ]; + if (AUDIENCE) { + mappers.push({ + name: "audience", + protocol: "openid-connect", + protocolMapper: "oidc-audience-mapper", + config: { + "included.client.audience": AUDIENCE, + "id.token.claim": "false", + "access.token.claim": "true", + }, + }); + } + + for (const m of mappers) { + if (has(m.name)) { log.ok(`${m.name} (exists)`); continue; } + if (DRY_RUN) { dry(`add mapper ${m.name}`); continue; } + await kc("POST", `/${REALM}/clients/${clientUuid}/protocol-mappers/models`, m); + log.add(`mapper ${m.name}`); + } + log.skip("realm roles ride the default `roles` client scope (realm_access.roles)"); +} + +// ── 5. Service-account realm-management roles ──────────────────────────────── +async function ensureServiceAccountRoles(clientUuid) { + log.step("Service-account roles (realm-management)"); + if (!clientUuid) return dry(`grant ${SERVICE_ACCOUNT_ROLES.join(", ")} to the service account`); + + const saUser = (await kc("GET", `/${REALM}/clients/${clientUuid}/service-account-user`)).json; + const rm = ((await kc("GET", `/${REALM}/clients?clientId=realm-management`)).json || [])[0]; + if (!saUser || !rm) { log.warn("service account or realm-management client not found"); return; } + + const rmRoles = (await kc("GET", `/${REALM}/clients/${rm.id}/roles`)).json || []; + const assigned = (await kc("GET", `/${REALM}/users/${saUser.id}/role-mappings/clients/${rm.id}`)).json || []; + const assignedNames = new Set(assigned.map((r) => r.name)); + + const toAdd = SERVICE_ACCOUNT_ROLES + .filter((n) => !assignedNames.has(n)) + .map((n) => rmRoles.find((r) => r.name === n)) + .filter(Boolean) + .map((r) => ({ id: r.id, name: r.name })); + + for (const n of SERVICE_ACCOUNT_ROLES) if (assignedNames.has(n)) log.ok(`${n} (already granted)`); + if (!toAdd.length) return; + if (DRY_RUN) return dry(`grant ${toAdd.map((r) => r.name).join(", ")}`); + await kc("POST", `/${REALM}/users/${saUser.id}/role-mappings/clients/${rm.id}`, toAdd); + for (const r of toAdd) log.add(`granted ${r.name}`); +} + +// ── 6. Groups (optional) ───────────────────────────────────────────────────── +async function ensureGroups() { + if (!GROUPS_JSON) return; + let defs; + try { defs = JSON.parse(GROUPS_JSON); } catch { log.warn("GROUPS_JSON is not valid JSON — skipping"); return; } + if (!Array.isArray(defs) || !defs.length) return; + + log.step("Groups"); + const existing = (await kc("GET", `/${REALM}/groups?max=500`)).json || []; + for (const def of defs) { + const name = typeof def === "string" ? def : def.name; + if (!name) continue; + let group = existing.find((g) => g.name === name); + if (group) log.ok(name); + else if (DRY_RUN) { dry(`create group ${name}`); continue; } + else { + const res = await kc("POST", `/${REALM}/groups`, { name }); + group = { id: idFromLocation(res.location), name }; + log.add(name); + } + // Optional realm-role mapping for the group. + const roleNames = (def && def.roles) || []; + if (group?.id && roleNames.length && !DRY_RUN) { + const reps = []; + for (const rn of roleNames) { + try { reps.push((await kc("GET", `/${REALM}/roles/${encodeURIComponent(rn)}`)).json); } + catch { log.warn(`role ${rn} not found for group ${name}`); } + } + if (reps.length) { + await kc("POST", `/${REALM}/groups/${group.id}/role-mappings/realm`, reps.map((r) => ({ id: r.id, name: r.name }))); + log.add(`${name} → ${reps.map((r) => r.name).join(", ")}`); + } + } + } +} + +// ── 7. Bootstrap admin (optional) ──────────────────────────────────────────── +async function ensureBootstrapAdmin() { + if (!BOOTSTRAP_ADMIN_EMAIL) return; + log.step(`Bootstrap admin (${BOOTSTRAP_ADMIN_EMAIL} → agentos-admin)`); + const users = (await kc("GET", `/${REALM}/users?email=${encodeURIComponent(BOOTSTRAP_ADMIN_EMAIL)}&exact=true`)).json || []; + if (!users.length) { log.warn("user not found (federate or create them first)"); return; } + const role = (await kc("GET", `/${REALM}/roles/agentos-admin`)).json; + if (DRY_RUN) return dry(`assign agentos-admin to ${BOOTSTRAP_ADMIN_EMAIL}`); + await kc("POST", `/${REALM}/users/${users[0].id}/role-mappings/realm`, [{ id: role.id, name: role.name }]); + log.add(`assigned agentos-admin to ${BOOTSTRAP_ADMIN_EMAIL}`); +} + +// ── 8. Test users (optional) ───────────────────────────────────────────────── +async function ensureUsers() { + if (!USERS_JSON) return; + let defs; + try { defs = JSON.parse(USERS_JSON); } catch { log.warn("USERS_JSON is not valid JSON — skipping"); return; } + if (!Array.isArray(defs) || !defs.length) return; + + log.step("Test users"); + for (const u of defs) { + if (!u.email) continue; + let user = ((await kc("GET", `/${REALM}/users?email=${encodeURIComponent(u.email)}&exact=true`)).json || [])[0]; + if (user) log.ok(u.email); + else if (DRY_RUN) { dry(`create user ${u.email}`); continue; } + else { + const res = await kc("POST", `/${REALM}/users`, { + username: u.email, email: u.email, enabled: true, emailVerified: true, + firstName: u.firstName || u.email.split("@")[0], lastName: u.lastName || "", + ...(u.password ? { credentials: [{ type: "password", value: u.password, temporary: false }] } : {}), + }); + user = { id: idFromLocation(res.location) }; + log.add(u.email); + } + if (DRY_RUN || !user?.id) continue; + for (const rn of u.roles || []) { + try { + const role = (await kc("GET", `/${REALM}/roles/${encodeURIComponent(rn)}`)).json; + await kc("POST", `/${REALM}/users/${user.id}/role-mappings/realm`, [{ id: role.id, name: role.name }]); + log.add(`${u.email} → ${rn}`); + } catch { log.warn(`role ${rn} not assignable to ${u.email}`); } + } + for (const gn of u.groups || []) { + const g = ((await kc("GET", `/${REALM}/groups?search=${encodeURIComponent(gn)}&max=50`)).json || []).find((x) => x.name === gn); + if (g) { await kc("PUT", `/${REALM}/users/${user.id}/groups/${g.id}`); log.add(`${u.email} ∈ ${gn}`); } + else log.warn(`group ${gn} not found for ${u.email}`); + } + } +} + +// ── 9. Okta IdP federation (optional) ──────────────────────────────────────── +async function ensureOktaIdp() { + if (!OKTA_ISSUER || !OKTA_CLIENT_ID || !OKTA_CLIENT_SECRET) return; + log.step('Okta IdP federation (alias "okta")'); + // Pull endpoints from Okta's discovery document. + let disc; + try { + disc = await (await fetch(`${OKTA_ISSUER}/.well-known/openid-configuration`)).json(); + } catch { log.warn("could not fetch Okta discovery — skipping IdP"); return; } + + const config = { + clientId: OKTA_CLIENT_ID, + clientSecret: OKTA_CLIENT_SECRET, + issuer: disc.issuer, + authorizationUrl: disc.authorization_endpoint, + tokenUrl: disc.token_endpoint, + jwksUrl: disc.jwks_uri, + userInfoUrl: disc.userinfo_endpoint, + logoutUrl: disc.end_session_endpoint || "", + defaultScope: "openid profile email groups", + syncMode: "FORCE", + useJwksUrl: "true", + validateSignature: "true", + }; + const rep = { alias: "okta", providerId: "oidc", enabled: true, trustEmail: true, config }; + + try { + await kc("GET", `/${REALM}/identity-provider/instances/okta`); + if (DRY_RUN) dry("update Okta IdP"); + else { await kc("PUT", `/${REALM}/identity-provider/instances/okta`, rep); log.upd("updated"); } + } catch (e) { + if (e.status !== 404) throw e; + if (DRY_RUN) { dry("create Okta IdP"); return; } + await kc("POST", `/${REALM}/identity-provider/instances`, rep); + log.add("created"); + } + log.skip("map each Okta group → KC group with a 'Claim to Group' IdP mapper (per group)"); +} + +// ── Run ────────────────────────────────────────────────────────────────────── +(async () => { + console.log(`Keycloak: ${KC}`); + console.log(`Realm: ${REALM}`); + console.log(`Client: ${CLIENT_ID}`); + if (DRY_RUN) console.log("Mode: DRY RUN (no writes)\n"); + + await getAdminToken(); + await ensureRealm(); + await ensureRealmRoles(); + const clientUuid = await ensureClient(); + await ensureMappers(clientUuid); + await ensureServiceAccountRoles(clientUuid); + await ensureGroups(); + await ensureBootstrapAdmin(); + await ensureUsers(); + await ensureOktaIdp(); + + console.log(`\n${DRY_RUN ? "Dry run complete — nothing written." : "Done. Keycloak is provisioned for AgentOS."}`); + if (!DRY_RUN) { + console.log("\nNext: set these on the agentos-server (already match what was created):"); + console.log(` KEYCLOAK_ISSUER_URL=${KC}/realms/${REALM}`); + console.log(` OIDC_CLIENT_ID=${CLIENT_ID}`); + console.log(` OIDC_CLIENT_SECRET=${CLIENT_SECRET || ""}`); + console.log(` OIDC_REDIRECT_URI=`); + } +})().catch((e) => { + console.error(`\n✗ ${e.message}`); + process.exit(1); +}); diff --git a/packages/agentos-server/src/auth/permissions.ts b/packages/agentos-server/src/auth/permissions.ts index 970c7e6..87ebe12 100644 --- a/packages/agentos-server/src/auth/permissions.ts +++ b/packages/agentos-server/src/auth/permissions.ts @@ -32,6 +32,8 @@ export const PERMISSIONS: PermissionDef[] = [ { key: "obs:read", description: "View observability traces and dashboards" }, { key: "keys:read", description: "View API keys" }, { key: "keys:manage", description: "Mint and revoke API keys" }, + { key: "git-credentials:read", description: "View git credentials (metadata only) and resolve them for cloning" }, + { key: "git-credentials:manage", description: "Create, rotate, and delete git credentials (PATs)" }, { key: "roles:manage", description: "View and edit roles and permissions" }, { key: "groups:read", description: "View groups + members (read-only, from Keycloak)" }, ]; diff --git a/packages/agentos-server/src/crypto/secret-box.test.ts b/packages/agentos-server/src/crypto/secret-box.test.ts new file mode 100644 index 0000000..9baeb19 --- /dev/null +++ b/packages/agentos-server/src/crypto/secret-box.test.ts @@ -0,0 +1,74 @@ +// Unit tests for the AES-256-GCM secret box: round-trip, tamper detection, and +// key rotation via `kid` (decrypt-old / re-wrap-on-write). + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + encryptSecret, + decryptSecret, + needsRewrap, + credentialsKeyConfigured, + resetKeyringForTests, +} from "./secret-box.js"; + +const KEY_A = Buffer.alloc(32, 1).toString("base64"); +const KEY_B = Buffer.alloc(32, 2).toString("base64"); + +beforeEach(() => { + delete process.env["AGENTOS_CREDENTIALS_KEY"]; + delete process.env["AGENTOS_CREDENTIALS_KEY_OLD"]; + resetKeyringForTests(); +}); +afterEach(() => { + delete process.env["AGENTOS_CREDENTIALS_KEY"]; + delete process.env["AGENTOS_CREDENTIALS_KEY_OLD"]; + resetKeyringForTests(); +}); + +describe("secret-box", () => { + it("round-trips a secret and never stores plaintext in the blob", () => { + process.env["AGENTOS_CREDENTIALS_KEY"] = KEY_A; + resetKeyringForTests(); + const blob = encryptSecret("ghp_supersecret"); + expect(blob.v).toBe(1); + expect(JSON.stringify(blob)).not.toContain("ghp_supersecret"); + expect(decryptSecret(blob)).toBe("ghp_supersecret"); + }); + + it("fails closed when no key is configured", () => { + expect(credentialsKeyConfigured()).toBe(false); + expect(() => encryptSecret("x")).toThrow(/AGENTOS_CREDENTIALS_KEY/); + }); + + it("rejects a tampered ciphertext / auth tag", () => { + process.env["AGENTOS_CREDENTIALS_KEY"] = KEY_A; + resetKeyringForTests(); + const blob = encryptSecret("token"); + const tampered = { ...blob, ct: Buffer.from("garbage").toString("base64") }; + expect(() => decryptSecret(tampered)).toThrow(); + }); + + it("decrypts an old-key blob after rotation and flags it for re-wrap", () => { + process.env["AGENTOS_CREDENTIALS_KEY"] = KEY_A; + resetKeyringForTests(); + const oldBlob = encryptSecret("rotate-me"); + + // Rotate: B is current, A becomes the old key. + process.env["AGENTOS_CREDENTIALS_KEY"] = KEY_B; + process.env["AGENTOS_CREDENTIALS_KEY_OLD"] = KEY_A; + resetKeyringForTests(); + + expect(decryptSecret(oldBlob)).toBe("rotate-me"); // old key still decrypts + expect(needsRewrap(oldBlob)).toBe(true); // sealed with non-current key + expect(needsRewrap(encryptSecret("fresh"))).toBe(false); // new writes use current + }); + + it("cannot decrypt once the sealing key is gone entirely", () => { + process.env["AGENTOS_CREDENTIALS_KEY"] = KEY_A; + resetKeyringForTests(); + const blob = encryptSecret("orphan"); + process.env["AGENTOS_CREDENTIALS_KEY"] = KEY_B; // A no longer present anywhere + delete process.env["AGENTOS_CREDENTIALS_KEY_OLD"]; + resetKeyringForTests(); + expect(() => decryptSecret(blob)).toThrow(/no credentials key/); + }); +}); diff --git a/packages/agentos-server/src/crypto/secret-box.ts b/packages/agentos-server/src/crypto/secret-box.ts new file mode 100644 index 0000000..9a09c28 --- /dev/null +++ b/packages/agentos-server/src/crypto/secret-box.ts @@ -0,0 +1,116 @@ +// AES-256-GCM "secret box" for encrypting credentials at rest (git PATs). +// +// API keys are HASHED (one-way) — they only ever need to be matched. A git PAT +// is different: the server must hand the *plaintext* back to the SDK so it can +// clone, so the PAT is ENCRYPTED (reversible) rather than hashed. +// +// Key material comes from `AGENTOS_CREDENTIALS_KEY` (base64 of 32 random bytes). +// An optional `AGENTOS_CREDENTIALS_KEY_OLD` lets you ROTATE: both keys can +// decrypt (selected per blob via `kid`), but new writes always use the current +// key, so ciphertext re-wraps lazily on the next update. +// +// Fail-closed: any encrypt/decrypt with no configured key throws — the +// credential store is unusable without it (private-repo auth simply won't work), +// but public-repo loading is unaffected because it never touches this module. + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +const IV_BYTES = 12; // standard GCM nonce length +const KEY_BYTES = 32; // AES-256 + +export interface EncryptedBlob { + v: 1; // scheme version + kid: string; // which key encrypted this (for rotation) + iv: string; // base64 + ct: string; // base64 ciphertext + tag: string; // base64 GCM auth tag +} + +interface KeyEntry { + kid: string; + key: Buffer; +} + +// Lazily built so a missing key only errors when credentials are actually used, +// not at import time (keeps the server bootable for public-repo-only setups). +let keyring: { current: KeyEntry | null; byKid: Map } | null = null; + +function parseKey(b64: string | undefined): Buffer | null { + if (!b64) return null; + let buf: Buffer; + try { + buf = Buffer.from(b64, "base64"); + } catch { + return null; + } + return buf.length === KEY_BYTES ? buf : null; +} + +/** Stable, non-secret id for a key (so a blob records which key sealed it). */ +function kidOf(key: Buffer): string { + return createHash("sha256").update(key).digest("hex").slice(0, 12); +} + +function loadKeyring(): { current: KeyEntry | null; byKid: Map } { + if (keyring) return keyring; + const byKid = new Map(); + const current = parseKey(process.env["AGENTOS_CREDENTIALS_KEY"]); + const old = parseKey(process.env["AGENTOS_CREDENTIALS_KEY_OLD"]); + let cur: KeyEntry | null = null; + if (current) { + cur = { kid: kidOf(current), key: current }; + byKid.set(cur.kid, cur); + } + if (old) { + const e = { kid: kidOf(old), key: old }; + if (!byKid.has(e.kid)) byKid.set(e.kid, e); + } + keyring = { current: cur, byKid }; + return keyring; +} + +/** Test/boot hook: re-read env (e.g. after setting keys in a test). */ +export function resetKeyringForTests(): void { + keyring = null; +} + +/** True when at least the current key is configured. */ +export function credentialsKeyConfigured(): boolean { + return loadKeyring().current !== null; +} + +function requireCurrentKey(): KeyEntry { + const { current } = loadKeyring(); + if (!current) { + throw new Error( + "AGENTOS_CREDENTIALS_KEY is not set (or not 32 bytes base64) — cannot encrypt credentials", + ); + } + return current; +} + +export function encryptSecret(plaintext: string): EncryptedBlob { + const { kid, key } = requireCurrentKey(); + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return { v: 1, kid, iv: iv.toString("base64"), ct: ct.toString("base64"), tag: tag.toString("base64") }; +} + +export function decryptSecret(blob: EncryptedBlob): string { + const { byKid } = loadKeyring(); + const entry = byKid.get(blob.kid); + if (!entry) { + throw new Error(`no credentials key for kid=${blob.kid} (rotate via AGENTOS_CREDENTIALS_KEY_OLD?)`); + } + const decipher = createDecipheriv("aes-256-gcm", entry.key, Buffer.from(blob.iv, "base64")); + decipher.setAuthTag(Buffer.from(blob.tag, "base64")); + return Buffer.concat([decipher.update(Buffer.from(blob.ct, "base64")), decipher.final()]).toString("utf8"); +} + +/** True when a blob was sealed with a non-current key — caller may re-wrap. */ +export function needsRewrap(blob: EncryptedBlob): boolean { + const { current } = loadKeyring(); + return !!current && blob.kid !== current.kid; +} diff --git a/packages/agentos-server/src/index.ts b/packages/agentos-server/src/index.ts index b558737..b8c88b2 100644 --- a/packages/agentos-server/src/index.ts +++ b/packages/agentos-server/src/index.ts @@ -25,6 +25,7 @@ import { pingClickHouse } from "./clickhouse.js"; import { pingNewRelic } from "./new-relic.js"; import { pingMongo, migrateLegacyWebSessions, migrateRegistryObjectIds, ensureRegistryIndexes } from "./mongo.js"; import { apiKeyStore } from "./stores/api-key-store.js"; +import { gitCredentialStore } from "./stores/git-credential-store.js"; import { roleStore } from "./stores/role-store.js"; import { ensureFieldValueMVs } from "./migrations.js"; import { startScheduler } from "./scheduler.js"; @@ -105,6 +106,7 @@ app.listen(PORT, async () => { } await ensureRegistryIndexes(); await apiKeyStore.ensureIndexes(); + await gitCredentialStore.ensureIndexes(); await roleStore.seedDefaults(); // idempotent: agentos-admin/editor/viewer } catch (err) { console.warn("[agentos-server] registry id migration/index failed:", (err as Error).message); diff --git a/packages/agentos-server/src/mongo.ts b/packages/agentos-server/src/mongo.ts index 70960e0..07e01a8 100644 --- a/packages/agentos-server/src/mongo.ts +++ b/packages/agentos-server/src/mongo.ts @@ -113,6 +113,11 @@ export interface RegistryDoc { // creator's principal id) governs mutate/delete. Legacy rows have neither. ownerGroup?: string | null; ownerUser?: string | null; + // GAP source sync — the commit SHA the SDK actually loaded, reported on each + // `session_started` (payload.agent_sha). Keeps the registry view honest about + // which revision is running; a change between runs is logged. + sourceSha?: string | null; + sourceSyncedAt?: Date | null; } export interface ChatPinDoc { diff --git a/packages/agentos-server/src/routes/dashboard.ts b/packages/agentos-server/src/routes/dashboard.ts index fa55f66..2b3f385 100644 --- a/packages/agentos-server/src/routes/dashboard.ts +++ b/packages/agentos-server/src/routes/dashboard.ts @@ -18,6 +18,7 @@ import { logsRouter } from "./logs.js"; import { sessionsRouter } from "./sessions.js"; import { schedulesRouter } from "./schedules.js"; import { apiKeysRouter } from "./api-keys.js"; +import { gitCredentialsRouter } from "./git-credentials.js"; import { rolesRouter } from "./roles.js"; import { groupsRouter } from "./groups.js"; import { chatRouter } from "./chat.js"; @@ -51,6 +52,7 @@ export function mountDashboard(): IRouter { r.use(completionRouter); ///completion r.use(evalsRouter); // /evals/* r.use(apiKeysRouter); // /api-keys + r.use(gitCredentialsRouter); // /git-credentials, /git-credentials/resolve r.use(rolesRouter); // /roles, /permissions r.use(groupsRouter); // /groups, /groups/:id/members (read-only, from Keycloak) diff --git a/packages/agentos-server/src/routes/git-credentials.ts b/packages/agentos-server/src/routes/git-credentials.ts new file mode 100644 index 0000000..b8ac898 --- /dev/null +++ b/packages/agentos-server/src/routes/git-credentials.ts @@ -0,0 +1,120 @@ +// Git credentials (PATs) — create / list / delete (dashboard) + resolve (SDK). +// +// A credential is owned by a GROUP (tenancy) and scoped to one HOST. The secret +// is AES-256-GCM encrypted at rest and only ever decrypted on `/resolve`, after +// RBAC + group-ownership checks. +// +// POST /git-credentials git-credentials:manage create/rotate +// GET /git-credentials git-credentials:read list (redacted) +// DELETE /git-credentials/:id git-credentials:manage delete (owner/admin) +// POST /git-credentials/resolve git-credentials:read SDK: fetch the PAT +// +// The resolve route is what the Python SDK calls with its `cak_` key — it works +// because cak_ keys authenticate at the dashboard boundary and yield a service +// principal whose `groups = [key.group]`. + +import { Router, type Router as IRouter } from "express"; +import { gitCredentialStore, normalizeGitHost } from "../stores/git-credential-store.js"; +import { credentialsKeyConfigured } from "../crypto/secret-box.js"; +import { authorize } from "../auth/authorize.js"; +import { canRead, canWrite, pickOwnerGroup } from "../auth/ownership.js"; + +export const gitCredentialsRouter: IRouter = Router(); + +const DEFAULT_USERNAME = "x-access-token"; + +// POST /git-credentials { host, group?, label, token, username? } → 201 { credential } +gitCredentialsRouter.post("/git-credentials", authorize("git-credentials:manage"), async (req, res, next) => { + try { + if (!credentialsKeyConfigured()) { + return res.status(503).json({ error: { code: "CREDENTIALS_KEY_NOT_CONFIGURED", message: "AGENTOS_CREDENTIALS_KEY is not set" } }); + } + const b = (req.body ?? {}) as Record; + const host = typeof b["host"] === "string" ? normalizeGitHost(b["host"]) : ""; + const label = typeof b["label"] === "string" ? b["label"].trim() : ""; + const token = typeof b["token"] === "string" ? b["token"].trim() : ""; + const username = typeof b["username"] === "string" && b["username"].trim() ? b["username"].trim() : null; + if (!host) return res.status(400).json({ error: { code: "MISSING_HOST" } }); + if (!label) return res.status(400).json({ error: { code: "MISSING_LABEL" } }); + if (!token) return res.status(400).json({ error: { code: "MISSING_TOKEN" } }); + + const principal = res.locals.principal!; + // A PAT is always group-owned. pickOwnerGroup enforces membership; a null + // resolved group (user has no groups, requested none) is rejected here. + const picked = pickOwnerGroup(principal, b["group"]); + if (!picked.ok) return res.status(403).json({ error: { code: "GROUP_NOT_ALLOWED", message: `not a member of group: ${String(b["group"])}` } }); + if (!picked.group) return res.status(400).json({ error: { code: "MISSING_GROUP", message: "a credential must be owned by a group" } }); + + const credential = await gitCredentialStore.upsert({ + host, + ownerGroup: picked.group, + ownerUser: principal.id, + label, + plaintextToken: token, + username, + createdBy: principal.id, + }); + // Never echo the token — the operator already holds the PAT. + res.status(201).json({ credential }); + } catch (err) { + next(err); + } +}); + +// GET /git-credentials → { credentials: [...] } (redacted; hard-isolated by group/owner) +gitCredentialsRouter.get("/git-credentials", authorize("git-credentials:read"), async (_req, res, next) => { + try { + const principal = res.locals.principal; + const all = await gitCredentialStore.list(); + const visible = all.filter((c) => canRead(principal, { ownerGroup: c.ownerGroup, ownerUser: c.ownerUser })); + res.json({ credentials: visible }); + } catch (err) { + next(err); + } +}); + +// DELETE /git-credentials/:id → { ok } (creator or admin) +gitCredentialsRouter.delete("/git-credentials/:id", authorize("git-credentials:manage"), async (req, res, next) => { + try { + const id = req.params["id"]!; + const doc = await gitCredentialStore.get(id); + if (!doc) return res.status(404).json({ error: { code: "NOT_FOUND" } }); + if (!canWrite(res.locals.principal, { ownerGroup: doc.ownerGroup, ownerUser: doc.ownerUser })) { + return res.status(403).json({ error: { code: "NOT_OWNER" } }); + } + await gitCredentialStore.remove(id); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +// POST /git-credentials/resolve { repoUrl? | host?, ref? } → { host, username, token, credentialId } +// The SDK endpoint. Strictly scoped to the caller's own groups — NO admin +// bypass — so even an admin key only resolves its own group's secret. +gitCredentialsRouter.post("/git-credentials/resolve", authorize("git-credentials:read"), async (req, res, next) => { + try { + // PAT in the body — never cache, never log. + res.setHeader("Cache-Control", "no-store"); + if (!credentialsKeyConfigured()) { + return res.status(503).json({ error: { code: "CREDENTIALS_KEY_NOT_CONFIGURED" } }); + } + const b = (req.body ?? {}) as Record; + const raw = typeof b["repoUrl"] === "string" ? b["repoUrl"] : typeof b["host"] === "string" ? b["host"] : ""; + const host = normalizeGitHost(raw); + if (!host) return res.status(400).json({ error: { code: "MISSING_HOST_OR_REPO_URL" } }); + + const principal = res.locals.principal!; + const found = await gitCredentialStore.resolve({ ownerGroups: principal.groups, host }); + if (!found) return res.status(404).json({ error: { code: "NO_CREDENTIAL", message: `no git credential for host ${host} in your group(s)` } }); + + res.json({ + host: found.doc.host, + username: found.doc.username || DEFAULT_USERNAME, + token: found.token, + credentialId: found.doc._id, + }); + } catch (err) { + next(err); + } +}); diff --git a/packages/agentos-server/src/stores/git-credential-store.test.ts b/packages/agentos-server/src/stores/git-credential-store.test.ts new file mode 100644 index 0000000..e61be4b --- /dev/null +++ b/packages/agentos-server/src/stores/git-credential-store.test.ts @@ -0,0 +1,135 @@ +// Unit tests for gitCredentialStore — in-memory Mongo fake (supports upsert / +// $setOnInsert / $in / deleteOne). Verifies: encrypted at rest, redacted across +// the boundary, upsert idempotency + rotation on (ownerGroup, host), and that +// resolve decrypts only for a matching group+host. + +import { beforeEach, describe, expect, it } from "vitest"; + +const h = vi.hoisted(() => { + function clone(v: any): any { + if (v instanceof Date) return new Date(v.getTime()); + if (Array.isArray(v)) return v.map(clone); + if (v && typeof v === "object") { + const o: any = {}; + for (const k of Object.keys(v)) o[k] = clone(v[k]); + return o; + } + return v; + } + const matches = (doc: any, filter: any): boolean => + Object.entries(filter ?? {}).every(([k, val]) => { + const dv = doc[k]; + if (val && typeof val === "object" && "$in" in (val as any)) return (val as any).$in.includes(dv); + if (val instanceof Date && dv instanceof Date) return dv.getTime() === val.getTime(); + return dv === val; + }); + + class FakeCollection { + docs = new Map(); + async createIndex() { return "idx"; } + async insertOne(doc: any) { this.docs.set(doc._id, clone(doc)); return { insertedId: doc._id }; } + async findOne(filter: any) { + for (const d of this.docs.values()) if (matches(d, filter)) return clone(d); + return null; + } + find(filter: any = {}) { + const all = [...this.docs.values()].filter((d) => matches(d, filter)).map(clone); + const sort = (spec: any) => { + const [[k, dir]] = Object.entries(spec) as [[string, number]]; + all.sort((a, b) => { + const av = a[k] instanceof Date ? a[k].getTime() : a[k]; + const bv = b[k] instanceof Date ? b[k].getTime() : b[k]; + return av < bv ? -dir : av > bv ? dir : 0; + }); + return { toArray: async () => all }; + }; + return { sort, toArray: async () => all }; + } + async updateOne(filter: any, update: any, opts: any = {}) { + for (const d of this.docs.values()) { + if (matches(d, filter)) { + if (update.$set) Object.assign(d, clone(update.$set)); + return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }; + } + } + if (opts.upsert) { + const doc: any = {}; + Object.assign(doc, clone(update.$setOnInsert ?? {}), clone(update.$set ?? {})); + this.docs.set(doc._id, doc); + return { matchedCount: 0, modifiedCount: 0, upsertedCount: 1 }; + } + return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 }; + } + async deleteOne(filter: any) { + for (const [id, d] of this.docs) if (matches(d, filter)) { this.docs.delete(id); return { deletedCount: 1 }; } + return { deletedCount: 0 }; + } + } + const coll = new FakeCollection(); + return { coll, fakeDb: { collection: () => coll } }; +}); + +import { vi } from "vitest"; +vi.mock("../mongo.js", () => ({ getDb: async () => h.fakeDb })); + +import { gitCredentialStore, normalizeGitHost } from "./git-credential-store.js"; +import { resetKeyringForTests } from "../crypto/secret-box.js"; + +beforeEach(() => { + h.coll.docs.clear(); + process.env["AGENTOS_CREDENTIALS_KEY"] = Buffer.alloc(32, 7).toString("base64"); + resetKeyringForTests(); +}); + +describe("normalizeGitHost", () => { + it("reduces any repo URL shape to a bare lowercased host", () => { + expect(normalizeGitHost("https://github.com/o/r")).toBe("github.com"); + expect(normalizeGitHost("github.com/o/r")).toBe("github.com"); + expect(normalizeGitHost("git@github.com:o/r.git")).toBe("github.com"); + expect(normalizeGitHost("ssh://git@GitHub.com/o/r")).toBe("github.com"); + expect(normalizeGitHost("gitlab.example.com:443")).toBe("gitlab.example.com"); + }); +}); + +describe("gitCredentialStore", () => { + it("encrypts at rest and never returns the secret across the boundary", async () => { + const red = await gitCredentialStore.upsert({ + host: "github.com", ownerGroup: "Platform", ownerUser: "u1", + label: "plat", plaintextToken: "ghp_abcd1234", createdBy: "u1", + }); + expect((red as any).secret).toBeUndefined(); + expect(red.hasSecret).toBe(true); + expect(red.last4).toBe("1234"); + const stored = [...h.coll.docs.values()][0]; + expect(JSON.stringify(stored.secret)).not.toContain("ghp_abcd1234"); // encrypted + }); + + it("is unique on (ownerGroup, host): re-upsert rotates, never duplicates", async () => { + await gitCredentialStore.upsert({ host: "github.com", ownerGroup: "Platform", ownerUser: "u1", label: "v1", plaintextToken: "tok-one", createdBy: "u1" }); + const first = [...h.coll.docs.values()][0]; + await gitCredentialStore.upsert({ host: "github.com", ownerGroup: "Platform", ownerUser: "u1", label: "v2", plaintextToken: "tok-two", createdBy: "u1" }); + expect(h.coll.docs.size).toBe(1); // same (group, host) → same doc + const after = [...h.coll.docs.values()][0]; + expect(after._id).toBe(first._id); + expect(after.rotatedAt).toBeTruthy(); + const r = await gitCredentialStore.resolve({ ownerGroups: ["Platform"], host: "github.com" }); + expect(r?.token).toBe("tok-two"); + }); + + it("resolve returns the plaintext only for a matching group + host", async () => { + await gitCredentialStore.upsert({ host: "github.com", ownerGroup: "Platform", ownerUser: "u1", label: "p", plaintextToken: "secret-pat", createdBy: "u1" }); + expect((await gitCredentialStore.resolve({ ownerGroups: ["Platform"], host: "https://github.com/o/r" }))?.token).toBe("secret-pat"); + expect(await gitCredentialStore.resolve({ ownerGroups: ["OtherTeam"], host: "github.com" })).toBeNull(); // wrong group + expect(await gitCredentialStore.resolve({ ownerGroups: ["Platform"], host: "gitlab.com" })).toBeNull(); // wrong host + expect(await gitCredentialStore.resolve({ ownerGroups: [], host: "github.com" })).toBeNull(); // no groups + }); + + it("list redacts and remove deletes", async () => { + await gitCredentialStore.upsert({ host: "github.com", ownerGroup: "Platform", ownerUser: "u1", label: "p", plaintextToken: "t", createdBy: "u1" }); + const list = await gitCredentialStore.list(); + expect(list).toHaveLength(1); + expect((list[0] as any).secret).toBeUndefined(); + expect(await gitCredentialStore.remove(list[0]!._id)).toBe(true); + expect(await gitCredentialStore.list()).toHaveLength(0); + }); +}); diff --git a/packages/agentos-server/src/stores/git-credential-store.ts b/packages/agentos-server/src/stores/git-credential-store.ts new file mode 100644 index 0000000..8d14095 --- /dev/null +++ b/packages/agentos-server/src/stores/git-credential-store.ts @@ -0,0 +1,134 @@ +// Git credentials (PATs) in MongoDB (`git_credentials`). A credential is owned +// by a GROUP (team) and scoped to one HOST — exactly one PAT per (ownerGroup, +// host). The Python SDK fetches it (via the cak_-key resolve endpoint) to clone +// private GAP repos. +// +// Unlike API keys, the secret is reversible: the PAT is AES-256-GCM encrypted at +// rest (see crypto/secret-box) and decrypted only on the resolve path, after +// RBAC + group-ownership checks. The plaintext is NEVER returned by list/CRUD. + +import { type Collection } from "mongodb"; +import { randomUUID } from "node:crypto"; +import { getDb } from "../mongo.js"; +import { encryptSecret, decryptSecret, type EncryptedBlob } from "../crypto/secret-box.js"; + +export interface GitCredentialDoc { + _id: string; // "gcr_" + uuid slice + host: string; // normalized, lowercased — e.g. "github.com" + ownerGroup: string; // TENANCY — required; a PAT is always group-owned + ownerUser: string; // creator principal id (mutate/delete) + label: string; + secret: EncryptedBlob; // AES-256-GCM — NEVER crosses an API boundary + username?: string | null; // git auth username (default "x-access-token") + last4: string; // display only + createdBy: string; + createdAt: Date; + updatedAt: Date; + rotatedAt?: Date | null; +} + +/** Redacted doc returned across the API boundary — never carries `secret`. */ +export type RedactedGitCredential = Omit & { hasSecret: true }; + +async function coll(): Promise> { + return (await getDb()).collection("git_credentials"); +} + +/** Normalize any repo URL or host string to a bare lowercased host. + * `https://github.com/o/r`, `github.com/o/r`, `git@github.com:o/r`, + * `ssh://git@github.com/o/r`, `github.com:443` → `github.com`. */ +export function normalizeGitHost(input: string): string { + let s = (input ?? "").trim(); + if (!s) return ""; + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // strip scheme:// + s = s.replace(/^[^@]+@/, ""); // strip user@ (incl. git@) + s = s.split(/[/:]/)[0] ?? s; // host ends at first / or : (path or port) + return s.toLowerCase(); +} + +function redact(doc: GitCredentialDoc): RedactedGitCredential { + const { secret: _secret, ...rest } = doc; + return { ...rest, hasSecret: true }; +} + +export const gitCredentialStore = { + /** Unique (ownerGroup, host) — one PAT per group per host. Idempotent. */ + async ensureIndexes(): Promise { + const c = await coll(); + await c.createIndex({ ownerGroup: 1, host: 1 }, { unique: true }); + await c.createIndex({ ownerUser: 1 }); + }, + + /** Create or rotate the (ownerGroup, host) credential. Re-using the same pair + * re-encrypts the new PAT under the same `_id` and stamps `rotatedAt`. */ + async upsert(input: { + host: string; + ownerGroup: string; + ownerUser: string; + label: string; + plaintextToken: string; + username?: string | null; + createdBy: string; + }): Promise { + const c = await coll(); + const host = normalizeGitHost(input.host); + const now = new Date(); + const secret = encryptSecret(input.plaintextToken); + const last4 = input.plaintextToken.slice(-4); + const existing = await c.findOne({ ownerGroup: input.ownerGroup, host }); + await c.updateOne( + { ownerGroup: input.ownerGroup, host }, + { + $set: { + label: input.label, + secret, + last4, + username: input.username ?? null, + ownerUser: input.ownerUser, + updatedAt: now, + ...(existing ? { rotatedAt: now } : {}), + }, + $setOnInsert: { + _id: `gcr_${randomUUID().slice(0, 12)}`, + host, + ownerGroup: input.ownerGroup, + createdBy: input.createdBy, + createdAt: now, + }, + }, + { upsert: true }, + ); + const doc = await c.findOne({ ownerGroup: input.ownerGroup, host }); + return redact(doc!); + }, + + /** All credentials (redacted), newest first. Caller applies ownership filter. */ + async list(): Promise { + const docs = await (await coll()).find({}).sort({ updatedAt: -1 }).toArray(); + return docs.map(redact); + }, + + async get(id: string): Promise { + const doc = await (await coll()).findOne({ _id: id }); + return doc ? redact(doc) : null; + }, + + /** The ONLY decrypt path: return the plaintext PAT for a (group, host) match. + * `ownerGroups` is the caller's groups — a credential is returned only when + * its `ownerGroup` is one of them. */ + async resolve(input: { + ownerGroups: string[]; + host: string; + }): Promise<{ doc: RedactedGitCredential; token: string } | null> { + const host = normalizeGitHost(input.host); + if (!host || input.ownerGroups.length === 0) return null; + const doc = await (await coll()).findOne({ host, ownerGroup: { $in: input.ownerGroups } }); + if (!doc) return null; + return { doc: redact(doc), token: decryptSecret(doc.secret) }; + }, + + async remove(id: string): Promise { + const r = await (await coll()).deleteOne({ _id: id }); + return (r.deletedCount ?? 0) > 0; + }, +}; diff --git a/packages/agentos-server/src/stores/role-store.ts b/packages/agentos-server/src/stores/role-store.ts index 477f32e..58d8fc4 100644 --- a/packages/agentos-server/src/stores/role-store.ts +++ b/packages/agentos-server/src/stores/role-store.ts @@ -35,6 +35,7 @@ const DEFAULT_ROLES: Array> = [ "evals:read", "evals:write", "obs:read", "keys:read", + "git-credentials:read", "git-credentials:manage", ], builtin: true, }, diff --git a/packages/agentos-server/src/stores/telemetry-projection.ts b/packages/agentos-server/src/stores/telemetry-projection.ts index 580e5b5..7c32f74 100644 --- a/packages/agentos-server/src/stores/telemetry-projection.ts +++ b/packages/agentos-server/src/stores/telemetry-projection.ts @@ -138,6 +138,18 @@ async function onSessionStarted(ev: IngestEvent): Promise { ? librarySourceFor(name, payload, description) : inlineSourceFor(name, payload, description); + // GAP source sync — the SDK reports the cloned commit SHA in payload.agent_sha. + // Record it on the registry doc so the dashboard reflects the running revision; + // a change since the last run is logged (the SDK already re-clones each run, so + // nothing to re-materialize server-side). + const observedSha = typeof payload["agent_sha"] === "string" && payload["agent_sha"] ? (payload["agent_sha"] as string) : null; + if (observedSha) { + const prior = await (await registryColl()).findOne({ name }, { projection: { sourceSha: 1 } }); + if (prior?.sourceSha && prior.sourceSha !== observedSha) { + console.log(`[agentos-server] GAP source changed for "${name}": ${prior.sourceSha.slice(0, 8)} → ${observedSha.slice(0, 8)}`); + } + } + // agent_registry upsert (idempotent on agent name). Mongo mints the // surrogate ObjectId `_id` on first insert; `name` is the unique key the // Python/library ingest addresses agents by. @@ -152,6 +164,7 @@ async function onSessionStarted(ev: IngestEvent): Promise { registeredBy: ev.host ?? undefined, updatedAt: now, lastSeen: now, + ...(observedSha ? { sourceSha: observedSha, sourceSyncedAt: now } : {}), }, }, { upsert: true },