Skip to content

Commit a4797a4

Browse files
committed
Merge remote-tracking branch 'origin/deploy' into deploy
2 parents 5c8de11 + a267e30 commit a4797a4

17 files changed

Lines changed: 398 additions & 102 deletions

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Optional GitHub repo **variables** (build-time baked into the SPA bundle):
9292
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.)
9393

9494
**Collections (database = the server's `MONGO_DATABASE`):**
95-
- `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).
95+
- `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). **Identity key:** when the SDK supplies a stable `agent_id` (sparse-unique `agentId` field) the registry + per-agent joins (`sessions`/`chat_sessions`/`agent_logs`) key on it and `name` becomes a mutable display label — so renaming an agent doesn't re-register it. A legacy name-keyed doc is *adopted* (not duplicated) on the first run that carries an id; absent → keyed on `name` as before. Dashboard reads join `{$or:[{agentId},{name}]}` for back-compat.
9696
- `agent_logs` — one doc per conversation (one `ComputerAgent` instance = one log row, multi-turn collapses correctly since the 0.2.0 session-id refactor)
9797
- `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)
9898
- `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).
@@ -405,7 +405,7 @@ pnpm build && pnpm start # node dist/index.js
405405
| `MONGO_URL` | `mongodb+srv://user:pass@cluster/...` — required, server refuses to start without it |
406406
| `MONGO_DATABASE` | Default `computeragent-test`. Set to `computeragent` / `computeragent-prod` per env |
407407
| `CA_BASE` | URL of the harness-server. Default `http://127.0.0.1:8787`. In Docker, use `http://host.docker.internal:8787` (Mac/Win) or the harness container hostname (compose / k8s) |
408-
| `ANTHROPIC_API_KEY` | Powers the `/completion` route (the "agent-less" chat from the SPA home page) |
408+
| `ANTHROPIC_API_KEY` | Powers the `/completion` route (the "agent-less" chat from the SPA home page) **and** the `POST /agentos/api/v1/messages` **Anthropic gateway** (`routes/messages.ts`) — a cak_-authed (`completion:run`) transparent reverse proxy to Anthropic. Lets a local SDK consumer point `ANTHROPIC_BASE_URL=<host>/agentos/api` + `ANTHROPIC_AUTH_TOKEN=cak_…` so the upstream key lives only on the server (see smoke #07). |
409409

410410
**Optional env**
411411

@@ -586,6 +586,8 @@ kustomize edit set image \
586586
| AgentOS auth / OIDC / BFF + refresh | `packages/agentos-server/src/auth/{oidc,authenticate,authorize,ownership,keycloak-admin}.ts`, `routes/auth.ts` |
587587
| Permission catalog + role seeds | `packages/agentos-server/src/auth/permissions.ts`, `stores/role-store.ts` |
588588
| Route composition / trust boundaries | `packages/agentos-server/src/app.ts`, `routes/dashboard.ts` |
589+
| Anthropic model gateway (cak_-authed proxy) | `packages/agentos-server/src/routes/messages.ts` (`POST /agentos/api/v1/messages`); smoke `computeragent-smoke/scripts/07_local_via_agentos_gateway.py` |
590+
| Run-an-agent-by-id (resolve handle) | `packages/agentos-server/src/routes/agents.ts` (`POST /agentos/api/v1/agents/resolve`, by `agentId`, group-scoped); SDK `computeragent-py/src/computeragent/harness/agent_resolve_client.py`; smoke `…/scripts/08_*.py` |
589591
| Git-credential store + resolve endpoint | `packages/agentos-server/src/crypto/secret-box.ts`, `stores/git-credential-store.ts`, `routes/git-credentials.ts` |
590592
| SDK private-repo clone (PAT + SHA) | `computeragent-py/src/computeragent/harness/git_credential_client.py`, `substrates/local.py` |
591593
| Keycloak provisioning script | `packages/agentos-server/scripts/provision-keycloak.mjs` (`pnpm provision:keycloak`) |

agentos/src/api.ts

Lines changed: 9 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
// and handles auth (the subdomain is gated by Caddy basic_auth). In dev, Vite
33
// proxies /api with an injected Basic Auth header. So the bundle never holds creds.
44

5+
// Reactive token refresh (single-flight + replay) lives in lib/auth-fetch, shared
6+
// with obs-api + sse so every surface refreshes. Re-export setAuthLostHandler so
7+
// AuthContext keeps its existing import path.
8+
import { authedFetch, setAuthLostHandler } from "./lib/auth-fetch.ts";
9+
export { setAuthLostHandler };
10+
511
/** Mirrors the protocol's IdentitySource zod schema. The server narrows on
612
* read; the dashboard renders the structured form when present. */
713
export type IdentitySource =
@@ -306,64 +312,13 @@ export interface GroupMember {
306312
roles: string[];
307313
}
308314

309-
// ── Auth: reactive token refresh ─────────────────────────────────────────────
310-
// The BFF session cookie is short-lived (it tracks the Keycloak access-token
311-
// expiry, ~5 min). When a dashboard request 401s we silently POST /auth/refresh
312-
// (which rotates the server-held refresh token and re-signs the cookie) and
313-
// replay the original request once. All concurrent 401s share ONE in-flight
314-
// refresh — refresh tokens rotate and can be spent only once, so a stampede
315-
// would invalidate itself. On a hard refresh failure the session is truly gone:
316-
// we notify AuthContext (→ SSO sign-in screen).
317-
318-
let refreshInFlight: Promise<boolean> | null = null;
319-
let onAuthLost: (() => void) | null = null;
320-
321-
/** AuthContext registers a callback here to flip to the anonymous/login state
322-
* when the refresh token is dead (idle timeout / revocation / logout). */
323-
export function setAuthLostHandler(fn: (() => void) | null): void {
324-
onAuthLost = fn;
325-
}
326-
327-
function tryRefresh(): Promise<boolean> {
328-
if (!refreshInFlight) {
329-
refreshInFlight = fetch(`/api/v1/auth/refresh`, {
330-
method: "POST",
331-
headers: { accept: "application/json" },
332-
credentials: "include",
333-
})
334-
.then((r) => r.ok)
335-
.catch(() => false)
336-
.finally(() => {
337-
refreshInFlight = null;
338-
});
339-
}
340-
return refreshInFlight;
341-
}
342-
343-
/** fetch against the dashboard API with credentials. On 401, refresh once and
344-
* replay; if refresh fails, signal auth-lost and return the 401 response. */
345-
async function authedFetch(path: string, init: RequestInit): Promise<Response> {
346-
const url = `/api/v1${path}`;
347-
const opts: RequestInit = { credentials: "include", ...init };
348-
let r = await fetch(url, opts);
349-
if (r.status === 401) {
350-
const ok = await tryRefresh();
351-
if (ok) {
352-
r = await fetch(url, opts);
353-
} else {
354-
onAuthLost?.();
355-
}
356-
}
357-
return r;
358-
}
359-
360315
async function getJSON<T>(path: string): Promise<T> {
361-
const r = await authedFetch(path, { headers: { accept: "application/json" } });
316+
const r = await authedFetch(`/api/v1${path}`, { headers: { accept: "application/json" } });
362317
if (!r.ok) throw new Error(`${path}${r.status}`);
363318
return r.json() as Promise<T>;
364319
}
365320
async function postJSON<T>(path: string, body: unknown): Promise<T> {
366-
const r = await authedFetch(path, {
321+
const r = await authedFetch(`/api/v1${path}`, {
367322
method: "POST",
368323
headers: { "content-type": "application/json", accept: "application/json" },
369324
body: JSON.stringify(body),
@@ -372,7 +327,7 @@ async function postJSON<T>(path: string, body: unknown): Promise<T> {
372327
return r.json() as Promise<T>;
373328
}
374329
async function reqJSON<T>(method: string, path: string, body?: unknown): Promise<T> {
375-
const r = await authedFetch(path, {
330+
const r = await authedFetch(`/api/v1${path}`, {
376331
method,
377332
headers: { "content-type": "application/json", accept: "application/json" },
378333
...(body !== undefined ? { body: JSON.stringify(body) } : {}),

agentos/src/lib/auth-fetch.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Centralized auth-aware fetch — the SPA's "interceptor" for the BFF session.
2+
//
3+
// The session cookie is short-lived (it tracks the Keycloak access-token expiry,
4+
// ~5 min). When ANY request 401s we silently POST /auth/refresh — which rotates
5+
// the server-held refresh token and re-signs the cookie — then replay the
6+
// request once. All concurrent 401s share ONE in-flight refresh: refresh tokens
7+
// rotate and can be spent only once, so a stampede would invalidate itself.
8+
// On a hard refresh failure the session is truly gone → notify AuthContext
9+
// (which flips to the SSO sign-in screen).
10+
//
11+
// Every network surface routes through here: the dashboard client (api.ts), the
12+
// observability reads (obs-api.ts), and the SSE streams (sse.ts). `authedFetch`
13+
// takes a FULL url (not a prefix) so each caller keeps its own base.
14+
15+
let refreshInFlight: Promise<boolean> | null = null;
16+
let onAuthLost: (() => void) | null = null;
17+
18+
/** AuthContext registers a callback here to flip to the anonymous/login state
19+
* when the refresh token is dead (idle timeout / revocation / logout). */
20+
export function setAuthLostHandler(fn: (() => void) | null): void {
21+
onAuthLost = fn;
22+
}
23+
24+
/** Single-flight refresh. Returns true if the session was renewed. */
25+
export function refreshOnce(): Promise<boolean> {
26+
if (!refreshInFlight) {
27+
refreshInFlight = fetch(`/api/v1/auth/refresh`, {
28+
method: "POST",
29+
headers: { accept: "application/json" },
30+
credentials: "include",
31+
})
32+
.then((r) => r.ok)
33+
.catch(() => false)
34+
.finally(() => {
35+
refreshInFlight = null;
36+
});
37+
}
38+
return refreshInFlight;
39+
}
40+
41+
/** fetch with credentials. On 401: refresh once and replay; if refresh fails,
42+
* signal auth-lost and return the (final) 401 response to the caller. */
43+
export async function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
44+
const opts: RequestInit = { credentials: "include", ...init };
45+
let r = await fetch(url, opts);
46+
if (r.status === 401) {
47+
const ok = await refreshOnce();
48+
if (ok) {
49+
r = await fetch(url, opts);
50+
} else {
51+
onAuthLost?.();
52+
}
53+
}
54+
return r;
55+
}

agentos/src/obs-api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// hits the local Express service via Vite's /obs-api proxy.
33

44
import type { Operator } from "./obs-fields.ts";
5+
import { authedFetch } from "./lib/auth-fetch.ts";
56

67
// Per-trace aggregate — one row per TraceId in /v1/traces and /v1/traces/search.
78
export interface TraceSummary {
@@ -93,12 +94,12 @@ export interface DashboardData {
9394
}
9495

9596
async function getJSON<T>(path: string): Promise<T> {
96-
const r = await fetch(`/obs-api${path}`, { headers: { accept: "application/json" } });
97+
const r = await authedFetch(`/obs-api${path}`, { headers: { accept: "application/json" } });
9798
if (!r.ok) throw new Error(`${path}${r.status} ${await r.text().catch(() => "")}`);
9899
return r.json() as Promise<T>;
99100
}
100101
async function postJSON<T>(path: string, body: unknown): Promise<T> {
101-
const r = await fetch(`/obs-api${path}`, {
102+
const r = await authedFetch(`/obs-api${path}`, {
102103
method: "POST",
103104
headers: { "content-type": "application/json", accept: "application/json" },
104105
body: JSON.stringify(body),

agentos/src/sse.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
// SSE chat streaming + multi-dialect event parser, ported from test.html.
22
// Handles claude-agent-sdk (nested), gitagent (flat), and deepagents (LangGraph)
33
// payload shapes. Surfaces tool-call progress + the final assistant text.
4+
//
5+
// Streams go through `authedFetch` (lib/auth-fetch): the session check happens
6+
// on the OPENING response, so a 401 there triggers the shared single-flight
7+
// refresh + one replay before the body stream is read.
8+
9+
import { authedFetch } from "./lib/auth-fetch.ts";
410

511
export interface ChatStreamHandlers {
612
onTool?: (name: string, count: number) => void;
@@ -30,10 +36,9 @@ export async function streamCompletion(
3036
handlers: ChatStreamHandlers,
3137
signal?: AbortSignal,
3238
): Promise<void> {
33-
const res = await fetch("/api/completion", {
39+
const res = await authedFetch("/api/completion", {
3440
method: "POST",
3541
headers: { "content-type": "application/json", accept: "text/event-stream" },
36-
credentials: "include",
3742
body: JSON.stringify({ messages }),
3843
signal,
3944
});
@@ -84,7 +89,7 @@ export async function streamChat(
8489
handlers: ChatStreamHandlers,
8590
signal?: AbortSignal,
8691
): Promise<void> {
87-
const res = await fetch(url, {
92+
const res = await authedFetch(url, {
8893
method: "POST",
8994
headers: { "content-type": "application/json", accept: "text/event-stream" },
9095
body: JSON.stringify({ message }),

packages/agentos-server/src/agent-defs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export interface AgentDef {
1919
/** Surrogate key — the registry doc's ObjectId, stringified. The public
2020
* API + frontend address agents by this. */
2121
id: string;
22+
/** Stable caller-supplied agent id (when present). The per-agent join key
23+
* for sessions/logs that survives a `name` change. `null` for legacy/UI
24+
* agents that predate it. */
25+
agentId?: string | null;
2226
name: string;
2327
label: string;
2428
harness: string;
@@ -177,6 +181,7 @@ export function registryDocToAgentDef(doc: RegistryDoc): AgentDef {
177181

178182
return {
179183
id: doc._id.toString(),
184+
agentId: doc.agentId ?? null,
180185
name: doc.name,
181186
label: doc.label ?? doc.name,
182187
harness: doc.harness ?? "claude-agent-sdk",

packages/agentos-server/src/app.smoke.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ describe("app routing + auth gate", () => {
5151
expect(j.error.code).toBe("UNAUTHENTICATED");
5252
});
5353

54+
it("401s on the agent-resolve gateway when unauthenticated (cak_-gated)", async () => {
55+
delete process.env["AGENTOS_DEV_AUTH"];
56+
const r = await fetch(`${base}/agentos/api/v1/agents/resolve`, {
57+
method: "POST",
58+
headers: { "content-type": "application/json" },
59+
body: JSON.stringify({ agentId: "x" }),
60+
});
61+
expect(r.status).toBe(401);
62+
});
63+
5464
it("401s on /me when unauthenticated (the SPA's signal to show SSO sign-in)", async () => {
5565
delete process.env["AGENTOS_DEV_AUTH"];
5666
const r = await fetch(`${base}/agentos/api/v1/me`);

packages/agentos-server/src/mongo.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export async function pingMongo(): Promise<boolean> {
6565

6666
export interface ChatSessionDoc {
6767
_id: string; // sessionId
68-
agent: string; // owning agent name
68+
agent: string; // owning agent name (display label)
69+
agentId?: string | null; // stable owning-agent id (when the SDK supplies one)
6970
createdAt?: Date;
7071
lastMessageAt?: Date;
7172
}
@@ -80,6 +81,7 @@ export interface SessionDoc {
8081
// reader normalizes both, and these extra fields are simply ignored there.
8182
createdAt?: Date;
8283
agentName?: string;
84+
agentId?: string | null; // stable owning-agent id (when the SDK supplies one)
8385
source?: string;
8486
model?: string | null;
8587
// `meta.prompt` persists the opening prompt so session_ended can recover it
@@ -93,7 +95,9 @@ export interface SessionDoc {
9395

9496
export interface RegistryDoc {
9597
_id: ObjectId; // surrogate key (Mongo-minted)
96-
name: string; // the agent's human identifier (unique index)
98+
name: string; // the agent's human display name (unique index)
99+
agentId?: string | null; // stable caller-supplied id; when present, the
100+
// business key (sparse-unique). Renaming `name` keeps the same agentId/doc.
97101
label?: string;
98102
harness?: string;
99103
source?: unknown; // string OR IdentitySource shape
@@ -264,6 +268,9 @@ export async function migrateRegistryObjectIds(): Promise<{ registry: number; pi
264268
export async function ensureRegistryIndexes(): Promise<void> {
265269
const db = await getDb();
266270
await db.collection("agent_registry").createIndex({ name: 1 }, { unique: true });
271+
// Sparse-unique: the stable agent id is the business key when present; docs
272+
// without one (legacy/UI-registered) are simply not indexed here.
273+
await db.collection("agent_registry").createIndex({ agentId: 1 }, { unique: true, sparse: true });
267274
await db.collection("chat_pins").createIndex({ agentName: 1 }, { unique: true });
268275
await db.collection("agent_policies").createIndex({ agentName: 1 }, { unique: true });
269276
}

0 commit comments

Comments
 (0)