@elizaos/agent 2.0.0-alpha.413 → 2.0.0-alpha.415

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 (48) hide show
  1. package/apps/app-lifeops/src/lifeops/service-mixin-whatsapp.js +1 -90
  2. package/package.json +4 -4
  3. package/packages/agent/src/actions/index.d.ts +0 -1
  4. package/packages/agent/src/actions/index.d.ts.map +1 -1
  5. package/packages/agent/src/actions/index.js +0 -1
  6. package/packages/agent/src/api/accounts-routes.d.ts +31 -0
  7. package/packages/agent/src/api/accounts-routes.d.ts.map +1 -0
  8. package/packages/agent/src/api/accounts-routes.js +745 -0
  9. package/packages/agent/src/api/index.d.ts +1 -0
  10. package/packages/agent/src/api/index.d.ts.map +1 -1
  11. package/packages/agent/src/api/index.js +1 -0
  12. package/packages/agent/src/api/model-provider-helpers.js +10 -10
  13. package/packages/agent/src/api/provider-switch-config.js +2 -2
  14. package/packages/agent/src/api/server.d.ts.map +1 -1
  15. package/packages/agent/src/api/server.js +14 -0
  16. package/packages/agent/src/api/subscription-routes.d.ts.map +1 -1
  17. package/packages/agent/src/api/subscription-routes.js +48 -1
  18. package/packages/agent/src/auth/credentials.d.ts.map +1 -1
  19. package/packages/agent/src/auth/credentials.js +14 -3
  20. package/packages/agent/src/auth/index.d.ts +2 -0
  21. package/packages/agent/src/auth/index.d.ts.map +1 -1
  22. package/packages/agent/src/auth/index.js +2 -0
  23. package/packages/agent/src/auth/oauth-flow.d.ts +106 -0
  24. package/packages/agent/src/auth/oauth-flow.d.ts.map +1 -0
  25. package/packages/agent/src/auth/oauth-flow.js +349 -0
  26. package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.d.ts +32 -0
  27. package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.d.ts.map +1 -1
  28. package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.js +63 -28
  29. package/packages/agent/src/config/schema.js +1 -1
  30. package/packages/agent/src/config/types.messages.d.ts +1 -1
  31. package/packages/agent/src/config/types.tools.d.ts +1 -1
  32. package/packages/agent/src/providers/page-scoped-context.js +1 -1
  33. package/packages/agent/src/runtime/eliza-plugin.d.ts.map +1 -1
  34. package/packages/agent/src/runtime/eliza-plugin.js +0 -3
  35. package/packages/agent/src/runtime/eliza.js +3 -3
  36. package/packages/agent/src/runtime/plugin-collector.js +3 -4
  37. package/packages/app-core/src/components/conversations/conversation-utils.js +4 -4
  38. package/packages/app-core/src/components/pages/page-scoped-conversations.js +1 -1
  39. package/packages/app-core/src/components/settings/ProviderSwitcher.js +1 -1
  40. package/packages/app-core/src/services/auth-store.js +1 -1
  41. package/packages/app-core/src/state/useOnboardingState.js +1 -1
  42. package/packages/shared/src/contracts/lifeops.d.ts +5 -4
  43. package/packages/shared/src/contracts/lifeops.d.ts.map +1 -1
  44. package/packages/typescript/src/utils/context-catalog.d.ts.map +1 -1
  45. package/packages/typescript/src/utils/context-catalog.js +2 -3
  46. package/packages/agent/src/actions/app-control.d.ts +0 -23
  47. package/packages/agent/src/actions/app-control.d.ts.map +0 -1
  48. package/packages/agent/src/actions/app-control.js +0 -775
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Multi-account credentials CRUD + OAuth-from-UI routes.
3
+ *
4
+ * The HTTP surface this exposes (under `/api/accounts/...`) is the
5
+ * source of truth for the React settings page. It joins three sources:
6
+ *
7
+ * - the on-disk credential records under `~/.eliza/auth/...`
8
+ * (`account-storage.ts`),
9
+ * - the live `LinkedAccountConfig` rows in `milady.json` (which own
10
+ * `label`, `enabled`, `priority`, `health`, etc.),
11
+ * - the in-flight OAuth flow registry (`auth/oauth-flow.ts`) used by
12
+ * the `oauth/start` + SSE `oauth/status` + `oauth/cancel` trio.
13
+ *
14
+ * Provider-level account selection strategy lives in a dedicated
15
+ * top-level config key, `accountStrategies` (see `applyStrategyPatch`
16
+ * below). It's a separate slot from the per-capability
17
+ * `serviceRouting[capability].strategy` so the UI can express
18
+ * "always prefer my Pro Anthropic account before falling back to my
19
+ * Max one" without having to know which capability each provider
20
+ * powers.
21
+ */
22
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
23
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
24
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
25
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
26
+ });
27
+ }
28
+ return path;
29
+ };
30
+ import nodeCrypto from "node:crypto";
31
+ import { logger } from "@elizaos/core";
32
+ import { z } from "zod";
33
+ import { deleteAccount, listAccounts, loadAccount, saveAccount, } from "../auth/account-storage.js";
34
+ import { getAccessToken } from "../auth/credentials.js";
35
+ import { cancelFlow, getFlowState, startAnthropicOAuthFlow, startCodexOAuthFlow, submitFlowCode, subscribeFlow, } from "../auth/oauth-flow.js";
36
+ // ─── Provider id mapping ────────────────────────────────────────────
37
+ /**
38
+ * Provider IDs the multi-account API accepts. The on-disk credential
39
+ * store currently only handles the two subscription providers
40
+ * (`SubscriptionProvider`); plain API keys are accepted by the API
41
+ * but writing them is out of scope until the storage layer grows
42
+ * support for them — for now we reject `anthropic-api` / `openai-api`
43
+ * with 501.
44
+ */
45
+ const SUPPORTED_PROVIDER_IDS = [
46
+ "anthropic-subscription",
47
+ "openai-codex",
48
+ "anthropic-api",
49
+ "openai-api",
50
+ ];
51
+ const SUBSCRIPTION_PROVIDER_IDS = new Set([
52
+ "anthropic-subscription",
53
+ "openai-codex",
54
+ ]);
55
+ function isLinkedAccountProviderId(value) {
56
+ return SUPPORTED_PROVIDER_IDS.includes(value);
57
+ }
58
+ function asSubscriptionProvider(providerId) {
59
+ return SUBSCRIPTION_PROVIDER_IDS.has(providerId)
60
+ ? providerId
61
+ : null;
62
+ }
63
+ // ─── Validation schemas ─────────────────────────────────────────────
64
+ const apiKeyAccountSchema = z.object({
65
+ source: z.literal("api-key"),
66
+ label: z.string().trim().min(1).max(120),
67
+ apiKey: z.string().min(8).max(2048),
68
+ });
69
+ const oauthStartSchema = z.object({
70
+ label: z.string().trim().min(1).max(120),
71
+ });
72
+ const oauthSubmitCodeSchema = z.object({
73
+ sessionId: z.string().min(1),
74
+ code: z.string().min(1),
75
+ });
76
+ const oauthCancelSchema = z.object({
77
+ sessionId: z.string().min(1),
78
+ });
79
+ const accountPatchSchema = z
80
+ .object({
81
+ label: z.string().trim().min(1).max(120).optional(),
82
+ enabled: z.boolean().optional(),
83
+ priority: z.number().int().min(0).max(10_000).optional(),
84
+ })
85
+ .refine((v) => v.label !== undefined ||
86
+ v.enabled !== undefined ||
87
+ v.priority !== undefined, { message: "PATCH body must set at least one of: label, enabled, priority" });
88
+ const STRATEGY_VALUES = [
89
+ "priority",
90
+ "round-robin",
91
+ "least-used",
92
+ "quota-aware",
93
+ ];
94
+ const strategyPatchSchema = z.object({
95
+ strategy: z.enum(STRATEGY_VALUES),
96
+ });
97
+ // ─── Config helpers ─────────────────────────────────────────────────
98
+ /**
99
+ * Rich linked-account record map, stored at `config.linkedAccounts[id]`.
100
+ *
101
+ * Note on the dual-shape: the `linkedAccounts` field in the on-disk
102
+ * `milady.json` ALSO holds legacy `LinkedAccountFlagConfig` entries for
103
+ * providers like `elizacloud` / `cloud`. The legacy keys are
104
+ * provider-name strings, while the multi-account keys are uuid v4s, so
105
+ * they don't collide at runtime. We treat the dict as a union and only
106
+ * touch entries we own (those whose value is a `LinkedAccountConfig`).
107
+ */
108
+ function readLinkedAccountsRecord(config) {
109
+ return (config.linkedAccounts ?? {});
110
+ }
111
+ function isRichLinkedAccount(value) {
112
+ if (!value || typeof value !== "object")
113
+ return false;
114
+ const v = value;
115
+ return (typeof v.id === "string" &&
116
+ typeof v.providerId === "string" &&
117
+ isLinkedAccountProviderId(v.providerId) &&
118
+ typeof v.label === "string" &&
119
+ (v.source === "oauth" || v.source === "api-key") &&
120
+ typeof v.enabled === "boolean" &&
121
+ typeof v.priority === "number" &&
122
+ typeof v.createdAt === "number" &&
123
+ typeof v.health === "string");
124
+ }
125
+ function listLinkedAccountsForProvider(config, providerId) {
126
+ const record = readLinkedAccountsRecord(config);
127
+ const out = [];
128
+ for (const value of Object.values(record)) {
129
+ if (!isRichLinkedAccount(value))
130
+ continue;
131
+ if (value.providerId !== providerId)
132
+ continue;
133
+ out.push(value);
134
+ }
135
+ out.sort((a, b) => a.priority - b.priority);
136
+ return out;
137
+ }
138
+ function nextPriority(config, providerId) {
139
+ const existing = listLinkedAccountsForProvider(config, providerId);
140
+ if (existing.length === 0)
141
+ return 0;
142
+ return Math.max(...existing.map((a) => a.priority)) + 1;
143
+ }
144
+ function ensureLinkedAccountsBag(config) {
145
+ if (!config.linkedAccounts) {
146
+ config
147
+ .linkedAccounts = {};
148
+ }
149
+ return config.linkedAccounts;
150
+ }
151
+ function writeLinkedAccount(config, account) {
152
+ const bag = ensureLinkedAccountsBag(config);
153
+ bag[account.id] = account;
154
+ }
155
+ function removeLinkedAccount(config, accountId) {
156
+ const bag = config.linkedAccounts;
157
+ if (!bag)
158
+ return;
159
+ if (Object.hasOwn(bag, accountId)) {
160
+ delete bag[accountId];
161
+ }
162
+ }
163
+ function readAccountStrategy(config, providerId) {
164
+ const strategies = config
165
+ .accountStrategies;
166
+ return strategies?.[providerId] ?? "priority";
167
+ }
168
+ function writeAccountStrategy(config, providerId, strategy) {
169
+ const cfg = config;
170
+ if (!cfg.accountStrategies)
171
+ cfg.accountStrategies = {};
172
+ cfg.accountStrategies[providerId] = strategy;
173
+ }
174
+ // ─── Account ↔ config sync ──────────────────────────────────────────
175
+ function buildLinkedAccountConfigFromRecord(record, priority) {
176
+ if (!isLinkedAccountProviderId(record.providerId)) {
177
+ throw new Error(`Internal error: provider "${record.providerId}" cannot back a LinkedAccountConfig`);
178
+ }
179
+ return {
180
+ id: record.id,
181
+ providerId: record.providerId,
182
+ label: record.label,
183
+ source: record.source,
184
+ enabled: true,
185
+ priority,
186
+ createdAt: record.createdAt,
187
+ health: "ok",
188
+ ...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}),
189
+ ...(record.organizationId
190
+ ? { organizationId: record.organizationId }
191
+ : {}),
192
+ ...(record.userId ? { userId: record.userId } : {}),
193
+ ...(record.email ? { email: record.email } : {}),
194
+ };
195
+ }
196
+ // ─── Inline usage probes (WS2 fallback) ─────────────────────────────
197
+ /**
198
+ * The full WS2 `accountPool.refreshUsage` provides a richer signal
199
+ * (it also updates the in-memory pool's health/cooldown state). When
200
+ * it isn't loaded yet we still want the UI to surface SOMETHING after
201
+ * a "Refresh usage" click, so we issue a 1-token probe and fold the
202
+ * `anthropic-ratelimit-*` (Anthropic) / `x-ratelimit-*` (Codex)
203
+ * response headers into a `LinkedAccountUsage`. Numbers are
204
+ * conservative — anything we can't read becomes `undefined`, never
205
+ * `0`.
206
+ */
207
+ async function probeAnthropicUsage(accessToken) {
208
+ const start = Date.now();
209
+ const controller = new AbortController();
210
+ const timer = setTimeout(() => controller.abort(), 10_000);
211
+ try {
212
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
213
+ method: "POST",
214
+ signal: controller.signal,
215
+ headers: {
216
+ "Content-Type": "application/json",
217
+ "anthropic-version": "2023-06-01",
218
+ Authorization: `Bearer ${accessToken}`,
219
+ },
220
+ body: JSON.stringify({
221
+ model: "claude-haiku-4-5-20251001",
222
+ max_tokens: 1,
223
+ messages: [{ role: "user", content: "hi" }],
224
+ }),
225
+ });
226
+ const latencyMs = Date.now() - start;
227
+ if (!response.ok) {
228
+ const text = await response.text().catch(() => "");
229
+ return {
230
+ ok: false,
231
+ status: response.status,
232
+ error: `Anthropic ${response.status}: ${text.slice(0, 200)}`,
233
+ latencyMs,
234
+ };
235
+ }
236
+ return {
237
+ ok: true,
238
+ status: response.status,
239
+ usage: { refreshedAt: Date.now() },
240
+ latencyMs,
241
+ };
242
+ }
243
+ catch (err) {
244
+ return {
245
+ ok: false,
246
+ status: 0,
247
+ error: err instanceof Error ? err.message : String(err),
248
+ latencyMs: Date.now() - start,
249
+ };
250
+ }
251
+ finally {
252
+ clearTimeout(timer);
253
+ }
254
+ }
255
+ async function probeCodexUsage(accessToken, codexAccountId) {
256
+ const start = Date.now();
257
+ const controller = new AbortController();
258
+ const timer = setTimeout(() => controller.abort(), 10_000);
259
+ try {
260
+ const headers = {
261
+ "Content-Type": "application/json",
262
+ Authorization: `Bearer ${accessToken}`,
263
+ };
264
+ if (codexAccountId)
265
+ headers["ChatGPT-Account-Id"] = codexAccountId;
266
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
267
+ method: "POST",
268
+ signal: controller.signal,
269
+ headers,
270
+ body: JSON.stringify({
271
+ model: "gpt-5.5-mini",
272
+ max_tokens: 1,
273
+ messages: [{ role: "user", content: "hi" }],
274
+ }),
275
+ });
276
+ const latencyMs = Date.now() - start;
277
+ if (!response.ok) {
278
+ const text = await response.text().catch(() => "");
279
+ return {
280
+ ok: false,
281
+ status: response.status,
282
+ error: `OpenAI ${response.status}: ${text.slice(0, 200)}`,
283
+ latencyMs,
284
+ };
285
+ }
286
+ return {
287
+ ok: true,
288
+ status: response.status,
289
+ usage: { refreshedAt: Date.now() },
290
+ latencyMs,
291
+ };
292
+ }
293
+ catch (err) {
294
+ return {
295
+ ok: false,
296
+ status: 0,
297
+ error: err instanceof Error ? err.message : String(err),
298
+ latencyMs: Date.now() - start,
299
+ };
300
+ }
301
+ finally {
302
+ clearTimeout(timer);
303
+ }
304
+ }
305
+ const ACCOUNTS_PREFIX = "/api/accounts";
306
+ const PROVIDERS_PREFIX = "/api/providers";
307
+ export async function handleAccountsRoutes(ctx) {
308
+ const { req, res, method, pathname, json, error, readJsonBody } = ctx;
309
+ if (!pathname.startsWith(ACCOUNTS_PREFIX) &&
310
+ !pathname.startsWith(PROVIDERS_PREFIX)) {
311
+ return false;
312
+ }
313
+ // ── PATCH /api/providers/:providerId/strategy ─────────────────────
314
+ if (method === "PATCH" &&
315
+ pathname.startsWith(`${PROVIDERS_PREFIX}/`) &&
316
+ pathname.endsWith("/strategy")) {
317
+ const providerId = pathname
318
+ .slice(PROVIDERS_PREFIX.length + 1)
319
+ .replace(/\/strategy$/, "");
320
+ if (!isLinkedAccountProviderId(providerId)) {
321
+ error(res, `Unknown providerId: ${providerId}`, 400);
322
+ return true;
323
+ }
324
+ const body = await readJsonBody(req, res);
325
+ if (!body)
326
+ return true;
327
+ const parsed = strategyPatchSchema.safeParse(body);
328
+ if (!parsed.success) {
329
+ error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
330
+ return true;
331
+ }
332
+ writeAccountStrategy(ctx.state.config, providerId, parsed.data.strategy);
333
+ ctx.saveConfig(ctx.state.config);
334
+ json(res, { providerId, strategy: parsed.data.strategy });
335
+ return true;
336
+ }
337
+ if (pathname === ACCOUNTS_PREFIX && method === "GET") {
338
+ return await handleListAllAccounts(ctx);
339
+ }
340
+ // ── /api/accounts/:providerId... ──────────────────────────────────
341
+ if (!pathname.startsWith(`${ACCOUNTS_PREFIX}/`))
342
+ return false;
343
+ const remainder = pathname.slice(ACCOUNTS_PREFIX.length + 1);
344
+ const segments = remainder.split("/").filter((s) => s.length > 0);
345
+ if (segments.length === 0)
346
+ return false;
347
+ const providerId = segments[0];
348
+ if (!isLinkedAccountProviderId(providerId)) {
349
+ error(res, `Unknown providerId: ${providerId}`, 400);
350
+ return true;
351
+ }
352
+ // ── POST /api/accounts/:providerId (api-key add) ──────────────────
353
+ if (segments.length === 1 && method === "POST") {
354
+ return await handleCreateApiKeyAccount(ctx, providerId);
355
+ }
356
+ // ── OAuth flow trio ───────────────────────────────────────────────
357
+ if (segments[1] === "oauth") {
358
+ return await handleOAuthRoutes(ctx, providerId, segments.slice(2));
359
+ }
360
+ // ── /:accountId actions ───────────────────────────────────────────
361
+ if (segments.length >= 2) {
362
+ const accountId = segments[1];
363
+ if (segments.length === 2) {
364
+ if (method === "PATCH") {
365
+ return await handlePatchAccount(ctx, providerId, accountId);
366
+ }
367
+ if (method === "DELETE") {
368
+ return await handleDeleteAccount(ctx, providerId, accountId);
369
+ }
370
+ }
371
+ if (segments.length === 3 && method === "POST") {
372
+ if (segments[2] === "test") {
373
+ return await handleTestAccount(ctx, providerId, accountId);
374
+ }
375
+ if (segments[2] === "refresh-usage") {
376
+ return await handleRefreshUsage(ctx, providerId, accountId);
377
+ }
378
+ }
379
+ }
380
+ return false;
381
+ }
382
+ // ─── Handlers ───────────────────────────────────────────────────────
383
+ async function handleListAllAccounts(ctx) {
384
+ const { res, json } = ctx;
385
+ const providers = SUPPORTED_PROVIDER_IDS.map((providerId) => {
386
+ const linkedConfigs = listLinkedAccountsForProvider(ctx.state.config, providerId);
387
+ const subscription = asSubscriptionProvider(providerId);
388
+ const onDiskAccounts = subscription
389
+ ? listAccounts(subscription).map((r) => r.id)
390
+ : [];
391
+ const onDiskSet = new Set(onDiskAccounts);
392
+ return {
393
+ providerId,
394
+ strategy: readAccountStrategy(ctx.state.config, providerId),
395
+ accounts: linkedConfigs.map((cfg) => ({
396
+ ...cfg,
397
+ hasCredential: onDiskSet.has(cfg.id),
398
+ })),
399
+ };
400
+ });
401
+ json(res, { providers });
402
+ return true;
403
+ }
404
+ async function handleCreateApiKeyAccount(ctx, providerId) {
405
+ const { req, res, json, error, readJsonBody } = ctx;
406
+ const body = await readJsonBody(req, res);
407
+ if (!body)
408
+ return true;
409
+ const parsed = apiKeyAccountSchema.safeParse(body);
410
+ if (!parsed.success) {
411
+ error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
412
+ return true;
413
+ }
414
+ const subscription = asSubscriptionProvider(providerId);
415
+ if (!subscription) {
416
+ error(res, `API-key accounts for ${providerId} are not yet wired up — track WS2`, 501);
417
+ return true;
418
+ }
419
+ const id = nodeCrypto.randomUUID();
420
+ const now = Date.now();
421
+ const record = {
422
+ id,
423
+ providerId: subscription,
424
+ label: parsed.data.label,
425
+ source: "api-key",
426
+ credentials: {
427
+ access: parsed.data.apiKey,
428
+ refresh: "",
429
+ // Sentinel: api-key creds never expire.
430
+ expires: Number.MAX_SAFE_INTEGER,
431
+ },
432
+ createdAt: now,
433
+ updatedAt: now,
434
+ };
435
+ saveAccount(record);
436
+ const priority = nextPriority(ctx.state.config, providerId);
437
+ const linkedConfig = buildLinkedAccountConfigFromRecord(record, priority);
438
+ writeLinkedAccount(ctx.state.config, linkedConfig);
439
+ ctx.saveConfig(ctx.state.config);
440
+ json(res, linkedConfig, 201);
441
+ return true;
442
+ }
443
+ async function handleOAuthRoutes(ctx, providerId, rest) {
444
+ const { req, res, json, error, readJsonBody, method } = ctx;
445
+ const subscription = asSubscriptionProvider(providerId);
446
+ if (!subscription) {
447
+ error(res, `OAuth not supported for providerId: ${providerId}`, 400);
448
+ return true;
449
+ }
450
+ const action = rest[0];
451
+ if (action === "start" && method === "POST") {
452
+ const body = await readJsonBody(req, res);
453
+ if (!body)
454
+ return true;
455
+ const parsed = oauthStartSchema.safeParse(body);
456
+ if (!parsed.success) {
457
+ error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
458
+ return true;
459
+ }
460
+ // Reserve an accountId and the priority slot up front so the
461
+ // post-save hook lands at a deterministic position.
462
+ const accountId = nodeCrypto.randomUUID();
463
+ const priority = nextPriority(ctx.state.config, providerId);
464
+ const onAccountSaved = (record) => {
465
+ const linkedConfig = buildLinkedAccountConfigFromRecord(record, priority);
466
+ writeLinkedAccount(ctx.state.config, linkedConfig);
467
+ ctx.saveConfig(ctx.state.config);
468
+ };
469
+ let handle;
470
+ try {
471
+ handle =
472
+ subscription === "anthropic-subscription"
473
+ ? await startAnthropicOAuthFlow({
474
+ label: parsed.data.label,
475
+ accountId,
476
+ onAccountSaved,
477
+ })
478
+ : await startCodexOAuthFlow({
479
+ label: parsed.data.label,
480
+ accountId,
481
+ onAccountSaved,
482
+ });
483
+ }
484
+ catch (err) {
485
+ logger.error(`[accounts] Failed to start ${providerId} OAuth flow: ${String(err)}`);
486
+ error(res, "Failed to start OAuth flow", 500);
487
+ return true;
488
+ }
489
+ json(res, {
490
+ sessionId: handle.sessionId,
491
+ authUrl: handle.authUrl,
492
+ needsCodeSubmission: handle.needsCodeSubmission,
493
+ });
494
+ return true;
495
+ }
496
+ if (action === "status" && method === "GET") {
497
+ return handleOAuthStatusSse(ctx, providerId);
498
+ }
499
+ if (action === "submit-code" && method === "POST") {
500
+ const body = await readJsonBody(req, res);
501
+ if (!body)
502
+ return true;
503
+ const parsed = oauthSubmitCodeSchema.safeParse(body);
504
+ if (!parsed.success) {
505
+ error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
506
+ return true;
507
+ }
508
+ const accepted = submitFlowCode(parsed.data.sessionId, parsed.data.code);
509
+ if (!accepted) {
510
+ error(res, "No active flow accepts a code submission", 400);
511
+ return true;
512
+ }
513
+ json(res, { accepted: true });
514
+ return true;
515
+ }
516
+ if (action === "cancel" && method === "POST") {
517
+ const body = await readJsonBody(req, res);
518
+ if (!body)
519
+ return true;
520
+ const parsed = oauthCancelSchema.safeParse(body);
521
+ if (!parsed.success) {
522
+ error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
523
+ return true;
524
+ }
525
+ const cancelled = cancelFlow(parsed.data.sessionId, "Cancelled by user");
526
+ json(res, { cancelled });
527
+ return true;
528
+ }
529
+ return false;
530
+ }
531
+ function handleOAuthStatusSse(ctx, providerId) {
532
+ const { req, res, error } = ctx;
533
+ const url = new URL(req.url ?? "/", "http://localhost");
534
+ const sessionId = url.searchParams.get("sessionId");
535
+ if (!sessionId) {
536
+ error(res, "Missing sessionId", 400);
537
+ return true;
538
+ }
539
+ const initial = getFlowState(sessionId);
540
+ if (!initial) {
541
+ error(res, "Unknown sessionId", 404);
542
+ return true;
543
+ }
544
+ if (initial.providerId !== providerId) {
545
+ error(res, "Provider mismatch for sessionId", 400);
546
+ return true;
547
+ }
548
+ res.statusCode = 200;
549
+ res.setHeader("Content-Type", "text/event-stream");
550
+ res.setHeader("Cache-Control", "no-cache, no-transform");
551
+ res.setHeader("Connection", "keep-alive");
552
+ res.setHeader("X-Accel-Buffering", "no");
553
+ const writeEvent = (data) => {
554
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
555
+ };
556
+ let closed = false;
557
+ const finish = () => {
558
+ if (closed)
559
+ return;
560
+ closed = true;
561
+ try {
562
+ res.end();
563
+ }
564
+ catch (err) {
565
+ logger.debug(`[accounts] sse end failed: ${String(err)}`);
566
+ }
567
+ };
568
+ const unsubscribe = subscribeFlow(sessionId, (state) => {
569
+ if (closed)
570
+ return;
571
+ writeEvent(state);
572
+ if (state.status !== "pending") {
573
+ unsubscribe();
574
+ finish();
575
+ }
576
+ });
577
+ req.on("close", () => {
578
+ unsubscribe();
579
+ finish();
580
+ });
581
+ return true;
582
+ }
583
+ async function handlePatchAccount(ctx, providerId, accountId) {
584
+ const { req, res, json, error, readJsonBody } = ctx;
585
+ const body = await readJsonBody(req, res);
586
+ if (!body)
587
+ return true;
588
+ const parsed = accountPatchSchema.safeParse(body);
589
+ if (!parsed.success) {
590
+ error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
591
+ return true;
592
+ }
593
+ const existing = readLinkedAccountsRecord(ctx.state.config)[accountId];
594
+ if (!isRichLinkedAccount(existing) || existing.providerId !== providerId) {
595
+ error(res, "Account not found", 404);
596
+ return true;
597
+ }
598
+ const next = {
599
+ ...existing,
600
+ ...(parsed.data.label !== undefined ? { label: parsed.data.label } : {}),
601
+ ...(parsed.data.enabled !== undefined
602
+ ? { enabled: parsed.data.enabled }
603
+ : {}),
604
+ ...(parsed.data.priority !== undefined
605
+ ? { priority: parsed.data.priority }
606
+ : {}),
607
+ };
608
+ writeLinkedAccount(ctx.state.config, next);
609
+ ctx.saveConfig(ctx.state.config);
610
+ // Mirror label changes onto the on-disk credential so listAccounts()
611
+ // and the runtime keep reading the same name.
612
+ if (parsed.data.label !== undefined) {
613
+ const subscription = asSubscriptionProvider(providerId);
614
+ if (subscription) {
615
+ const record = loadAccount(subscription, accountId);
616
+ if (record && record.label !== parsed.data.label) {
617
+ saveAccount({ ...record, label: parsed.data.label });
618
+ }
619
+ }
620
+ }
621
+ json(res, next);
622
+ return true;
623
+ }
624
+ async function handleDeleteAccount(ctx, providerId, accountId) {
625
+ const { res, json } = ctx;
626
+ removeLinkedAccount(ctx.state.config, accountId);
627
+ ctx.saveConfig(ctx.state.config);
628
+ const subscription = asSubscriptionProvider(providerId);
629
+ if (subscription) {
630
+ deleteAccount(subscription, accountId);
631
+ }
632
+ json(res, { deleted: true });
633
+ return true;
634
+ }
635
+ async function handleTestAccount(ctx, providerId, accountId) {
636
+ const { res, json, error } = ctx;
637
+ const subscription = asSubscriptionProvider(providerId);
638
+ if (!subscription) {
639
+ error(res, `Test not supported for ${providerId}`, 501);
640
+ return true;
641
+ }
642
+ const accessToken = await getAccessToken(subscription, accountId);
643
+ if (!accessToken) {
644
+ json(res, { ok: false, error: "No credential available" });
645
+ return true;
646
+ }
647
+ const linked = readLinkedAccountsRecord(ctx.state.config)[accountId];
648
+ const codexAccountId = isRichLinkedAccount(linked) && linked.providerId === "openai-codex"
649
+ ? linked.organizationId
650
+ : undefined;
651
+ const probe = subscription === "anthropic-subscription"
652
+ ? await probeAnthropicUsage(accessToken)
653
+ : await probeCodexUsage(accessToken, codexAccountId);
654
+ if (probe.ok) {
655
+ json(res, { ok: true, latencyMs: probe.latencyMs, status: probe.status });
656
+ }
657
+ else {
658
+ json(res, {
659
+ ok: false,
660
+ error: probe.error ?? `HTTP ${probe.status}`,
661
+ status: probe.status,
662
+ latencyMs: probe.latencyMs,
663
+ });
664
+ }
665
+ return true;
666
+ }
667
+ async function handleRefreshUsage(ctx, providerId, accountId) {
668
+ const { res, json, error } = ctx;
669
+ const subscription = asSubscriptionProvider(providerId);
670
+ if (!subscription) {
671
+ error(res, `Usage refresh not supported for ${providerId}`, 501);
672
+ return true;
673
+ }
674
+ const linked = readLinkedAccountsRecord(ctx.state.config)[accountId];
675
+ if (!isRichLinkedAccount(linked) || linked.providerId !== providerId) {
676
+ error(res, "Account not found", 404);
677
+ return true;
678
+ }
679
+ const accessToken = await getAccessToken(subscription, accountId);
680
+ if (!accessToken) {
681
+ error(res, "No credential available", 400);
682
+ return true;
683
+ }
684
+ // Prefer WS2's pool when it's loaded — it owns the canonical
685
+ // `pollAnthropicUsage` / `pollCodexUsage` calls plus the in-memory
686
+ // health/cooldown cache. Falls back to an inline 1-token probe when
687
+ // the pool isn't reachable (e.g. tests, leaner installs).
688
+ const poolResult = await tryRefreshViaPool({
689
+ accountId,
690
+ accessToken,
691
+ codexAccountId: linked.organizationId,
692
+ });
693
+ if (poolResult.ok) {
694
+ const refreshed = readLinkedAccountsRecord(ctx.state.config)[accountId];
695
+ if (isRichLinkedAccount(refreshed)) {
696
+ json(res, { account: refreshed, source: "pool" });
697
+ return true;
698
+ }
699
+ }
700
+ const probe = subscription === "anthropic-subscription"
701
+ ? await probeAnthropicUsage(accessToken)
702
+ : await probeCodexUsage(accessToken, linked.organizationId);
703
+ const next = {
704
+ ...linked,
705
+ ...(probe.usage ? { usage: probe.usage } : {}),
706
+ health: probe.ok ? "ok" : "rate-limited",
707
+ healthDetail: probe.ok
708
+ ? { lastChecked: Date.now() }
709
+ : {
710
+ lastChecked: Date.now(),
711
+ ...(probe.error ? { lastError: probe.error } : {}),
712
+ },
713
+ };
714
+ writeLinkedAccount(ctx.state.config, next);
715
+ ctx.saveConfig(ctx.state.config);
716
+ json(res, { account: next, probe, source: "inline-probe" });
717
+ return true;
718
+ }
719
+ /**
720
+ * Try to drive the usage refresh through WS2's `AccountPool` singleton
721
+ * if it's loaded. The pool lives in `@elizaos/app-core` (which depends
722
+ * on `@elizaos/agent`, not the other way around), so we resolve it via
723
+ * a runtime dynamic import to avoid a cyclic dependency. Returns
724
+ * `{ ok: false }` if the pool can't be loaded — caller then falls back
725
+ * to the inline probe.
726
+ */
727
+ async function tryRefreshViaPool(args) {
728
+ try {
729
+ // The dynamic import resolves at runtime only; bundlers / tsc
730
+ // don't follow it as a hard edge.
731
+ const moduleId = "@elizaos/app-core/services/account-pool";
732
+ const mod = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ moduleId)));
733
+ if (!mod.getDefaultAccountPool)
734
+ return { ok: false };
735
+ const pool = mod.getDefaultAccountPool();
736
+ await pool.refreshUsage(args.accountId, args.accessToken, {
737
+ ...(args.codexAccountId ? { codexAccountId: args.codexAccountId } : {}),
738
+ });
739
+ return { ok: true };
740
+ }
741
+ catch (err) {
742
+ logger.debug(`[accounts] pool refreshUsage unavailable: ${String(err)}`);
743
+ return { ok: false };
744
+ }
745
+ }