@agentuity/core 1.0.55 → 1.0.57

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.
Files changed (34) hide show
  1. package/dist/services/oauth/flow.d.ts +31 -0
  2. package/dist/services/oauth/flow.d.ts.map +1 -1
  3. package/dist/services/oauth/flow.js +138 -13
  4. package/dist/services/oauth/flow.js.map +1 -1
  5. package/dist/services/oauth/index.d.ts +1 -0
  6. package/dist/services/oauth/index.d.ts.map +1 -1
  7. package/dist/services/oauth/index.js +1 -0
  8. package/dist/services/oauth/index.js.map +1 -1
  9. package/dist/services/oauth/token-storage.d.ts +109 -0
  10. package/dist/services/oauth/token-storage.d.ts.map +1 -0
  11. package/dist/services/oauth/token-storage.js +140 -0
  12. package/dist/services/oauth/token-storage.js.map +1 -0
  13. package/dist/services/oauth/types.d.ts +11 -0
  14. package/dist/services/oauth/types.d.ts.map +1 -1
  15. package/dist/services/oauth/types.js +19 -0
  16. package/dist/services/oauth/types.js.map +1 -1
  17. package/dist/services/sandbox/execute.d.ts.map +1 -1
  18. package/dist/services/sandbox/execute.js +22 -11
  19. package/dist/services/sandbox/execute.js.map +1 -1
  20. package/dist/services/sandbox/run.d.ts.map +1 -1
  21. package/dist/services/sandbox/run.js +64 -25
  22. package/dist/services/sandbox/run.js.map +1 -1
  23. package/dist/services/sandbox/types.d.ts +8 -0
  24. package/dist/services/sandbox/types.d.ts.map +1 -1
  25. package/dist/services/sandbox/types.js +14 -0
  26. package/dist/services/sandbox/types.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/services/oauth/flow.ts +156 -15
  29. package/src/services/oauth/index.ts +1 -0
  30. package/src/services/oauth/token-storage.ts +220 -0
  31. package/src/services/oauth/types.ts +26 -0
  32. package/src/services/sandbox/execute.ts +26 -12
  33. package/src/services/sandbox/run.ts +105 -29
  34. package/src/services/sandbox/types.ts +14 -0
@@ -25,11 +25,28 @@ function resolveConfig(config?: OAuthFlowConfig) {
25
25
  config?.userinfoUrl ??
26
26
  getEnv('OAUTH_USERINFO_URL') ??
27
27
  (issuer ? `${issuer}/userinfo` : undefined);
28
+ const revokeUrl =
29
+ config?.revokeUrl ?? getEnv('OAUTH_REVOKE_URL') ?? (issuer ? `${issuer}/revoke` : undefined);
30
+ const endSessionUrl =
31
+ config?.endSessionUrl ??
32
+ getEnv('OAUTH_END_SESSION_URL') ??
33
+ (issuer ? `${issuer}/end_session` : undefined);
28
34
  const scopes = config?.scopes ?? getEnv('OAUTH_SCOPES') ?? 'openid profile email';
29
35
 
30
36
  const prompt = config?.prompt;
31
37
 
32
- return { clientId, clientSecret, issuer, authorizeUrl, tokenUrl, userinfoUrl, scopes, prompt };
38
+ return {
39
+ clientId,
40
+ clientSecret,
41
+ issuer,
42
+ authorizeUrl,
43
+ tokenUrl,
44
+ userinfoUrl,
45
+ revokeUrl,
46
+ endSessionUrl,
47
+ scopes,
48
+ prompt,
49
+ };
33
50
  }
34
51
 
35
52
  /**
@@ -118,9 +135,6 @@ export async function exchangeToken(
118
135
  });
119
136
  }
120
137
 
121
- const controller = new AbortController();
122
- const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
123
-
124
138
  let response: Response;
125
139
  try {
126
140
  response = await fetch(resolved.tokenUrl, {
@@ -133,18 +147,16 @@ export async function exchangeToken(
133
147
  client_id: resolved.clientId,
134
148
  client_secret: resolved.clientSecret,
135
149
  }),
136
- signal: controller.signal,
150
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
137
151
  });
138
152
  } catch (err) {
139
- clearTimeout(timer);
140
- if (err instanceof DOMException && err.name === 'AbortError') {
153
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
141
154
  throw new OAuthResponseError({
142
155
  message: `Token exchange timed out after ${DEFAULT_TIMEOUT_MS}ms`,
143
156
  });
144
157
  }
145
158
  throw err;
146
159
  }
147
- clearTimeout(timer);
148
160
 
149
161
  if (!response.ok) {
150
162
  const error = await response.text();
@@ -157,6 +169,140 @@ export async function exchangeToken(
157
169
  return OAuthTokenResponseSchema.parse(data);
158
170
  }
159
171
 
172
+ /**
173
+ * Refresh an access token using a refresh token.
174
+ *
175
+ * @param refreshTokenValue - The refresh token obtained from a previous token exchange
176
+ * @param config - Optional OAuth configuration. Falls back to environment variables.
177
+ * @returns The token response including a new access_token, and optionally a new refresh_token
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * const newToken = await refreshToken(previousToken.refresh_token!);
182
+ * console.log(newToken.access_token);
183
+ * ```
184
+ */
185
+ export async function refreshToken(
186
+ refreshTokenValue: string,
187
+ config?: OAuthFlowConfig
188
+ ): Promise<OAuthTokenResponse> {
189
+ const resolved = resolveConfig(config);
190
+
191
+ if (!resolved.tokenUrl) {
192
+ throw new OAuthResponseError({
193
+ message:
194
+ 'No token URL configured. Set OAUTH_TOKEN_URL or OAUTH_ISSUER environment variable.',
195
+ });
196
+ }
197
+ if (!resolved.clientId) {
198
+ throw new OAuthResponseError({
199
+ message: 'No client ID configured. Set OAUTH_CLIENT_ID environment variable.',
200
+ });
201
+ }
202
+
203
+ const params = new URLSearchParams({
204
+ grant_type: 'refresh_token',
205
+ refresh_token: refreshTokenValue,
206
+ client_id: resolved.clientId,
207
+ });
208
+
209
+ if (resolved.clientSecret) {
210
+ params.set('client_secret', resolved.clientSecret);
211
+ }
212
+
213
+ let response: Response;
214
+ try {
215
+ response = await fetch(resolved.tokenUrl, {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
218
+ body: params,
219
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
220
+ });
221
+ } catch (err) {
222
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
223
+ throw new OAuthResponseError({
224
+ message: `Token refresh timed out after ${DEFAULT_TIMEOUT_MS}ms`,
225
+ });
226
+ }
227
+ throw err;
228
+ }
229
+
230
+ if (!response.ok) {
231
+ const error = await response.text();
232
+ throw new OAuthResponseError({
233
+ message: `Token refresh failed (${response.status}): ${error}`,
234
+ });
235
+ }
236
+
237
+ const data = await response.json();
238
+ return OAuthTokenResponseSchema.parse(data);
239
+ }
240
+
241
+ /**
242
+ * Revoke an OAuth token (access token or refresh token) to log the user out.
243
+ *
244
+ * Calls the token revocation endpoint (RFC 7009). The server will invalidate
245
+ * the token so it can no longer be used. Per the spec, the endpoint returns
246
+ * a success response even if the token was already invalid.
247
+ *
248
+ * @param token - The access token or refresh token to revoke
249
+ * @param config - Optional OAuth configuration. Falls back to environment variables.
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * // Revoke the refresh token to fully log out
254
+ * await logout(token.refresh_token!);
255
+ * ```
256
+ */
257
+ export async function logout(token: string, config?: OAuthFlowConfig): Promise<void> {
258
+ const resolved = resolveConfig(config);
259
+
260
+ if (!resolved.revokeUrl) {
261
+ throw new OAuthResponseError({
262
+ message:
263
+ 'No revoke URL configured. Set OAUTH_REVOKE_URL or OAUTH_ISSUER environment variable.',
264
+ });
265
+ }
266
+ if (!resolved.clientId) {
267
+ throw new OAuthResponseError({
268
+ message: 'No client ID configured. Set OAUTH_CLIENT_ID environment variable.',
269
+ });
270
+ }
271
+
272
+ const params = new URLSearchParams({
273
+ token,
274
+ client_id: resolved.clientId,
275
+ });
276
+
277
+ if (resolved.clientSecret) {
278
+ params.set('client_secret', resolved.clientSecret);
279
+ }
280
+
281
+ let response: Response;
282
+ try {
283
+ response = await fetch(resolved.revokeUrl, {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
286
+ body: params,
287
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
288
+ });
289
+ } catch (err) {
290
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
291
+ throw new OAuthResponseError({
292
+ message: `Token revocation timed out after ${DEFAULT_TIMEOUT_MS}ms`,
293
+ });
294
+ }
295
+ throw err;
296
+ }
297
+
298
+ if (!response.ok) {
299
+ const error = await response.text();
300
+ throw new OAuthResponseError({
301
+ message: `Token revocation failed (${response.status}): ${error}`,
302
+ });
303
+ }
304
+ }
305
+
160
306
  /**
161
307
  * Fetch user information from the OIDC userinfo endpoint using an access token.
162
308
  *
@@ -183,25 +329,20 @@ export async function fetchUserInfo(
183
329
  });
184
330
  }
185
331
 
186
- const controller = new AbortController();
187
- const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
188
-
189
332
  let response: Response;
190
333
  try {
191
334
  response = await fetch(resolved.userinfoUrl, {
192
335
  headers: { Authorization: `Bearer ${accessToken}` },
193
- signal: controller.signal,
336
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
194
337
  });
195
338
  } catch (err) {
196
- clearTimeout(timer);
197
- if (err instanceof DOMException && err.name === 'AbortError') {
339
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
198
340
  throw new OAuthResponseError({
199
341
  message: `Userinfo request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
200
342
  });
201
343
  }
202
344
  throw err;
203
345
  }
204
- clearTimeout(timer);
205
346
 
206
347
  if (!response.ok) {
207
348
  const error = await response.text();
@@ -7,3 +7,4 @@ export * from './members.ts';
7
7
  export * from './keys.ts';
8
8
  export * from './util.ts';
9
9
  export * from './flow.ts';
10
+ export * from './token-storage.ts';
@@ -0,0 +1,220 @@
1
+ import type { KeyValueStorage } from '../keyvalue/service.ts';
2
+ import type { OAuthFlowConfig, OAuthTokenResponse, StoredToken } from './types.ts';
3
+ import { StoredTokenSchema } from './types.ts';
4
+ import { refreshToken, logout } from './flow.ts';
5
+
6
+ const DEFAULT_NAMESPACE = 'oauth-tokens';
7
+
8
+ /**
9
+ * Check whether a stored token's access token has expired.
10
+ *
11
+ * @param token - The stored token to check
12
+ * @returns true if the token has an expires_at timestamp that is in the past
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const token = await storage.get('user:123');
17
+ * if (token && isTokenExpired(token)) {
18
+ * // Token is expired and auto-refresh wasn't available
19
+ * }
20
+ * ```
21
+ */
22
+ export function isTokenExpired(token: StoredToken): boolean {
23
+ if (!token.expires_at) return false;
24
+ return Math.floor(Date.now() / 1000) >= token.expires_at;
25
+ }
26
+
27
+ /**
28
+ * Options for configuring a TokenStorage instance.
29
+ */
30
+ export interface TokenStorageOptions {
31
+ /**
32
+ * OAuth configuration for auto-refresh and token revocation.
33
+ * If not provided, auto-refresh on get() and server-side revocation on invalidate() are disabled.
34
+ */
35
+ config?: OAuthFlowConfig;
36
+
37
+ /**
38
+ * KV namespace for storing tokens. Defaults to 'oauth-tokens'.
39
+ */
40
+ namespace?: string;
41
+
42
+ /**
43
+ * Key prefix prepended to all storage keys.
44
+ * Useful for scoping tokens by application or tenant.
45
+ */
46
+ prefix?: string;
47
+ }
48
+
49
+ /**
50
+ * Interface for storing, retrieving, and invalidating OAuth tokens.
51
+ *
52
+ * Implementations handle persistence and may support automatic token refresh
53
+ * on retrieval and server-side revocation on invalidation.
54
+ */
55
+ export interface TokenStorage {
56
+ /**
57
+ * Retrieve a stored token by key.
58
+ *
59
+ * If the token is expired and a refresh_token is available (and config is provided),
60
+ * the token is automatically refreshed, stored, and the new token is returned.
61
+ * If auto-refresh fails, the expired token is returned so the caller can decide
62
+ * how to handle it (check with {@link isTokenExpired}).
63
+ *
64
+ * @param key - The storage key (e.g. a user ID or session ID)
65
+ * @returns The stored token, or null if no token exists for the key
66
+ */
67
+ get(key: string): Promise<StoredToken | null>;
68
+
69
+ /**
70
+ * Store a token response from a token exchange or refresh.
71
+ *
72
+ * Automatically computes `expires_at` from `expires_in` if present.
73
+ *
74
+ * @param key - The storage key (e.g. a user ID or session ID)
75
+ * @param token - The OAuth token response to store
76
+ */
77
+ set(key: string, token: OAuthTokenResponse): Promise<void>;
78
+
79
+ /**
80
+ * Invalidate a stored token: revoke it server-side and remove from storage.
81
+ *
82
+ * If config is provided, the refresh token (or access token as fallback)
83
+ * is revoked via the token revocation endpoint. Revocation is best-effort —
84
+ * the token is removed from storage regardless of whether revocation succeeds.
85
+ *
86
+ * @param key - The storage key to invalidate
87
+ * @returns The token that was removed, or null if no token existed
88
+ */
89
+ invalidate(key: string): Promise<StoredToken | null>;
90
+ }
91
+
92
+ /**
93
+ * Convert an OAuth token response to a StoredToken with computed expires_at.
94
+ */
95
+ function toStoredToken(token: OAuthTokenResponse): StoredToken {
96
+ return {
97
+ access_token: token.access_token,
98
+ token_type: token.token_type,
99
+ refresh_token: token.refresh_token,
100
+ scope: token.scope,
101
+ id_token: token.id_token,
102
+ expires_at: token.expires_in ? Math.floor(Date.now() / 1000) + token.expires_in : undefined,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Token storage backed by Agentuity's Key-Value storage service.
108
+ *
109
+ * Stores tokens as JSON in a KV namespace. Supports automatic token refresh
110
+ * on retrieval when tokens expire (if OAuth config is provided).
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * import { KeyValueTokenStorage } from '@agentuity/core/oauth';
115
+ *
116
+ * // Create storage with auto-refresh enabled
117
+ * const storage = new KeyValueTokenStorage(ctx.kv, {
118
+ * config: { issuer: 'https://auth.example.com' },
119
+ * });
120
+ *
121
+ * // Store a token after initial exchange
122
+ * await storage.set('user:123', tokenResponse);
123
+ *
124
+ * // Retrieve — auto-refreshes if expired
125
+ * const token = await storage.get('user:123');
126
+ *
127
+ * // Logout — revokes server-side and removes from storage
128
+ * await storage.invalidate('user:123');
129
+ * ```
130
+ */
131
+ export class KeyValueTokenStorage implements TokenStorage {
132
+ #kv: KeyValueStorage;
133
+ #namespace: string;
134
+ #prefix: string;
135
+ #config?: OAuthFlowConfig;
136
+
137
+ constructor(kv: KeyValueStorage, options?: TokenStorageOptions) {
138
+ this.#kv = kv;
139
+ this.#namespace = options?.namespace ?? DEFAULT_NAMESPACE;
140
+ this.#prefix = options?.prefix ?? '';
141
+ this.#config = options?.config;
142
+ }
143
+
144
+ async get(key: string): Promise<StoredToken | null> {
145
+ const result = await this.#kv.get<StoredToken>(this.#namespace, this.#resolveKey(key));
146
+ if (!result.exists) return null;
147
+
148
+ const parsed = StoredTokenSchema.safeParse(result.data);
149
+ if (!parsed.success) return null;
150
+
151
+ const token = parsed.data;
152
+
153
+ // Auto-refresh if expired and refresh_token + config are available
154
+ if (isTokenExpired(token) && token.refresh_token && this.#config) {
155
+ try {
156
+ const newTokenResponse = await refreshToken(token.refresh_token, this.#config);
157
+ const newStored = toStoredToken(newTokenResponse);
158
+ await this.#store(key, newStored);
159
+ return newStored;
160
+ } catch {
161
+ // Refresh failed — return the expired token, caller can check isTokenExpired()
162
+ return token;
163
+ }
164
+ }
165
+
166
+ return token;
167
+ }
168
+
169
+ async set(key: string, token: OAuthTokenResponse): Promise<void> {
170
+ const stored = toStoredToken(token);
171
+ await this.#store(key, stored);
172
+ }
173
+
174
+ async invalidate(key: string): Promise<StoredToken | null> {
175
+ const resolvedKey = this.#resolveKey(key);
176
+ const result = await this.#kv.get<StoredToken>(this.#namespace, resolvedKey);
177
+
178
+ if (!result.exists) return null;
179
+
180
+ const parsed = StoredTokenSchema.safeParse(result.data);
181
+ const token = parsed.success ? parsed.data : null;
182
+
183
+ // Revoke server-side (best effort)
184
+ if (token && this.#config) {
185
+ const tokenToRevoke = token.refresh_token ?? token.access_token;
186
+ try {
187
+ await logout(tokenToRevoke, this.#config);
188
+ } catch {
189
+ // Best effort — continue with storage cleanup
190
+ }
191
+ }
192
+
193
+ // Remove from storage regardless of revocation result
194
+ await this.#kv.delete(this.#namespace, resolvedKey);
195
+
196
+ return token;
197
+ }
198
+
199
+ async #store(key: string, token: StoredToken): Promise<void> {
200
+ // Only set explicit TTL for tokens without a refresh_token.
201
+ // Tokens with refresh capability persist until explicitly invalidated
202
+ // (auto-refresh on get() will keep them fresh).
203
+ let ttl: number | undefined;
204
+ if (!token.refresh_token && token.expires_at) {
205
+ const remaining = token.expires_at - Math.floor(Date.now() / 1000);
206
+ if (remaining > 0) {
207
+ ttl = Math.max(remaining, 60); // KV minimum is 60 seconds
208
+ }
209
+ }
210
+
211
+ await this.#kv.set(this.#namespace, this.#resolveKey(key), token, {
212
+ ttl,
213
+ contentType: 'application/json',
214
+ });
215
+ }
216
+
217
+ #resolveKey(key: string): string {
218
+ return this.#prefix ? `${this.#prefix}${key}` : key;
219
+ }
220
+ }
@@ -268,6 +268,18 @@ export const OAuthFlowConfigSchema = z.object({
268
268
  .string()
269
269
  .optional()
270
270
  .describe('UserInfo endpoint. Defaults to OAUTH_USERINFO_URL or {issuer}/userinfo'),
271
+ revokeUrl: z
272
+ .string()
273
+ .optional()
274
+ .describe(
275
+ 'Token revocation endpoint (RFC 7009). Defaults to OAUTH_REVOKE_URL or {issuer}/revoke'
276
+ ),
277
+ endSessionUrl: z
278
+ .string()
279
+ .optional()
280
+ .describe(
281
+ 'OIDC end session endpoint. Defaults to OAUTH_END_SESSION_URL or {issuer}/end_session'
282
+ ),
271
283
  scopes: z
272
284
  .string()
273
285
  .optional()
@@ -305,3 +317,17 @@ export const OAuthUserInfoSchema = z
305
317
  .catchall(z.unknown());
306
318
 
307
319
  export type OAuthUserInfo = z.infer<typeof OAuthUserInfoSchema>;
320
+
321
+ export const StoredTokenSchema = z.object({
322
+ access_token: z.string(),
323
+ token_type: z.string().optional(),
324
+ refresh_token: z.string().optional(),
325
+ scope: z.string().optional(),
326
+ id_token: z.string().optional(),
327
+ expires_at: z
328
+ .number()
329
+ .optional()
330
+ .describe('Unix timestamp (seconds) when the access token expires'),
331
+ });
332
+
333
+ export type StoredToken = z.infer<typeof StoredTokenSchema>;
@@ -1,7 +1,7 @@
1
1
  import type { ExecuteOptions, Execution, ExecutionStatus } from './types.ts';
2
2
  import { z } from 'zod';
3
3
  import type { APIClient } from '../api.ts';
4
- import { SandboxBusyError, throwSandboxError } from './util.ts';
4
+ import { SandboxBusyError, SandboxNotFoundError, throwSandboxError } from './util.ts';
5
5
 
6
6
  export const ExecuteRequestSchema = z
7
7
  .object({
@@ -114,23 +114,37 @@ export async function sandboxExecute(
114
114
  signal ?? options.signal
115
115
  );
116
116
  } catch (error: unknown) {
117
- // Detect 409 Conflict (sandbox busy) and throw a specific error.
118
- // The sandbox API client is configured with maxRetries: 0 to fail fast
119
- // when sandbox is busy (retrying wouldn't help - sandbox is still busy).
120
- // Convert APIErrorResponse with status 409 to SandboxBusyError for clarity.
121
117
  if (
122
118
  error &&
123
119
  typeof error === 'object' &&
124
120
  '_tag' in error &&
125
121
  (error as { _tag: string })._tag === 'APIErrorResponse' &&
126
- 'status' in error &&
127
- (error as { status: number }).status === 409
122
+ 'status' in error
128
123
  ) {
129
- throw new SandboxBusyError({
130
- message:
131
- 'Sandbox is currently busy executing another command. Please wait for the current execution to complete before submitting a new one.',
132
- sandboxId,
133
- });
124
+ const status = (error as { status: number }).status;
125
+
126
+ // Detect 404 Not Found sandbox may not exist or may still be initializing.
127
+ // The server normally handles the creating→idle wait transparently, but this
128
+ // provides a clear typed error if a 404 reaches the SDK for any reason.
129
+ if (status === 404) {
130
+ throw new SandboxNotFoundError({
131
+ message:
132
+ 'Sandbox not found. If you just created this sandbox, it may still be initializing. The server should handle this automatically — if this persists, the sandbox may have been destroyed.',
133
+ sandboxId,
134
+ });
135
+ }
136
+
137
+ // Detect 409 Conflict (sandbox busy) and throw a specific error.
138
+ // The sandbox API client is configured with maxRetries: 0 to fail fast
139
+ // when sandbox is busy (retrying wouldn't help - sandbox is still busy).
140
+ // Convert APIErrorResponse with status 409 to SandboxBusyError for clarity.
141
+ if (status === 409) {
142
+ throw new SandboxBusyError({
143
+ message:
144
+ 'Sandbox is currently busy executing another command. Please wait for the current execution to complete before submitting a new one.',
145
+ sandboxId,
146
+ });
147
+ }
134
148
  }
135
149
  throw error;
136
150
  }