From 466a4303b247f99eb444a74b014c40b148974e02 Mon Sep 17 00:00:00 2001 From: ywkj Date: Fri, 20 Mar 2026 06:24:52 +0800 Subject: [PATCH] fix: restore cache + add .env reload on auth failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct retry logic: 1. Use cached token 2. Token rejected → refresh session with current CLIENT_KEY 3. Still failing → reloadGlobalEnv() to pick up updated .env, rebuild config with new CLIENT_KEY, retry env.ts now tracks which keys it loaded from .env so reloadGlobalEnv() can overwrite them without touching externally-set env vars. Co-Authored-By: Claude Opus 4.6 --- src/auth.ts | 35 ++++++++++++++++++++-- src/cache.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 76 +++++++++++++++++++++++++++++++++++++++++----- src/env.ts | 21 ++++++++++++- src/index.ts | 1 + src/types.ts | 13 ++++++++ 6 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 src/cache.ts diff --git a/src/auth.ts b/src/auth.ts index bf82ff1..d9a1a45 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,5 +1,6 @@ import type { ApiResponse, EnvConfig, HttpMethod, SessionResponse } from './types.js'; import { requestApi } from './http.js'; +import { getCacheFile, readCachedToken, writeCache, deleteCache } from './cache.js'; const SESSION_RETRYABLE_STATUS = new Set([401, 403]); const SESSION_RETRYABLE_BODY_MARKERS = [ @@ -17,6 +18,8 @@ export function createEnvConfig(): EnvConfig { return { authBase: (process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''), clientKey: process.env.CLIENT_KEY || '', + authCacheDir: process.env.AUTH_CACHE_DIR || '/tmp/skill-auth-cache', + authMinTtlSec: parseInt(process.env.AUTH_MIN_TTL_SEC || '60', 10), }; } @@ -62,23 +65,51 @@ export async function fetchSessionJson( } /** - * Get access token (always fetches fresh) + * Get access token with caching */ export async function getAccessToken( dryRun: boolean, config: EnvConfig ): Promise { + if (dryRun) { + return ''; + } + + if (!config.clientKey) { + throw new Error('CLIENT_KEY is required'); + } + + const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir); + const cachedToken = readCachedToken(cacheFile, config.authMinTtlSec); + + if (cachedToken) { + return cachedToken; + } + const session = await fetchSessionJson(dryRun, config); + writeCache(cacheFile, session); + return session.accessToken; } /** - * Refresh access token (same as getAccessToken — no cache to clear) + * Refresh access token (bypass cache) */ export async function refreshAccessToken( dryRun: boolean, config: EnvConfig ): Promise { + if (dryRun) { + return ''; + } + + if (!config.clientKey) { + throw new Error('CLIENT_KEY is required'); + } + + const cacheFile = getCacheFile(config.authBase, config.clientKey, config.authCacheDir); + deleteCache(cacheFile); + return getAccessToken(dryRun, config); } diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..c2b13c6 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { CachedTokenData } from './types.js'; + +/** + * Generate SHA256 hash for cache key + */ +export function sha256(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +/** + * Get cache file path for current AUTH_BASE and CLIENT_KEY + */ +export function getCacheFile(authBase: string, clientKey: string, cacheDir: string): string { + const key = sha256(`${authBase}|${clientKey}`); + + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + return path.join(cacheDir, `session_${key}.json`); +} + +/** + * Read cached token if valid + */ +export function readCachedToken( + cacheFile: string, + minTtlSec: number +): string | null { + if (!fs.existsSync(cacheFile)) { + return null; + } + + try { + const data = JSON.parse(fs.readFileSync(cacheFile, 'utf-8')) as CachedTokenData; + const now = Math.floor(Date.now() / 1000); + + if (!data.accessToken || data.expiresAtEpoch <= 0) { + return null; + } + + // Check if token is still valid (with min TTL buffer) + if (now + minTtlSec >= data.expiresAtEpoch) { + return null; + } + + return data.accessToken; + } catch (error) { + return null; + } +} + +/** + * Write token to cache + */ +export function writeCache( + cacheFile: string, + sessionJson: { accessToken: string; expiresIn: number } +): void { + const nowEpoch = Math.floor(Date.now() / 1000); + const expiresIn = sessionJson.expiresIn > 0 ? sessionJson.expiresIn : 900; + const expiresAtEpoch = nowEpoch + expiresIn; + + const cacheData: CachedTokenData = { + accessToken: sessionJson.accessToken, + expiresAtEpoch, + createdAtEpoch: nowEpoch, + }; + + fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2), 'utf-8'); +} + +/** + * Delete cache file if exists + */ +export function deleteCache(cacheFile: string): void { + if (fs.existsSync(cacheFile)) { + fs.unlinkSync(cacheFile); + } +} diff --git a/src/client.ts b/src/client.ts index ace7f52..17a87b1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import type { ApiResponse, ClientConfig, EnvConfig, HttpMethod, SessionResponse } from './types.js'; import { requestApi } from './http.js'; -import { loadGlobalEnv } from './env.js'; +import { getCacheFile, readCachedToken, writeCache, deleteCache } from './cache.js'; +import { loadGlobalEnv, reloadGlobalEnv } from './env.js'; const SESSION_RETRYABLE_STATUS = new Set([401, 403]); const SESSION_RETRYABLE_BODY_MARKERS = [ @@ -20,13 +21,18 @@ export interface SkillClientOptions { apiBase?: string; /** Dry run mode — no real HTTP calls */ dryRun?: boolean; + /** Cache directory (default: /tmp/skill-auth-cache) */ + cacheDir?: string; + /** Min TTL before token refresh, seconds (default: 60) */ + minTtlSec?: number; } function buildConfig(options: SkillClientOptions): EnvConfig { - loadGlobalEnv(); return { authBase: (options.authBase || process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''), clientKey: options.clientKey || process.env.CLIENT_KEY || '', + authCacheDir: options.cacheDir || process.env.AUTH_CACHE_DIR || '/tmp/skill-auth-cache', + authMinTtlSec: options.minTtlSec ?? parseInt(process.env.AUTH_MIN_TTL_SEC || '60', 10), }; } @@ -37,12 +43,20 @@ function isRetryable(response: ApiResponse): boolean { return SESSION_RETRYABLE_BODY_MARKERS.some((m) => body.includes(m)); } +function isKeyError(response: ApiResponse): boolean { + const body = (response.body || '').toLowerCase(); + return body.includes('client key revoked') || body.includes('client key expired'); +} + export class SkillClient { - private readonly config: EnvConfig; - private readonly apiBase: string; + private config: EnvConfig; + private apiBase: string; private readonly dryRun: boolean; + private readonly options: SkillClientOptions; constructor(options: SkillClientOptions = {}) { + this.options = options; + loadGlobalEnv(); this.config = buildConfig(options); this.apiBase = (options.apiBase || process.env.ECOM_BASE || this.config.authBase).replace(/\/$/, ''); this.dryRun = options.dryRun ?? false; @@ -111,16 +125,62 @@ export class SkillClient { const url = `${this.apiBase}${path}`; const bodyStr = body != null ? JSON.stringify(body) : undefined; - const token = (await this.fetchSession()).accessToken; + // 1. Try with cached or fresh token + const token = await this.getToken(); const first = await requestApi(method, url, token, bodyStr); if (!isRetryable(first)) { return first; } - // Token rejected — fetch fresh and retry once - const freshToken = (await this.fetchSession()).accessToken; - return requestApi(method, url, freshToken, bodyStr); + // 2. Token rejected — clear cache, fetch new session with same key + const freshToken = await this.refreshToken(); + const second = await requestApi(method, url, freshToken, bodyStr); + + if (!isRetryable(second)) { + return second; + } + + // 3. Still failing — maybe CLIENT_KEY changed in .env, reload and retry + this.reloadConfig(); + const reloadedToken = await this.refreshToken(); + return requestApi(method, url, reloadedToken, bodyStr); + } + + private async getToken(): Promise { + const cacheFile = getCacheFile(this.config.authBase, this.config.clientKey, this.config.authCacheDir); + const cached = readCachedToken(cacheFile, this.config.authMinTtlSec); + if (cached) return cached; + + const session = await this.fetchSession(); + writeCache(cacheFile, session); + return session.accessToken; + } + + private async refreshToken(): Promise { + const cacheFile = getCacheFile(this.config.authBase, this.config.clientKey, this.config.authCacheDir); + deleteCache(cacheFile); + + const session = await this.fetchSession(); + writeCache(cacheFile, session); + return session.accessToken; + } + + /** + * Re-read ~/.openclaw/.env and rebuild config. + * Called when auth keeps failing — the CLIENT_KEY may have been updated on disk. + */ + private reloadConfig(): void { + reloadGlobalEnv(); + const oldKey = this.config.clientKey; + this.config = buildConfig(this.options); + this.apiBase = (this.options.apiBase || process.env.ECOM_BASE || this.config.authBase).replace(/\/$/, ''); + + if (this.config.clientKey !== oldKey) { + // Clear old key's cache too + const oldCacheFile = getCacheFile(this.config.authBase, oldKey, this.config.authCacheDir); + deleteCache(oldCacheFile); + } } private async fetchSession(): Promise { diff --git a/src/env.ts b/src/env.ts index ccfcd61..8a38b74 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,15 +4,33 @@ import * as os from 'os'; const GLOBAL_ENV_PATH = path.join(os.homedir(), '.openclaw', '.env'); +/** Keys that were loaded from .env (not pre-existing in process.env) */ +const loadedKeys = new Set(); let loaded = false; /** - * Load ~/.openclaw/.env into process.env (once, won't overwrite existing vars). + * Load ~/.openclaw/.env into process.env (once, won't overwrite explicitly set env vars). */ export function loadGlobalEnv(): void { if (loaded) return; loaded = true; + applyEnvFile(); +} +/** + * Force re-read ~/.openclaw/.env. Overwrites keys that were originally loaded + * from .env, but still won't touch keys set externally (e.g. shell export). + */ +export function reloadGlobalEnv(): void { + // Clear values we previously loaded so applyEnvFile can overwrite them + for (const key of loadedKeys) { + delete process.env[key]; + } + loadedKeys.clear(); + applyEnvFile(); +} + +function applyEnvFile(): void { let content: string; try { content = fs.readFileSync(GLOBAL_ENV_PATH, 'utf-8'); @@ -38,6 +56,7 @@ export function loadGlobalEnv(): void { // don't overwrite explicitly set env vars if (process.env[key] === undefined) { process.env[key] = value; + loadedKeys.add(key); } } } diff --git a/src/index.ts b/src/index.ts index d131f6f..ee2ac82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export type { EnvConfig, SessionResponse, ClientConfig, + CachedTokenData, ApiResponse, HttpMethod, } from './types.js'; diff --git a/src/types.ts b/src/types.ts index 4532b70..8e83271 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,10 @@ export interface EnvConfig { authBase: string; /** Client key for authentication */ clientKey: string; + /** Directory for storing auth cache files */ + authCacheDir: string; + /** Minimum TTL for cached tokens in seconds */ + authMinTtlSec: number; } /** @@ -29,6 +33,15 @@ export interface ClientConfig { metadata: Record; } +/** + * Cached token data stored on disk + */ +export interface CachedTokenData { + accessToken: string; + expiresAtEpoch: number; + createdAtEpoch: number; +} + /** * HTTP method used by requestApi */