@economic/agents 2.5.0 → 2.6.1

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
@@ -514,6 +514,39 @@ protected async getUserContext(jwtToken: string) {
514
514
  }
515
515
  ```
516
516
 
517
+ If `getUserContext` throws, the socket closes with `1011` — or, when the thrown `Error` carries a numeric `closeCode` in the `4000`–`4999` range, with that code and an `"unauthorized"` status frame:
518
+
519
+ ```typescript
520
+ const error = new Error("No Eva access");
521
+ (error as Error & { closeCode: number }).closeCode = 4003;
522
+ throw error;
523
+ ```
524
+
525
+ ### Token exchange
526
+
527
+ For agents whose clients connect with a _down-scoped_ token — one only valid for the client ↔ agent hop — override the static `getTokenExchangeConfig(env)` to exchange it (RFC 8693) at the identity server for a token issued to the agent's own OAuth client. Requires `getJwtAuthConfig`.
528
+
529
+ ```typescript
530
+ export class SupportAgent extends Agent {
531
+ static getTokenExchangeConfig(env: Cloudflare.Env) {
532
+ return {
533
+ tokenUrl: `${env.IDENTITY_ENDPOINT}/connect/token`,
534
+ clientId: env.OAUTH_CLIENT_ID,
535
+ clientSecret: env.OAUTH_CLIENT_SECRET, // string or Secrets Store binding
536
+ scopes: "api.read api.write", // always sent explicitly
537
+ tokenHeader: "X-EconomicToken", // default
538
+ };
539
+ }
540
+ }
541
+ ```
542
+
543
+ When configured:
544
+
545
+ - The verified connect token is exchanged on every connect; the exchanged token is what `getUserContext` receives and what tools see as `ctx.tokens[tokenHeader]` each turn. **Client-sent request-body tokens for that key are ignored** — tool credentials always derive from the verified connection identity, so clients should stop sending them.
546
+ - Renewal is connection-based. The client token rides in the WebSocket protocols, so presenting a renewed token always means a reconnect, which re-verifies and re-exchanges. Keep the `authToken` passed to the hooks current (refresh it before expiry) and the socket rotates silently — the `unauthorized` close (`4001`) then only happens when the session is genuinely dead (logout, revocation).
547
+ - The exchanged token expires on the identity server's clock (with a 30s margin). A turn arriving after expiry closes the connection `4001` to force a reconnect; a client that refreshes its token on time never hits this.
548
+ - Exchange failures at connect: `invalid_grant` (dead session token) → status `"unauthorized"` + close `4001`; anything else (identity server down, misconfigured client) → status `"error"` + close `1011`. Partial configuration is the app's concern — throw from `getTokenExchangeConfig` if you consider it a deployment fault, or return `undefined` to disable the exchange.
549
+
517
550
  ## Observability
518
551
 
519
552
  Emitted per turn from the `ai` SDK's OpenTelemetry spans, no setup beyond bindings:
package/dist/index.d.mts CHANGED
@@ -26,6 +26,61 @@ type SqlFn = InstanceType<typeof Agent$1>["sql"];
26
26
  type AgentConnectionStatus = "connecting" | "connected" | "disconnected" | "unauthorized" | "error";
27
27
  type AgentConnectionType = "agent" | "chat" | "assistant";
28
28
  //#endregion
29
+ //#region src/server/features/auth/tokenExchange.d.ts
30
+ interface TokenExchangeConfig {
31
+ /** Identity server token endpoint, e.g. `${identityOrigin}/connect/token`. */
32
+ tokenUrl: string;
33
+ /** OAuth client the agent authenticates as. */
34
+ clientId: string;
35
+ /**
36
+ * Plain secret, or a binding resolved with `get()` (e.g. a Cloudflare
37
+ * Secrets Store binding). Resolved on every exchange so rotation is
38
+ * picked up without a redeploy.
39
+ */
40
+ clientSecret: string | {
41
+ get(): Promise<string>;
42
+ };
43
+ /**
44
+ * Space-separated scopes for the exchanged token. Always sent explicitly:
45
+ * omitting `scope` would make the identity server issue every scope the
46
+ * client is allowed, the opposite of least privilege.
47
+ */
48
+ scopes: string;
49
+ /**
50
+ * Key under the request-context `tokens` record that tools read the token
51
+ * from. Defaults to {@link DEFAULT_TOKEN_HEADER}.
52
+ */
53
+ tokenHeader?: string;
54
+ }
55
+ interface ExchangedToken {
56
+ accessToken: string;
57
+ /** Unix ms; already includes {@link EXPIRY_MARGIN_MS}. */
58
+ expiresAt: number;
59
+ }
60
+ type TokenExchangeResult = ({
61
+ success: true;
62
+ } & ExchangedToken) | {
63
+ success: false;
64
+ status: number;
65
+ errorCode: string;
66
+ message: string;
67
+ /**
68
+ * True when the identity server rejected the subject token itself
69
+ * (RFC 6749 `invalid_grant`: expired or revoked). Re-authenticating is
70
+ * the only fix — other failures (`invalid_client`, `invalid_scope`,
71
+ * 5xx) are deployment or identity-server faults the user cannot fix by
72
+ * logging in again.
73
+ */
74
+ subjectTokenRejected: boolean;
75
+ };
76
+ /**
77
+ * Exchange `subjectToken` for a token issued to the agent's own OAuth client.
78
+ *
79
+ * Expected identity-server rejections return a failure result; unexpected
80
+ * failures (network error, timeout, malformed success response) throw.
81
+ */
82
+ declare function exchangeToken(subjectToken: string, config: TokenExchangeConfig): Promise<TokenExchangeResult>;
83
+ //#endregion
29
84
  //#region src/server/util/tools.d.ts
30
85
  type ToolSet = Record<string, Tool>;
31
86
  type Tool<Context extends Record<string, unknown> = Record<string, unknown>, INPUT extends JSONValue | unknown | never = any, OUTPUT extends JSONValue | unknown | never = any> = Tool$1<INPUT, OUTPUT> & {
@@ -141,6 +196,38 @@ declare abstract class Agent<RequestContext extends Record<string, unknown> = Re
141
196
  * @returns JWT auth config or undefined to skip authentication
142
197
  */
143
198
  static getJwtAuthConfig?(env: Cloudflare.Env): JwtAuthConfig<Record<string, unknown>> | undefined;
199
+ /**
200
+ * Override to exchange the verified connect token (RFC 8693) for a token
201
+ * issued to this agent's own OAuth client. Requires {@link getJwtAuthConfig}.
202
+ *
203
+ * When configured, the exchanged token is passed to {@link getUserContext}
204
+ * and injected into the tool context every turn under
205
+ * `tokens[config.tokenHeader]` — client-sent request-body tokens for that
206
+ * key are ignored. Renewal is connection-based: a client presenting a new
207
+ * token reconnects, and the connect flow re-verifies and re-exchanges.
208
+ *
209
+ * Do not add side effects to this method - return a single config from it,
210
+ * or undefined to disable the exchange.
211
+ *
212
+ * @param env - The worker environment variables
213
+ * @returns Token exchange config or undefined to disable
214
+ */
215
+ static getTokenExchangeConfig?(env: Cloudflare.Env): TokenExchangeConfig | undefined;
216
+ /**
217
+ * The exchanged token for this agent, refreshed on every authenticated
218
+ * connect. Kept in memory for the hot path and mirrored to DO storage so
219
+ * turns that run after hibernation (or without a connection at all) still
220
+ * have it. Short-lived by construction — at most the identity server's
221
+ * token lifetime.
222
+ */
223
+ private _exchangedToken?;
224
+ private _storeExchangedToken;
225
+ private _getExchangedToken;
226
+ /**
227
+ * Swaps the client-sent token for the exchanged one in a copy of the turn
228
+ * body. No-op when token exchange is not configured.
229
+ */
230
+ private _applyTokenExchange;
144
231
  /**
145
232
  * The user context for the session.
146
233
  * Define getUserContext to set a user context.
@@ -365,4 +452,4 @@ declare function skill<Context extends Record<string, unknown> = Record<string,
365
452
  //#region src/server/index.d.ts
366
453
  declare function getCurrentToolContext(): any;
367
454
  //#endregion
368
- export { Agent, type AgentConnectionStatus, type AgentConnectionType, type AgentEnv, Assistant, ChatAgent, type FacetStub, type LegacyChatStub, type LegacyMessageFeedback, type MigrationDeps, type Skill, type SkillSource, type Tool, type ToolContext, type ToolSet, type TurnSnapshot, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
455
+ export { Agent, type AgentConnectionStatus, type AgentConnectionType, type AgentEnv, Assistant, ChatAgent, type ExchangedToken, type FacetStub, type LegacyChatStub, type LegacyMessageFeedback, type MigrationDeps, type Skill, type SkillSource, type TokenExchangeConfig, type TokenExchangeResult, type Tool, type ToolContext, type ToolSet, type TurnSnapshot, exchangeToken, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
package/dist/index.mjs CHANGED
@@ -4,6 +4,178 @@ import { Think, skills } from "@cloudflare/think";
4
4
  import { Output, convertToModelMessages, generateText, jsonSchema, pruneMessages, tool as tool$1 } from "ai";
5
5
  import { nanoid } from "nanoid";
6
6
  import { createCompactFunction } from "agents/experimental/memory/utils";
7
+ //#region src/server/util/agent.ts
8
+ function sendConnectionStatus(connection, status) {
9
+ connection.send(JSON.stringify({
10
+ type: "connection_status",
11
+ status
12
+ }));
13
+ }
14
+ function sendAgentType(connection, agentType) {
15
+ connection.send(JSON.stringify({
16
+ type: "agent_type",
17
+ agentType
18
+ }));
19
+ }
20
+ function sendSubAgentName(connection, subAgentName) {
21
+ connection.send(JSON.stringify({
22
+ type: "sub_agent_name",
23
+ subAgentName
24
+ }));
25
+ }
26
+ /**
27
+ * Close code for a getUserContext failure. Subclasses signal an intentional
28
+ * auth-style rejection by throwing an Error carrying a numeric `closeCode`
29
+ * in the WebSocket application range (4000-4999); anything else is treated
30
+ * as an internal error (1011).
31
+ */
32
+ function getConnectCloseCode(error) {
33
+ const closeCode = error?.closeCode;
34
+ if (typeof closeCode === "number" && closeCode >= 4e3 && closeCode <= 4999) return closeCode;
35
+ return 1011;
36
+ }
37
+ //#endregion
38
+ //#region src/server/features/auth/tokenExchange.ts
39
+ /**
40
+ * OAuth 2.0 Token Exchange (RFC 8693) against an identity server.
41
+ *
42
+ * The client connects with a token down-scoped for the client ↔ agent hop only.
43
+ * When an agent defines `static getTokenExchangeConfig`, the verified connect
44
+ * token is exchanged — authenticating as the agent's own OAuth client — for a
45
+ * token carrying the scopes the agent needs against backends. Backends then see
46
+ * the agent's `client_id` and can attribute calls to it, and the browser never
47
+ * holds a backend-capable credential.
48
+ *
49
+ * Expected failures (the identity server rejecting the request) return a result
50
+ * object; unexpected failures (network errors, malformed responses) throw. Same
51
+ * contract as `verifyJwt` in ./index.ts.
52
+ */
53
+ const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
54
+ const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
55
+ const TOKEN_EXCHANGE_TIMEOUT_MS = 3e4;
56
+ /** Exchanged tokens are treated as expired this long before they actually are. */
57
+ const EXPIRY_MARGIN_MS = 3e4;
58
+ /**
59
+ * Exchange `subjectToken` for a token issued to the agent's own OAuth client.
60
+ *
61
+ * Expected identity-server rejections return a failure result; unexpected
62
+ * failures (network error, timeout, malformed success response) throw.
63
+ */
64
+ async function exchangeToken(subjectToken, config) {
65
+ const clientSecret = typeof config.clientSecret === "string" ? config.clientSecret : await config.clientSecret.get();
66
+ const body = new URLSearchParams({
67
+ client_id: config.clientId,
68
+ client_secret: clientSecret,
69
+ grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
70
+ subject_token: subjectToken,
71
+ subject_token_type: ACCESS_TOKEN_TYPE,
72
+ scope: config.scopes
73
+ });
74
+ const response = await fetch(config.tokenUrl, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
+ body: body.toString(),
78
+ signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
79
+ });
80
+ if (!response.ok) {
81
+ let errorCode = "unknown_error";
82
+ let description;
83
+ try {
84
+ const json = await response.json();
85
+ errorCode = json.error ?? errorCode;
86
+ description = json.error_description;
87
+ } catch {}
88
+ return {
89
+ success: false,
90
+ status: response.status,
91
+ errorCode,
92
+ message: `Token exchange failed with status ${response.status} (${errorCode})${description ? `: ${description}` : ""}`,
93
+ subjectTokenRejected: response.status === 400 && errorCode === "invalid_grant"
94
+ };
95
+ }
96
+ let json;
97
+ try {
98
+ json = await response.json();
99
+ } catch {
100
+ throw new Error("Token exchange response body was not valid JSON");
101
+ }
102
+ if (!json.access_token) throw new Error("Token exchange response contained no access_token");
103
+ if (typeof json.expires_in !== "number") throw new Error("Token exchange response contained no expires_in");
104
+ return {
105
+ success: true,
106
+ accessToken: json.access_token,
107
+ expiresAt: Date.now() + Math.max(0, json.expires_in * 1e3 - EXPIRY_MARGIN_MS)
108
+ };
109
+ }
110
+ /**
111
+ * Returns a copy of the request body with the exchanged token injected under
112
+ * `tokens[tokenHeader]`, overriding any client-sent value for that key.
113
+ *
114
+ * Never mutates `body`: think persists the original request body
115
+ * (`_lastBody`) and replays it on continuation turns, so a mutation would
116
+ * persist the exchanged token and replay it as a subject token later.
117
+ */
118
+ function withExchangedToken(body, tokenHeader, accessToken) {
119
+ const tokens = body?.tokens ?? {};
120
+ return {
121
+ ...body,
122
+ tokens: {
123
+ ...tokens,
124
+ [tokenHeader]: accessToken
125
+ }
126
+ };
127
+ }
128
+ /**
129
+ * Connect-time exchange. On failure, signals the client and closes the
130
+ * connection — `unauthorized`/4001 when the session token itself was rejected
131
+ * (only a re-login fixes that), `error`/1011 for identity-server or
132
+ * configuration faults — and returns undefined so the caller abandons the
133
+ * connect.
134
+ */
135
+ async function exchangeConnectToken(subjectToken, config, connection) {
136
+ let result;
137
+ try {
138
+ result = await exchangeToken(subjectToken, config);
139
+ } catch (error) {
140
+ sendConnectionStatus(connection, "error");
141
+ console.error(`[Agent] Token exchange error - ${error}`);
142
+ connection.close(1011, "Token exchange failed");
143
+ return;
144
+ }
145
+ if (!result.success) {
146
+ console.error(`[Agent] Token exchange failed - ${result.message}`);
147
+ if (result.subjectTokenRejected) {
148
+ sendConnectionStatus(connection, "unauthorized");
149
+ connection.close(4001, "Unauthorized");
150
+ } else {
151
+ sendConnectionStatus(connection, "error");
152
+ connection.close(1011, "Token exchange failed");
153
+ }
154
+ return;
155
+ }
156
+ return {
157
+ accessToken: result.accessToken,
158
+ expiresAt: result.expiresAt
159
+ };
160
+ }
161
+ /**
162
+ * Turn-time injection: returns a body copy with the held token under
163
+ * `tokens[config.tokenHeader]`. When the held token is missing or expired the
164
+ * turn cannot run — only a re-authenticating reconnect delivers a fresh
165
+ * subject token — so the current connection (if any) is closed to force one,
166
+ * and this throws to fail the turn.
167
+ */
168
+ function injectExchangedToken(body, config, token, connection) {
169
+ if (!token || token.expiresAt <= Date.now()) {
170
+ if (connection) {
171
+ sendConnectionStatus(connection, "unauthorized");
172
+ connection.close(4001, "Unauthorized");
173
+ }
174
+ throw new Error("Exchanged token missing or expired - the client must reconnect");
175
+ }
176
+ return withExchangedToken(body, config.tokenHeader ?? "X-EconomicToken", token.accessToken);
177
+ }
178
+ //#endregion
7
179
  //#region src/server/util/tools.ts
8
180
  function tool(tool) {
9
181
  return tool$1(tool);
@@ -92,26 +264,6 @@ async function touchAgentInstance(db, input) {
92
264
  WHERE agent_name = ? AND durable_object_name = ?`).bind(input.updatedAt, input.agentName, input.durableObjectName).run();
93
265
  }
94
266
  //#endregion
95
- //#region src/server/util/agent.ts
96
- function sendConnectionStatus(connection, status) {
97
- connection.send(JSON.stringify({
98
- type: "connection_status",
99
- status
100
- }));
101
- }
102
- function sendAgentType(connection, agentType) {
103
- connection.send(JSON.stringify({
104
- type: "agent_type",
105
- agentType
106
- }));
107
- }
108
- function sendSubAgentName(connection, subAgentName) {
109
- connection.send(JSON.stringify({
110
- type: "sub_agent_name",
111
- subAgentName
112
- }));
113
- }
114
- //#endregion
115
267
  //#region src/server/util/llm.ts
116
268
  /**
117
269
  * Convention: tools can include a `sources` array in their return value to surface
@@ -148,6 +300,7 @@ function buildSourcesTransform(additional) {
148
300
  }
149
301
  //#endregion
150
302
  //#region src/server/agents/Agent.ts
303
+ const EXCHANGED_TOKEN_STORAGE_KEY = "economic:exchangedToken";
151
304
  var Agent = class extends Think {
152
305
  agentType = "agent";
153
306
  clientIp;
@@ -207,7 +360,24 @@ var Agent = class extends Think {
207
360
  return;
208
361
  }
209
362
  const token = extractTokenFromConnectRequest(ctx.request);
210
- if (token && this.getUserContext) connection.setState({ userContext: await this.getUserContext(token) });
363
+ let effectiveToken = token;
364
+ const getTokenExchangeConfig = this.constructor.getTokenExchangeConfig;
365
+ const exchangeConfig = getTokenExchangeConfig?.(this.env);
366
+ if (token && exchangeConfig) {
367
+ const exchanged = await exchangeConnectToken(token, exchangeConfig, connection);
368
+ if (!exchanged) return;
369
+ await this._storeExchangedToken(exchanged);
370
+ effectiveToken = exchanged.accessToken;
371
+ }
372
+ if (effectiveToken && this.getUserContext) try {
373
+ connection.setState({ userContext: await this.getUserContext(effectiveToken) });
374
+ } catch (error) {
375
+ const closeCode = getConnectCloseCode(error);
376
+ sendConnectionStatus(connection, closeCode === 1011 ? "error" : "unauthorized");
377
+ console.error(`[Agent] getUserContext error - ${error}`);
378
+ connection.close(closeCode, "Could not establish user context");
379
+ return;
380
+ }
211
381
  }
212
382
  }
213
383
  sendConnectionStatus(connection, "connected");
@@ -231,7 +401,8 @@ var Agent = class extends Think {
231
401
  */
232
402
  async beforeTurn(ctx) {
233
403
  this.registerInstanceActivity();
234
- this._toolContextForCurrentTurn = this.getToolContextForCurrentTurn(ctx.body);
404
+ const body = await this._applyTokenExchange(ctx.body);
405
+ this._toolContextForCurrentTurn = this.getToolContextForCurrentTurn(body);
235
406
  this._toolsForCurrentTurn = ctx.tools;
236
407
  const activeSkillName = this._getLastActivatedSkillName(ctx.messages);
237
408
  const { tools, activeTools } = await this._getAuthorizedTools(ctx.tools, activeSkillName);
@@ -424,6 +595,36 @@ var Agent = class extends Think {
424
595
  }
425
596
  }
426
597
  /**
598
+ * The exchanged token for this agent, refreshed on every authenticated
599
+ * connect. Kept in memory for the hot path and mirrored to DO storage so
600
+ * turns that run after hibernation (or without a connection at all) still
601
+ * have it. Short-lived by construction — at most the identity server's
602
+ * token lifetime.
603
+ */
604
+ _exchangedToken;
605
+ async _storeExchangedToken(token) {
606
+ this._exchangedToken = token;
607
+ await this.ctx.storage.put(EXCHANGED_TOKEN_STORAGE_KEY, token);
608
+ }
609
+ async _getExchangedToken() {
610
+ if (!this._exchangedToken) this._exchangedToken = await this.ctx.storage.get(EXCHANGED_TOKEN_STORAGE_KEY);
611
+ if (this._exchangedToken && this._exchangedToken.expiresAt <= Date.now()) {
612
+ this._exchangedToken = void 0;
613
+ await this.ctx.storage.delete(EXCHANGED_TOKEN_STORAGE_KEY);
614
+ }
615
+ return this._exchangedToken;
616
+ }
617
+ /**
618
+ * Swaps the client-sent token for the exchanged one in a copy of the turn
619
+ * body. No-op when token exchange is not configured.
620
+ */
621
+ async _applyTokenExchange(body) {
622
+ const getTokenExchangeConfig = this.constructor.getTokenExchangeConfig;
623
+ const exchangeConfig = getTokenExchangeConfig?.(this.env);
624
+ if (!exchangeConfig) return body;
625
+ return injectExchangedToken(body, exchangeConfig, await this._getExchangedToken(), getCurrentAgent().connection);
626
+ }
627
+ /**
427
628
  * The user context for the session.
428
629
  * Define getUserContext to set a user context.
429
630
  */
@@ -1378,4 +1579,4 @@ function getCurrentToolContext() {
1378
1579
  return getCurrentAgent().agent._lastBody;
1379
1580
  }
1380
1581
  //#endregion
1381
- export { Agent, Assistant, ChatAgent, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
1582
+ export { Agent, Assistant, ChatAgent, exchangeToken, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@economic/agents",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "A starter for creating a TypeScript package.",
5
5
  "license": "MIT",
6
6
  "files": [