@elizaos/plugin-elizacloud 2.0.3-beta.5 → 2.0.3-beta.7

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.
@@ -26,11 +26,10 @@ import {
26
26
  resolveApiSecurityConfig,
27
27
  resolveDesktopApiPort,
28
28
  } from "@elizaos/core";
29
- import { isCloudReachable } from "@elizaos/shared";
30
29
  import { createRemoteJWKSet, jwtVerify } from "jose";
31
30
  import type { CloudCredentials, DeviceAuthResponse, DevicePlatform } from "../types/cloud";
32
31
  import { DEFAULT_CLOUD_CONFIG } from "../types/cloud";
33
- import { CloudApiClient } from "../utils/cloud-api";
32
+ import { CloudApiClient, CloudApiError } from "../utils/cloud-api";
34
33
  import type { CloudBootstrapService } from "./cloud-bootstrap";
35
34
 
36
35
  /** SHA-256 hash of hostname + platform + arch + cpu + memory. */
@@ -389,12 +388,115 @@ export async function exchangeCodeForSession(args: ExchangeCodeArgs): Promise<Cl
389
388
 
390
389
  // ─── Service ───────────────────────────────────────────────────────────────
391
390
 
391
+ /** Result of probing the saved API key against the cloud. */
392
+ export type ApiKeyProbe = "valid" | "unreachable" | "invalid";
393
+
394
+ export interface RevalidationState {
395
+ /** `unknown` until a probe resolves; `invalid` once a revoked key is confirmed. */
396
+ keyState: "unknown" | "valid" | "invalid";
397
+ /** Consecutive reachable-but-rejected probes; debounces a transient 5xx. */
398
+ consecutiveInvalid: number;
399
+ }
400
+
401
+ export interface RevalidationConfig {
402
+ /** Re-probe delay while the key state is unresolved (unreachable / unconfirmed-invalid). */
403
+ retryMs: number;
404
+ /** Re-probe delay once the key state is resolved — catches a LATER revocation. */
405
+ steadyMs: number;
406
+ /** Reachable-but-rejected probes required before declaring the key revoked. */
407
+ invalidThreshold: number;
408
+ }
409
+
410
+ export interface RevalidationDecision {
411
+ state: RevalidationState;
412
+ delayMs: number;
413
+ log: { level: "info" | "error"; message: string } | null;
414
+ }
415
+
416
+ export const DEFAULT_REVALIDATION_CONFIG: RevalidationConfig = {
417
+ retryMs: 60_000,
418
+ steadyMs: 30 * 60_000,
419
+ invalidThreshold: 2,
420
+ };
421
+
422
+ /**
423
+ * Pure state machine for background API-key re-validation. Given the current
424
+ * state and a fresh probe result, decide the next state, when to re-probe, and
425
+ * whether to emit a one-shot state-change log. No I/O — fully unit-testable.
426
+ *
427
+ * Why this exists: at boot the key is trusted optimistically and validated once
428
+ * in the background. If the cloud was unreachable at boot (or the key is revoked
429
+ * AFTER boot), the one-shot check left the agent 401-blind on every turn with no
430
+ * surfaced state. This loop retries transient unreachability, confirms a revoked
431
+ * key with a loud actionable error (debounced so a single 5xx doesn't false-
432
+ * alarm), and steady-re-checks so a post-boot revocation is still caught and a
433
+ * later re-authorization self-heals.
434
+ */
435
+ export function decideRevalidation(
436
+ prev: RevalidationState,
437
+ probe: ApiKeyProbe,
438
+ cfg: RevalidationConfig = DEFAULT_REVALIDATION_CONFIG
439
+ ): RevalidationDecision {
440
+ if (probe === "valid") {
441
+ const log =
442
+ prev.keyState !== "valid"
443
+ ? { level: "info" as const, message: "[CloudAuth] API key validated" }
444
+ : null;
445
+ return {
446
+ state: { keyState: "valid", consecutiveInvalid: 0 },
447
+ delayMs: cfg.steadyMs,
448
+ log,
449
+ };
450
+ }
451
+
452
+ if (probe === "invalid") {
453
+ const consecutiveInvalid = prev.consecutiveInvalid + 1;
454
+ const confirmed = consecutiveInvalid >= cfg.invalidThreshold;
455
+ if (confirmed) {
456
+ const log =
457
+ prev.keyState !== "invalid"
458
+ ? {
459
+ level: "error" as const,
460
+ message:
461
+ "[CloudAuth] Eliza Cloud API key is REVOKED/INVALID (cloud reachable, key rejected). " +
462
+ "Model calls will fail with 401 until the agent is re-provisioned or a valid ELIZAOS_CLOUD_API_KEY is set.",
463
+ }
464
+ : null;
465
+ return {
466
+ state: { keyState: "invalid", consecutiveInvalid },
467
+ delayMs: cfg.steadyMs,
468
+ log,
469
+ };
470
+ }
471
+ // Not yet confirmed — re-probe soon (don't change the surfaced state yet).
472
+ return {
473
+ state: { keyState: prev.keyState, consecutiveInvalid },
474
+ delayMs: cfg.retryMs,
475
+ log: null,
476
+ };
477
+ }
478
+
479
+ // unreachable — transient network state, not an auth signal: keep the prior
480
+ // state and back off. (consecutiveInvalid is preserved, not reset, so a blip
481
+ // between two real rejections doesn't restart the confirmation count.)
482
+ return {
483
+ state: { ...prev },
484
+ delayMs: cfg.retryMs,
485
+ log: null,
486
+ };
487
+ }
488
+
392
489
  export class CloudAuthService extends Service {
393
490
  static serviceType = "CLOUD_AUTH";
394
491
  capabilityDescription = "Eliza Cloud device authentication and SSO session helpers";
395
492
 
396
493
  private client: CloudApiClient;
397
494
  private credentials: CloudCredentials | null = null;
495
+ private revalidationTimer: ReturnType<typeof setTimeout> | null = null;
496
+ private revalidationState: RevalidationState = {
497
+ keyState: "unknown",
498
+ consecutiveInvalid: 0,
499
+ };
398
500
 
399
501
  constructor(runtime?: IAgentRuntime) {
400
502
  super(runtime);
@@ -408,6 +510,11 @@ export class CloudAuthService extends Service {
408
510
  }
409
511
 
410
512
  async stop(): Promise<void> {
513
+ if (this.revalidationTimer) {
514
+ clearTimeout(this.revalidationTimer);
515
+ this.revalidationTimer = null;
516
+ }
517
+ this.revalidationState = { keyState: "unknown", consecutiveInvalid: 0 };
411
518
  this.credentials = null;
412
519
  }
413
520
 
@@ -441,19 +548,13 @@ export class CloudAuthService extends Service {
441
548
  };
442
549
  logger.info("[CloudAuth] Authenticated with saved API key");
443
550
 
444
- // Non-blocking validation — if the key is invalid the next model
445
- // call will surface the error; we just log a warning here.
446
- this.validateApiKey(key)
447
- .then((valid) => {
448
- if (!valid) {
449
- logger.warn(
450
- "[CloudAuth] Saved API key could not be validated (cloud may be unreachable or key revoked) — model calls will use the key anyway"
451
- );
452
- }
453
- })
454
- .catch(() => {
455
- // Swallow — already logged inside validateApiKey
456
- });
551
+ // Non-blocking, self-healing key re-validation. Boot stays instant; a
552
+ // background loop confirms the key retrying transient cloud-unreachable
553
+ // so a boot-time outage doesn't leave it unvalidated forever, surfacing a
554
+ // loud actionable error the moment the key is confirmed revoked, and
555
+ // steady-re-checking so a revocation that happens AFTER boot is caught
556
+ // instead of the agent silently 401ing every turn (see #8434 key-flag).
557
+ this.scheduleRevalidation(0);
457
558
  return;
458
559
  }
459
560
 
@@ -474,22 +575,75 @@ export class CloudAuthService extends Service {
474
575
  }
475
576
  }
476
577
 
477
- private async validateApiKey(key: string): Promise<boolean> {
478
- if (!(await isCloudReachable())) {
479
- logger.warn(
480
- "[CloudAuth] Cloud unreachable at boot skipping API key validation; key will be used as-is"
481
- );
482
- return false;
483
- }
578
+ /**
579
+ * Probe the saved key: `valid` when an authenticated `/models` call succeeds,
580
+ * `invalid` ONLY when the cloud is reachable and explicitly rejects the key
581
+ * (401/403revoked/expired), `unreachable` for every other failure (5xx,
582
+ * 429, timeout, network error). Only a `CloudApiError` carries a real HTTP
583
+ * status; timeouts (`AbortSignal.timeout`) and connection failures reject with
584
+ * a raw fetch/DOMException and so are correctly treated as `unreachable`. A
585
+ * server-side 5xx/429 is NOT an auth signal — classifying it as `invalid`
586
+ * would false-alarm a revoked-key error on a transient outage. The `invalid`
587
+ * case is still debounced upstream so a single 401 blip can't flip the state.
588
+ */
589
+ private async probeApiKey(key: string): Promise<ApiKeyProbe> {
484
590
  try {
485
591
  const validationClient = new CloudApiClient(this.client.getBaseUrl(), key);
486
592
  await validationClient.get("/models", { timeoutMs: 2_500 });
487
- return true;
593
+ return "valid";
488
594
  } catch (err) {
489
- const msg = err instanceof Error ? err.message : String(err);
490
- logger.warn(`[CloudAuth] Could not reach cloud API to validate key: ${msg}`);
491
- return false;
595
+ if (err instanceof CloudApiError && (err.statusCode === 401 || err.statusCode === 403)) {
596
+ return "invalid";
597
+ }
598
+ return "unreachable";
599
+ }
600
+ }
601
+
602
+ private scheduleRevalidation(delayMs: number): void {
603
+ if (this.revalidationTimer) {
604
+ clearTimeout(this.revalidationTimer);
492
605
  }
606
+ // The timer is always cleared in stop(), so it can't outlive the service.
607
+ const timer = setTimeout(() => {
608
+ void this.runRevalidation();
609
+ }, delayMs);
610
+ // Don't let the background re-probe pin the Node event loop / block a clean
611
+ // process exit. `unref` is Node-only; it's absent on the browser shim type.
612
+ timer.unref?.();
613
+ this.revalidationTimer = timer;
614
+ }
615
+
616
+ private async runRevalidation(): Promise<void> {
617
+ const key = this.credentials?.apiKey;
618
+ if (!key) {
619
+ return; // logged out / stopped — let the loop die
620
+ }
621
+ const probe = await this.probeApiKey(key).catch(
622
+ () => "unreachable" as ApiKeyProbe
623
+ );
624
+ // The active key may have changed during the await (re-auth) — drop stale.
625
+ if (this.credentials?.apiKey !== key) {
626
+ return;
627
+ }
628
+ const decision = decideRevalidation(this.revalidationState, probe);
629
+ this.revalidationState = decision.state;
630
+ if (decision.log) {
631
+ if (decision.log.level === "error") {
632
+ logger.error(decision.log.message);
633
+ } else {
634
+ logger.info(decision.log.message);
635
+ }
636
+ }
637
+ this.scheduleRevalidation(decision.delayMs);
638
+ }
639
+
640
+ /**
641
+ * True once a background probe has CONFIRMED the saved API key is
642
+ * revoked/invalid (cloud reachable, key rejected). Status/health surfaces can
643
+ * read this to report a degraded agent instead of letting it 401 blindly.
644
+ */
645
+ isApiKeyInvalid(): boolean {
646
+ return this.revalidationState.keyState === "invalid";
493
647
  }
494
648
 
495
649
  /**