@m6d/cortex-server 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,17 @@ export default {
62
62
 
63
63
  Only `database`, `model`, and `agents` are required for a minimal setup. `storage` enables attachment uploads/downloads, `embedding` is only required when `neo4j` graph features are configured, and agent `systemPrompt` defaults to an empty prompt when omitted. An optional `redis: { url, streamTtlSeconds? }` backs resumable streams with Redis so runs survive reconnects, reloads, and multiple server instances; without it an in-memory, single-process fallback is used.
64
64
 
65
- Each key in `agents` becomes a route prefix (e.g. `assistant` → `/agents/assistant/...`). Agents can define per-agent `systemPrompt`, `tools`, `backendFetch`, `loadSessionData`, `resolveRequestContext`, and lifecycle hooks (`onToolCall`, `onStreamFinish`).
65
+ Each key in `agents` becomes a route prefix (e.g. `assistant` → `/agents/assistant/...`). Agents can define per-agent `systemPrompt`, `tools`, `backendFetch`, `loadSessionData`, `resolveRequestContext`, `auth`, and lifecycle hooks (`onToolCall`, `onStreamFinish`).
66
+
67
+ ## Authentication
68
+
69
+ The top-level `auth` verifies end-user JWTs for the whole server: tokens are checked against the IdP's remote JWKS (`jwksUri`) and pinned `issuer`, and the verified `sub` becomes the user id. Without it, every request shares one anonymous identity — fine for local development, wrong for anything multi-user.
70
+
71
+ An agent can carry its own `auth: { jwksUri, issuer, audience? }`, either in its `agents` entry or configured on the agent in the Control Center (the code entry wins; the cc value reaches the server through the same config sync as the rest of the agent). On such an agent, identity comes only from tokens that issuer signed — the server-level default and the anonymous fallback no longer apply, and requests without a verifiable token get a 401. cc playground tokens remain valid so the Control Center can test auth-enabled agents.
72
+
73
+ Set the optional `audience` when the issuer mints tokens for more than one application: the token's `aud` claim must then contain it, so a token issued for a different audience under the same issuer is rejected. The owner-supplied `jwksUri` is fetched under an SSRF policy that refuses loopback, private, link-local, and cloud-metadata destinations, pins DNS against rebinding, and rejects redirects; a self-hosted IdP on a private network is reached by setting `EGRESS_ALLOW_PRIVATE_NETWORK=true` (development only).
74
+
75
+ Requirements for the token minter: asymmetric signing (RS256/ES256/EdDSA) with the public keys published at the JWKS URL, a `kid` header, and exact `iss` and `exp` claims. Tokens travel as `Authorization: Bearer <jwt>`; WebSocket upgrades use `?token=<jwt>` since browsers cannot set headers there.
66
76
 
67
77
  ## Client tools
68
78
 
@@ -130,6 +130,22 @@ export const serviceCardSchema = z.object({
130
130
 
131
131
  export const runtimeAgentConfigSchema = z.object({
132
132
  agentId: agentSlugSchema,
133
+ /**
134
+ * End-user JWT verification for this agent, managed by the agent's owner.
135
+ * Tokens must be asymmetrically signed (the JWKS URI serves the public
136
+ * keys) with a matching `iss` claim. Absent = the server's own auth
137
+ * default applies.
138
+ */
139
+ auth: z
140
+ .object({
141
+ jwksUri: z.url(),
142
+ issuer: z.string().trim().min(1).max(2048),
143
+ // Optional: when set, the token's `aud` must contain it. Guards
144
+ // against a token minted for another audience under the same
145
+ // issuer being replayed here.
146
+ audience: z.string().trim().min(1).max(2048).optional(),
147
+ })
148
+ .optional(),
133
149
  systemPrompt: z.string(),
134
150
  promptVariables: z.array(z.enum(PROMPT_VARIABLES)),
135
151
  defaultLocale: localeSchema,
@@ -101,6 +101,11 @@ export declare const serviceCardSchema: z.ZodObject<{
101
101
  }, z.core.$strip>;
102
102
  export declare const runtimeAgentConfigSchema: z.ZodObject<{
103
103
  agentId: z.ZodString;
104
+ auth: z.ZodOptional<z.ZodObject<{
105
+ jwksUri: z.ZodURL;
106
+ issuer: z.ZodString;
107
+ audience: z.ZodOptional<z.ZodString>;
108
+ }, z.core.$strip>>;
104
109
  systemPrompt: z.ZodString;
105
110
  promptVariables: z.ZodArray<z.ZodEnum<{
106
111
  userName: "userName";
@@ -46,8 +46,10 @@ export type DatabaseAdapter = {
46
46
  output: string | null;
47
47
  tokenUsage: TokenUsage | null;
48
48
  }[]): Promise<void>;
49
+ /** `threadId` is for the caller's ownership check, not the wire. */
49
50
  listByMessageId(messageId: string): Promise<{
50
51
  id: string;
52
+ threadId: string;
51
53
  prompt: string;
52
54
  output: string | null;
53
55
  tokenUsage: TokenUsage | null;
@@ -95,6 +95,7 @@ export declare function createMssqlAdapter(connectionString: string, storage?: S
95
95
  }[]): Promise<void>;
96
96
  listByMessageId(messageId: string): Promise<{
97
97
  id: string;
98
+ threadId: string;
98
99
  prompt: string;
99
100
  output: string | null;
100
101
  tokenUsage: import("../../..").TokenUsage | null;
@@ -11,6 +11,7 @@ export declare function createLlmRequestsRepository(db: MssqlDb, enqueue?: typeo
11
11
  /** Ordered by step so the inspector replays a turn in the order it ran. */
12
12
  listByMessageId(messageId: string): Promise<{
13
13
  id: string;
14
+ threadId: string;
14
15
  prompt: string;
15
16
  output: string | null;
16
17
  tokenUsage: import("../../..").TokenUsage | null;
@@ -95,6 +95,7 @@ export declare function createPostgresAdapter(connectionString: string, storage?
95
95
  }[]): Promise<void>;
96
96
  listByMessageId(messageId: string): Promise<{
97
97
  id: string;
98
+ threadId: string;
98
99
  prompt: string;
99
100
  output: string | null;
100
101
  tokenUsage: import("../../..").TokenUsage | null;
@@ -11,6 +11,7 @@ export declare function createLlmRequestsRepository(db: PostgresDb, enqueue?: ty
11
11
  /** Ordered by step so the inspector replays a turn in the order it ran. */
12
12
  listByMessageId(messageId: string): Promise<{
13
13
  id: string;
14
+ threadId: string;
14
15
  prompt: string;
15
16
  output: string | null;
16
17
  tokenUsage: import("../../..").TokenUsage | null;
@@ -45,6 +45,11 @@ export declare function createCcRuntime(options: CcRuntimeOptions): {
45
45
  };
46
46
  catalogVersion: string;
47
47
  publishedAt: string;
48
+ auth?: {
49
+ jwksUri: string;
50
+ issuer: string;
51
+ audience?: string | undefined;
52
+ } | undefined;
48
53
  };
49
54
  registry: CcToolRegistry;
50
55
  clientTools: Map<string, CcClientTool>;
@@ -0,0 +1,10 @@
1
+ export declare function remoteJwks(url: string, options?: {
2
+ guarded?: boolean;
3
+ }): {
4
+ (protectedHeader?: import("jose").JWSHeaderParameters, token?: import("jose").FlattenedJWSInput): Promise<import("jose").CryptoKey>;
5
+ coolingDown: boolean;
6
+ fresh: boolean;
7
+ reloading: boolean;
8
+ reload: () => Promise<void>;
9
+ jwks: () => import("jose").JSONWebKeySet | undefined;
10
+ };
@@ -1,4 +1,15 @@
1
1
  import type { AppEnv, AuthedAppEnv } from "../types";
2
- import type { ControlCenterConfig, CortexConfig } from "../config";
2
+ import type { ControlCenterConfig, CortexConfig, ResolvedCortexAgentConfig } from "../config";
3
3
  export declare function createUserLoaderMiddleware(authConfig: CortexConfig["auth"], controlCenter?: ControlCenterConfig): import("hono").MiddlewareHandler<AppEnv, string, {}, Response>;
4
+ /**
5
+ * Identity for an agent that carries its own JWKS + issuer: the caller is
6
+ * whoever that issuer vouches for, or nobody — the server-level result
7
+ * (including the anonymous fallback) does not apply on that agent's routes.
8
+ * cc playground tokens stay valid so the Control Center can test
9
+ * auth-enabled agents. `undefined` means `requireAuth` will answer 401.
10
+ */
11
+ export declare function resolveAgentUser(request: Request, auth: NonNullable<ResolvedCortexAgentConfig["auth"]>, controlCenter?: ControlCenterConfig): Promise<{
12
+ id: string;
13
+ token: string;
14
+ } | undefined>;
4
15
  export declare const requireAuth: import("hono").MiddlewareHandler<AuthedAppEnv, string, {}, Response>;
@@ -0,0 +1,10 @@
1
+ import type { FetchImplementation } from "jose";
2
+ /**
3
+ * A jose `customFetch`: the global `fetch` signature jose would otherwise call,
4
+ * but pinned to the SSRF policy above and refusing redirects. Passed to
5
+ * `createRemoteJWKSet` for the per-agent path only. The undici call is cast
6
+ * once at the boundary: undici's Request/Response types diverge from the
7
+ * global ones jose is typed against, and only its `dispatcher` option carries
8
+ * the pinned lookup.
9
+ */
10
+ export declare const guardedJwksFetch: FetchImplementation;
@@ -47,6 +47,11 @@ export declare class ControlCenterClient {
47
47
  };
48
48
  catalogVersion: string;
49
49
  publishedAt: string;
50
+ auth?: {
51
+ jwksUri: string;
52
+ issuer: string;
53
+ audience?: string | undefined;
54
+ } | undefined;
50
55
  };
51
56
  readonly etag: string | null;
52
57
  } | null>;
@@ -16,4 +16,9 @@ export declare function getControlCenterConfig(cc: ControlCenterClient, agentId:
16
16
  };
17
17
  catalogVersion: string;
18
18
  publishedAt: string;
19
+ auth?: {
20
+ jwksUri: string;
21
+ issuer: string;
22
+ audience?: string | undefined;
23
+ } | undefined;
19
24
  } | null>;
@@ -133,6 +133,17 @@ type AgentBehavior<TSession extends Record<string, unknown>, TRequestContext ext
133
133
  isAborted: boolean;
134
134
  }) => void;
135
135
  };
136
+ /**
137
+ * Agent-level end-user JWT verification. When set, identity on that agent's
138
+ * routes comes only from tokens this issuer signed (or cc playground tokens);
139
+ * the server-level `auth`/anonymous fallback no longer applies there.
140
+ */
141
+ type AgentAuthConfig = {
142
+ jwksUri: string;
143
+ issuer: string;
144
+ /** When set, the token's `aud` must contain it. */
145
+ audience?: string;
146
+ };
136
147
  /**
137
148
  * An agent as authored. Providers are optional because an unset one inherits
138
149
  * from server level; `null` on knowledge means "opt out entirely" rather than
@@ -140,6 +151,7 @@ type AgentBehavior<TSession extends Record<string, unknown>, TRequestContext ext
140
151
  * decided by publishing them there, not per entry here.
141
152
  */
142
153
  export type CortexAgentDefinition<TSession extends Record<string, unknown> = Record<string, unknown>, TRequestContext extends Record<string, unknown> = Record<string, unknown>> = AgentBehavior<TSession, TRequestContext> & Partial<ProviderConfig> & {
154
+ auth?: AgentAuthConfig;
143
155
  knowledge?: KnowledgeConfig | null;
144
156
  context?: Partial<ContextConfig>;
145
157
  };
@@ -150,6 +162,7 @@ export type CortexAgentDefinition<TSession extends Record<string, unknown> = Rec
150
162
  */
151
163
  export type ResolvedCortexAgentConfig = AgentBehavior<Record<string, unknown>, Record<string, unknown>> & ProviderConfig & {
152
164
  agentId: string;
165
+ auth?: AgentAuthConfig;
153
166
  db: DatabaseAdapter;
154
167
  storage?: StorageAdapter;
155
168
  controlCenter?: ControlCenterConfig;
@@ -1,7 +1,3 @@
1
1
  import { Hono } from "hono";
2
2
  import type { AppEnv } from "../types";
3
- type WsRouteOptions = {
4
- useAgentParam?: boolean;
5
- };
6
- export declare function createWsRoute(options?: WsRouteOptions): Hono<AppEnv, import("hono/types").BlankSchema, "/">;
7
- export {};
3
+ export declare function createWsRoute(): Hono<AppEnv, import("hono/types").BlankSchema, "/">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-server",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Reusable AI agent chat server library for Hono + Bun",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,7 +44,8 @@
44
44
  "minio": "^8.0.7",
45
45
  "mssql": "^12.2.0",
46
46
  "pg": "^8.16.3",
47
- "quickjs-emscripten": "^0.32.0"
47
+ "quickjs-emscripten": "^0.32.0",
48
+ "undici": "7.28.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@tanstack/ai": "^0.43.1",
@@ -55,9 +55,11 @@ export type DatabaseAdapter = {
55
55
  tokenUsage: TokenUsage | null;
56
56
  }[],
57
57
  ): Promise<void>;
58
+ /** `threadId` is for the caller's ownership check, not the wire. */
58
59
  listByMessageId(messageId: string): Promise<
59
60
  {
60
61
  id: string;
62
+ threadId: string;
61
63
  prompt: string;
62
64
  output: string | null;
63
65
  tokenUsage: TokenUsage | null;
@@ -1,5 +1,5 @@
1
1
  import { asc, eq, type InferInsertModel } from "drizzle-orm";
2
- import { llmRequests } from "@/db/schema.mssql";
2
+ import { llmRequests, messages } from "@/db/schema.mssql";
3
3
  import { usageRecordEvent } from "@/cc/sync-events";
4
4
  import type { DatabaseAdapter } from "@/adapters/database/index";
5
5
  import type { MssqlDb } from "./client";
@@ -50,11 +50,15 @@ export function createLlmRequestsRepository(
50
50
  return await db
51
51
  .select({
52
52
  id: llmRequests.id,
53
+ // Requests key on the message; the owning thread comes
54
+ // through it for the route's ownership check.
55
+ threadId: messages.threadId,
53
56
  prompt: llmRequests.prompt,
54
57
  output: llmRequests.output,
55
58
  tokenUsage: llmRequests.tokenUsage,
56
59
  })
57
60
  .from(llmRequests)
61
+ .innerJoin(messages, eq(messages.id, llmRequests.messageId))
58
62
  .where(eq(llmRequests.messageId, messageId))
59
63
  .orderBy(asc(llmRequests.stepNumber))
60
64
  .execute();
@@ -1,7 +1,7 @@
1
1
  // fallow-ignore-file code-duplication -- mirrors ../mssql/llm-requests.ts by design;
2
2
  // Drizzle types query builders per dialect. See DatabaseAdapter in ../index.ts.
3
3
  import { asc, eq, type InferInsertModel } from "drizzle-orm";
4
- import { llmRequests } from "@/db/schema.pg";
4
+ import { llmRequests, messages } from "@/db/schema.pg";
5
5
  import { usageRecordEvent } from "@/cc/sync-events";
6
6
  import type { DatabaseAdapter } from "@/adapters/database/index";
7
7
  import type { PostgresDb } from "./client";
@@ -52,11 +52,15 @@ export function createLlmRequestsRepository(
52
52
  return await db
53
53
  .select({
54
54
  id: llmRequests.id,
55
+ // Requests key on the message; the owning thread comes
56
+ // through it for the route's ownership check.
57
+ threadId: messages.threadId,
55
58
  prompt: llmRequests.prompt,
56
59
  output: llmRequests.output,
57
60
  tokenUsage: llmRequests.tokenUsage,
58
61
  })
59
62
  .from(llmRequests)
63
+ .innerJoin(messages, eq(messages.id, llmRequests.messageId))
60
64
  .where(eq(llmRequests.messageId, messageId))
61
65
  .orderBy(asc(llmRequests.stepNumber))
62
66
  .execute();
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  EventType,
3
+ generateMessageId,
3
4
  StreamProcessor,
4
5
  type MessagePart,
5
6
  type StreamChunk,
@@ -15,6 +16,77 @@ type CommitGateOptions = {
15
16
 
16
17
  const ABORTED_TOOL_RESULT = "Generation was aborted";
17
18
 
19
+ /**
20
+ * Chunk types whose processor handler opens an assistant message when none is
21
+ * active, minting a local `msg-…` fallback id if the chunk names none.
22
+ */
23
+ const MESSAGE_OPENERS = new Set<StreamChunk["type"]>([
24
+ EventType.TEXT_MESSAGE_CONTENT,
25
+ EventType.TOOL_CALL_START,
26
+ EventType.STEP_FINISHED,
27
+ EventType.REASONING_MESSAGE_CONTENT,
28
+ EventType.RUN_ERROR,
29
+ ]);
30
+
31
+ /** The message id a chunk names, for the types that can name one. */
32
+ function namedMessageId(chunk: StreamChunk) {
33
+ switch (chunk.type) {
34
+ case EventType.TEXT_MESSAGE_START:
35
+ case EventType.TEXT_MESSAGE_CONTENT:
36
+ return chunk.messageId;
37
+ case EventType.TOOL_CALL_START:
38
+ return chunk.parentMessageId;
39
+ default:
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The id half of the read-back guarantee. The model adapters' chunks name no
46
+ * message id, so the StreamProcessor rebuilding messages here and the one in
47
+ * every client each mint their own `msg-…` fallback id for the same message —
48
+ * and the client's post-turn refetch then remounts those messages under the
49
+ * stored ids (re-running entrance animations). Opening each such message here,
50
+ * with an explicit TEXT_MESSAGE_START carrying an id minted once, puts the
51
+ * same id on both sides. Mirrors the processor's activation rules: an opened
52
+ * message stays active until the iteration's RUN_FINISHED (or RUN_ERROR)
53
+ * finalizes it.
54
+ */
55
+ async function* withStampedMessageIds(stream: AsyncIterable<StreamChunk>) {
56
+ let activeId: string | undefined;
57
+ let openRuns = 0;
58
+
59
+ for await (const chunk of stream) {
60
+ const named = namedMessageId(chunk);
61
+ if (named) activeId = named;
62
+
63
+ if (!activeId && MESSAGE_OPENERS.has(chunk.type)) {
64
+ activeId = generateMessageId();
65
+ yield {
66
+ type: EventType.TEXT_MESSAGE_START,
67
+ messageId: activeId,
68
+ role: "assistant",
69
+ timestamp: Date.now(),
70
+ } satisfies Extract<StreamChunk, { type: EventType.TEXT_MESSAGE_START }>;
71
+ }
72
+
73
+ switch (chunk.type) {
74
+ case EventType.RUN_STARTED:
75
+ openRuns += 1;
76
+ break;
77
+ case EventType.RUN_FINISHED:
78
+ openRuns -= 1;
79
+ if (openRuns <= 0) activeId = undefined;
80
+ break;
81
+ case EventType.RUN_ERROR:
82
+ activeId = undefined;
83
+ break;
84
+ }
85
+
86
+ yield chunk;
87
+ }
88
+ }
89
+
18
90
  /**
19
91
  * Rebuilds the run's messages from its own chunks and holds the run's terminal
20
92
  * event back until they are committed, so a client that refetches when the
@@ -33,7 +105,7 @@ export async function* commitBeforeTerminal(
33
105
  let commitError: unknown;
34
106
 
35
107
  try {
36
- for await (const chunk of stream) {
108
+ for await (const chunk of withStampedMessageIds(stream)) {
37
109
  // A snapshot replaces the processor's whole message list with the
38
110
  // context window under synthetic ids. The client wants it; persistence
39
111
  // must not see it, or a turn would rewrite the thread it replied to.
@@ -0,0 +1,27 @@
1
+ import { createRemoteJWKSet, customFetch } from "jose";
2
+ import { guardedJwksFetch } from "./safe-jwks-fetch";
3
+
4
+ /*
5
+ * One remote key set per JWKS URL, shared by every issuer this server trusts
6
+ * (the cc playground, per-agent IdPs). jose already caches the fetched keys
7
+ * and refetches on unknown-kid, so one set per URL is all the state needed.
8
+ *
9
+ * `guarded` routes the fetch through the SSRF policy in `safe-jwks-fetch`, for
10
+ * owner-supplied per-agent URLs. Operator-configured issuers stay unguarded: a
11
+ * self-hosted cc or IdP may legitimately sit on a private network.
12
+ */
13
+
14
+ const jwksByUrl = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
15
+
16
+ export function remoteJwks(url: string, options: { guarded?: boolean } = {}) {
17
+ const key = options.guarded ? `guarded:${url}` : url;
18
+ let jwks = jwksByUrl.get(key);
19
+ if (!jwks) {
20
+ jwks = createRemoteJWKSet(
21
+ new URL(url),
22
+ options.guarded ? { [customFetch]: guardedJwksFetch } : undefined,
23
+ );
24
+ jwksByUrl.set(key, jwks);
25
+ }
26
+ return jwks;
27
+ }
@@ -1,11 +1,20 @@
1
1
  import { createMiddleware } from "hono/factory";
2
2
  import { getCookie } from "hono/cookie";
3
3
  import { HTTPException } from "hono/http-exception";
4
- import { createRemoteJWKSet, jwtVerify } from "jose";
4
+ import { jwtVerify } from "jose";
5
5
  import type { AppEnv, AuthedAppEnv } from "@/types";
6
- import type { ControlCenterConfig, CortexConfig } from "@/config";
6
+ import type { ControlCenterConfig, CortexConfig, ResolvedCortexAgentConfig } from "@/config";
7
+ import { remoteJwks } from "./jwks-cache";
7
8
  import { isPlaygroundToken, verifyPlaygroundToken } from "./playground";
8
9
 
10
+ /** Bearer header first, then `?token=` — the query form exists for WebSocket
11
+ * upgrades, where browsers cannot set headers. */
12
+ function tokenFromRequest(request: Request) {
13
+ const header = request.headers.get("Authorization");
14
+ if (header?.startsWith("Bearer ")) return header.slice(7);
15
+ return new URL(request.url).searchParams.get("token");
16
+ }
17
+
9
18
  export function createUserLoaderMiddleware(
10
19
  authConfig: CortexConfig["auth"],
11
20
  controlCenter?: ControlCenterConfig,
@@ -35,7 +44,7 @@ export function createUserLoaderMiddleware(
35
44
  });
36
45
  }
37
46
 
38
- const jwks = createRemoteJWKSet(new URL(authConfig.jwksUri));
47
+ const jwks = remoteJwks(authConfig.jwksUri);
39
48
 
40
49
  return createMiddleware<AppEnv>(async (c, next) => {
41
50
  let token: string | null = null;
@@ -43,19 +52,7 @@ export function createUserLoaderMiddleware(
43
52
  if (authConfig.tokenExtractor) {
44
53
  token = authConfig.tokenExtractor(c.req.raw);
45
54
  } else {
46
- // 1. Authorization header
47
- const authHeader = c.req.header("Authorization");
48
- if (authHeader?.startsWith("Bearer ")) {
49
- token = authHeader.slice(7);
50
- }
51
-
52
- // 2. Query parameter
53
- if (!token) {
54
- const url = new URL(c.req.url);
55
- token = url.searchParams.get("token");
56
- }
57
-
58
- // 3. Cookie
55
+ token = tokenFromRequest(c.req.raw);
59
56
  if (!token && authConfig.cookieName) {
60
57
  token = getCookie(c, authConfig.cookieName) ?? null;
61
58
  }
@@ -88,6 +85,37 @@ export function createUserLoaderMiddleware(
88
85
  });
89
86
  }
90
87
 
88
+ /**
89
+ * Identity for an agent that carries its own JWKS + issuer: the caller is
90
+ * whoever that issuer vouches for, or nobody — the server-level result
91
+ * (including the anonymous fallback) does not apply on that agent's routes.
92
+ * cc playground tokens stay valid so the Control Center can test
93
+ * auth-enabled agents. `undefined` means `requireAuth` will answer 401.
94
+ */
95
+ export async function resolveAgentUser(
96
+ request: Request,
97
+ auth: NonNullable<ResolvedCortexAgentConfig["auth"]>,
98
+ controlCenter?: ControlCenterConfig,
99
+ ) {
100
+ const token = tokenFromRequest(request);
101
+ if (!token) return undefined;
102
+
103
+ try {
104
+ const payload = isPlaygroundToken(controlCenter, token)
105
+ ? await verifyPlaygroundToken(controlCenter!, token)
106
+ : (
107
+ await jwtVerify(token, remoteJwks(auth.jwksUri, { guarded: true }), {
108
+ issuer: auth.issuer,
109
+ audience: auth.audience,
110
+ clockTolerance: 60,
111
+ })
112
+ ).payload;
113
+ return payload.sub ? { id: payload.sub, token } : undefined;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
91
119
  export const requireAuth = createMiddleware<AuthedAppEnv>(async (c, next) => {
92
120
  const user = c.get("user");
93
121
  if (!user) {
@@ -1,24 +1,15 @@
1
- import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
1
+ import { decodeJwt, jwtVerify } from "jose";
2
2
  import type { ControlCenterConfig } from "@/config";
3
+ import { remoteJwks } from "./jwks-cache";
3
4
 
4
5
  /*
5
6
  * cc as a secondary issuer (opt-in via `controlCenter.playground`): its
6
7
  * short-lived end-user JWTs verify against `{url}/api/auth/jwks`, and its
7
- * signed `X-Cortex-Playground` header marks threads as test data. The remote
8
- * JWKS set is cached per cc URL — jose already caches the fetched keys and
9
- * refetches on unknown-kid, so one set per URL is all the state needed.
8
+ * signed `X-Cortex-Playground` header marks threads as test data.
10
9
  */
11
10
 
12
- const jwksByUrl = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
13
-
14
11
  function ccJwks(config: ControlCenterConfig) {
15
- const url = `${config.url.replace(/\/+$/, "")}/api/auth/jwks`;
16
- let jwks = jwksByUrl.get(url);
17
- if (!jwks) {
18
- jwks = createRemoteJWKSet(new URL(url));
19
- jwksByUrl.set(url, jwks);
20
- }
21
- return jwks;
12
+ return remoteJwks(`${config.url.replace(/\/+$/, "")}/api/auth/jwks`);
22
13
  }
23
14
 
24
15
  export function ccIssuer(config: ControlCenterConfig) {
@@ -0,0 +1,129 @@
1
+ // fallow-ignore-file complexity -- SSRF classification keeps each prohibited address range explicit
2
+ import { lookup } from "node:dns/promises";
3
+ import { isIP } from "node:net";
4
+ import type { LookupFunction } from "node:net";
5
+ import { Agent, fetch as undiciFetch } from "undici";
6
+ import type { FetchImplementation } from "jose";
7
+
8
+ /*
9
+ * A per-agent JWKS URL is supplied by the agent's owner, so the fetch the
10
+ * runtime makes for its signing keys is an SSRF vector. This mirrors the
11
+ * outbound policy the Control Center already applies to tool endpoints
12
+ * (apps/cortex-cc/src/lib/safe-fetch.ts): plaintext blocked off loopback,
13
+ * private/link-local/metadata ranges refused, DNS pinned so a rebind cannot
14
+ * slip a private address past the check, redirects rejected. cortex-server is
15
+ * a separately published package and cannot import cc app code, so the address
16
+ * policy is duplicated here rather than shared.
17
+ *
18
+ * Operator-configured issuers (server-level `auth`, the cc playground) do not
19
+ * pass through here: only the per-agent path is attacker-influenced.
20
+ */
21
+
22
+ function isBlockedAddress(address: string): boolean {
23
+ // Development-only opt-out so a self-hosted IdP on localhost/a private
24
+ // network works. Never set in production: it disables every range below.
25
+ if (process.env.EGRESS_ALLOW_PRIVATE_NETWORK === "true") return false;
26
+ if (isIP(address) === 6) {
27
+ const normalized = address.toLowerCase();
28
+ if (normalized.startsWith("::ffff:")) {
29
+ const mapped = normalized.slice("::ffff:".length);
30
+ return isIP(mapped) === 4 ? isBlockedAddress(mapped) : true;
31
+ }
32
+ return (
33
+ normalized === "::" ||
34
+ normalized === "::1" ||
35
+ normalized.startsWith("fc") ||
36
+ normalized.startsWith("fd") ||
37
+ /^fe[89ab]/.test(normalized)
38
+ );
39
+ }
40
+
41
+ const [a = 0, b = 0] = address.split(".").map(Number);
42
+ return (
43
+ a === 0 ||
44
+ a === 10 ||
45
+ a === 127 ||
46
+ (a === 100 && b >= 64 && b <= 127) ||
47
+ (a === 169 && b === 254) ||
48
+ (a === 172 && b >= 16 && b <= 31) ||
49
+ (a === 192 && b === 168) ||
50
+ (a === 198 && (b === 18 || b === 19)) ||
51
+ a >= 224
52
+ );
53
+ }
54
+
55
+ function egressError() {
56
+ const error: NodeJS.ErrnoException = new Error("egress blocked");
57
+ error.code = "EGRESS_BLOCKED";
58
+ return error;
59
+ }
60
+
61
+ /**
62
+ * Re-resolves the hostname and re-validates every candidate on every connection
63
+ * attempt, handing the socket only vetted public addresses. The address dialed
64
+ * is the exact one just checked, so a nameserver cannot return a public IP to
65
+ * the check and a private one to the connect.
66
+ */
67
+ const pinnedLookup: LookupFunction = (hostname, options, callback) => {
68
+ lookup(hostname, { all: true, verbatim: true }).then(
69
+ (addresses) => {
70
+ const first = addresses[0];
71
+ if (!first || addresses.some((address) => isBlockedAddress(address.address))) {
72
+ callback(egressError(), "", 0);
73
+ return;
74
+ }
75
+ if (options.all) {
76
+ callback(
77
+ null,
78
+ addresses.map((address) => ({
79
+ address: address.address,
80
+ family: address.family,
81
+ })),
82
+ );
83
+ return;
84
+ }
85
+ callback(null, first.address, first.family);
86
+ },
87
+ (error: unknown) =>
88
+ callback(error instanceof Error ? error : new Error("lookup failed"), "", 0),
89
+ );
90
+ };
91
+
92
+ /**
93
+ * A jose `customFetch`: the global `fetch` signature jose would otherwise call,
94
+ * but pinned to the SSRF policy above and refusing redirects. Passed to
95
+ * `createRemoteJWKSet` for the per-agent path only. The undici call is cast
96
+ * once at the boundary: undici's Request/Response types diverge from the
97
+ * global ones jose is typed against, and only its `dispatcher` option carries
98
+ * the pinned lookup.
99
+ */
100
+ export const guardedJwksFetch: FetchImplementation = async (url, options) => {
101
+ const target = new URL(url);
102
+ const hostname = target.hostname.replace(/^\[(.*)]$/, "$1");
103
+
104
+ if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) {
105
+ throw egressError();
106
+ }
107
+
108
+ // Fail fast before opening a socket; pinnedLookup re-checks at connect time.
109
+ const preflight = await lookup(hostname, { all: true, verbatim: true });
110
+ if (!preflight.length || preflight.some((address) => isBlockedAddress(address.address))) {
111
+ throw egressError();
112
+ }
113
+
114
+ const dispatcher = new Agent({ connect: { lookup: pinnedLookup } });
115
+ try {
116
+ const response = await undiciFetch(target, {
117
+ method: options?.method,
118
+ // Flattened to entries: jose's `Headers` is a nominally different
119
+ // type from undici's, but both accept `[name, value][]`.
120
+ headers: options?.headers ? [...options.headers] : undefined,
121
+ signal: options?.signal ?? undefined,
122
+ redirect: "error",
123
+ dispatcher,
124
+ });
125
+ return response as unknown as Response;
126
+ } finally {
127
+ if (typeof dispatcher.close === "function") void dispatcher.close().catch(() => undefined);
128
+ }
129
+ };
package/src/lib/config.ts CHANGED
@@ -160,6 +160,18 @@ type AgentBehavior<
160
160
  onStreamFinish?: (result: { messages: ChatMessage[]; isAborted: boolean }) => void;
161
161
  };
162
162
 
163
+ /**
164
+ * Agent-level end-user JWT verification. When set, identity on that agent's
165
+ * routes comes only from tokens this issuer signed (or cc playground tokens);
166
+ * the server-level `auth`/anonymous fallback no longer applies there.
167
+ */
168
+ type AgentAuthConfig = {
169
+ jwksUri: string;
170
+ issuer: string;
171
+ /** When set, the token's `aud` must contain it. */
172
+ audience?: string;
173
+ };
174
+
163
175
  /**
164
176
  * An agent as authored. Providers are optional because an unset one inherits
165
177
  * from server level; `null` on knowledge means "opt out entirely" rather than
@@ -171,6 +183,7 @@ export type CortexAgentDefinition<
171
183
  TRequestContext extends Record<string, unknown> = Record<string, unknown>,
172
184
  > = AgentBehavior<TSession, TRequestContext> &
173
185
  Partial<ProviderConfig> & {
186
+ auth?: AgentAuthConfig;
174
187
  knowledge?: KnowledgeConfig | null;
175
188
  context?: Partial<ContextConfig>;
176
189
  };
@@ -186,6 +199,7 @@ export type ResolvedCortexAgentConfig = AgentBehavior<
186
199
  > &
187
200
  ProviderConfig & {
188
201
  agentId: string;
202
+ auth?: AgentAuthConfig;
189
203
  db: DatabaseAdapter;
190
204
  storage?: StorageAdapter;
191
205
  controlCenter?: ControlCenterConfig;
@@ -4,7 +4,7 @@ import type { CortexConfig, ResolvedCortexAgentConfig } from "./config";
4
4
  import { DEFAULT_CONTEXT_CONFIG } from "./ai/context/types";
5
5
  import type { ContextConfig } from "./ai/context/types";
6
6
  import type { AppEnv, CortexAppEnv } from "./types";
7
- import { createUserLoaderMiddleware } from "./auth/middleware";
7
+ import { createUserLoaderMiddleware, resolveAgentUser } from "./auth/middleware";
8
8
  import { createThreadRoutes } from "./routes/threads";
9
9
  import { createChatRoutes } from "./routes/chat";
10
10
  import { createFileRoutes } from "./routes/files";
@@ -63,9 +63,6 @@ export function createCortex(config: CortexConfig) {
63
63
  const userLoader = createUserLoaderMiddleware(config.auth, config.controlCenter);
64
64
  app.use("*", userLoader);
65
65
 
66
- // Compatibility WebSocket route
67
- app.route("/", createWsRoute());
68
-
69
66
  // Agent-scoped routes
70
67
  const agentApp = new Hono<CortexAppEnv>();
71
68
 
@@ -75,9 +72,13 @@ export function createCortex(config: CortexConfig) {
75
72
  // Control Center: existence is checked against it (ETag-cached, so
76
73
  // usually a 304), which makes publishing there the only allowlist.
77
74
  let agentDef = config.agents?.[agentId];
75
+ let ccAuth: ResolvedCortexAgentConfig["auth"];
78
76
  if (!agentDef && ccClient && AGENT_SLUG_PATTERN.test(agentId)) {
79
77
  const ccConfig = await getControlCenterConfig(ccClient, agentId);
80
- if (ccConfig) agentDef = {};
78
+ if (ccConfig) {
79
+ agentDef = {};
80
+ ccAuth = ccConfig.auth;
81
+ }
81
82
  }
82
83
  if (!agentDef) return c.json({ error: "Agent not found" }, 404);
83
84
 
@@ -89,6 +90,7 @@ export function createCortex(config: CortexConfig) {
89
90
 
90
91
  const resolvedConfig: ResolvedCortexAgentConfig = {
91
92
  agentId,
93
+ auth: agentDef.auth ?? ccAuth,
92
94
  db,
93
95
  storage,
94
96
  model: agentDef.model ?? config.model,
@@ -114,13 +116,26 @@ export function createCortex(config: CortexConfig) {
114
116
 
115
117
  c.set("agentConfig", resolvedConfig);
116
118
  c.set("agentId", agentId);
119
+
120
+ if (resolvedConfig.auth) {
121
+ const user = await resolveAgentUser(
122
+ c.req.raw,
123
+ resolvedConfig.auth,
124
+ config.controlCenter,
125
+ );
126
+ // CortexAppEnv only promises a user past requireAuth; an
127
+ // unverifiable caller must also clear whatever identity the
128
+ // server-level loader derived.
129
+ c.set("user", user as { id: string; token: string });
130
+ }
131
+
117
132
  await next();
118
133
  });
119
134
 
120
135
  agentApp.route("/", createThreadRoutes());
121
136
  agentApp.route("/", createChatRoutes());
122
137
  agentApp.route("/", createFileRoutes());
123
- agentApp.route("/", createWsRoute({ useAgentParam: true }));
138
+ agentApp.route("/", createWsRoute());
124
139
 
125
140
  app.route("/agents/:agentId", agentApp);
126
141
 
@@ -77,7 +77,14 @@ export function createThreadRoutes() {
77
77
  const config = c.get("agentConfig");
78
78
  const messageId = c.req.param("messageId");
79
79
  const requests = await config.db.llmRequests.listByMessageId(messageId);
80
- return c.json(requests);
80
+
81
+ // Message ids are caller-supplied: the rows only leave through the
82
+ // caller's own thread on the agent in the path.
83
+ if (requests[0]) {
84
+ await requireOwnedThread(c, requests[0].threadId);
85
+ }
86
+
87
+ return c.json(requests.map(({ threadId: _, ...request }) => request));
81
88
  });
82
89
 
83
90
  app.post(
@@ -3,18 +3,13 @@ import { upgradeWebSocket } from "hono/bun";
3
3
  import type { AppEnv } from "@/types";
4
4
  import { addConnection, removeConnection } from "@/ws/connections";
5
5
 
6
- type WsRouteOptions = {
7
- useAgentParam?: boolean;
8
- };
9
-
10
- export function createWsRoute(options?: WsRouteOptions) {
6
+ export function createWsRoute() {
11
7
  const app = new Hono<AppEnv>();
12
8
  const handleUpgrade = upgradeWebSocket(function (c) {
13
9
  // upgradeWebSocket erases the app's env generic, so the context reads as any.
14
10
  const user = c.get("user") as AppEnv["Variables"]["user"];
15
11
  const userId = user?.id;
16
- const agentId =
17
- options?.useAgentParam === true ? c.req.param("agentId") : c.req.query("agentId");
12
+ const agentId = c.req.param("agentId");
18
13
 
19
14
  return {
20
15
  onOpen(_, ws) {
@@ -36,8 +31,7 @@ export function createWsRoute(options?: WsRouteOptions) {
36
31
  return c.json({ error: "Unauthorized" }, 401);
37
32
  }
38
33
 
39
- const agentId =
40
- options?.useAgentParam === true ? c.req.param("agentId") : c.req.query("agentId");
34
+ const agentId = c.req.param("agentId");
41
35
  if (!agentId) {
42
36
  return c.json({ error: "agentId is required" }, 400);
43
37
  }