fix: restore cache + add .env reload on auth failure

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 <noreply@anthropic.com>
This commit is contained in:
ywkj 2026-03-20 06:24:52 +08:00
parent 153b05414e
commit 466a4303b2
6 changed files with 218 additions and 11 deletions

View File

@ -1,5 +1,6 @@
import type { ApiResponse, EnvConfig, HttpMethod, SessionResponse } from './types.js'; import type { ApiResponse, EnvConfig, HttpMethod, SessionResponse } from './types.js';
import { requestApi } from './http.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_STATUS = new Set([401, 403]);
const SESSION_RETRYABLE_BODY_MARKERS = [ const SESSION_RETRYABLE_BODY_MARKERS = [
@ -17,6 +18,8 @@ export function createEnvConfig(): EnvConfig {
return { return {
authBase: (process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''), authBase: (process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''),
clientKey: process.env.CLIENT_KEY || '', 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( export async function getAccessToken(
dryRun: boolean, dryRun: boolean,
config: EnvConfig config: EnvConfig
): Promise<string> { ): Promise<string> {
if (dryRun) {
return '<dry-run-token>';
}
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); const session = await fetchSessionJson(dryRun, config);
writeCache(cacheFile, session);
return session.accessToken; return session.accessToken;
} }
/** /**
* Refresh access token (same as getAccessToken no cache to clear) * Refresh access token (bypass cache)
*/ */
export async function refreshAccessToken( export async function refreshAccessToken(
dryRun: boolean, dryRun: boolean,
config: EnvConfig config: EnvConfig
): Promise<string> { ): Promise<string> {
if (dryRun) {
return '<dry-run-token>';
}
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); return getAccessToken(dryRun, config);
} }

83
src/cache.ts Normal file
View File

@ -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);
}
}

View File

@ -1,6 +1,7 @@
import type { ApiResponse, ClientConfig, EnvConfig, HttpMethod, SessionResponse } from './types.js'; import type { ApiResponse, ClientConfig, EnvConfig, HttpMethod, SessionResponse } from './types.js';
import { requestApi } from './http.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_STATUS = new Set([401, 403]);
const SESSION_RETRYABLE_BODY_MARKERS = [ const SESSION_RETRYABLE_BODY_MARKERS = [
@ -20,13 +21,18 @@ export interface SkillClientOptions {
apiBase?: string; apiBase?: string;
/** Dry run mode — no real HTTP calls */ /** Dry run mode — no real HTTP calls */
dryRun?: boolean; 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 { function buildConfig(options: SkillClientOptions): EnvConfig {
loadGlobalEnv();
return { return {
authBase: (options.authBase || process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''), authBase: (options.authBase || process.env.AUTH_BASE || 'https://api-gw-test.yuanwei-lnc.com').replace(/\/$/, ''),
clientKey: options.clientKey || process.env.CLIENT_KEY || '', 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)); 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 { export class SkillClient {
private readonly config: EnvConfig; private config: EnvConfig;
private readonly apiBase: string; private apiBase: string;
private readonly dryRun: boolean; private readonly dryRun: boolean;
private readonly options: SkillClientOptions;
constructor(options: SkillClientOptions = {}) { constructor(options: SkillClientOptions = {}) {
this.options = options;
loadGlobalEnv();
this.config = buildConfig(options); this.config = buildConfig(options);
this.apiBase = (options.apiBase || process.env.ECOM_BASE || this.config.authBase).replace(/\/$/, ''); this.apiBase = (options.apiBase || process.env.ECOM_BASE || this.config.authBase).replace(/\/$/, '');
this.dryRun = options.dryRun ?? false; this.dryRun = options.dryRun ?? false;
@ -111,16 +125,62 @@ export class SkillClient {
const url = `${this.apiBase}${path}`; const url = `${this.apiBase}${path}`;
const bodyStr = body != null ? JSON.stringify(body) : undefined; 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); const first = await requestApi(method, url, token, bodyStr);
if (!isRetryable(first)) { if (!isRetryable(first)) {
return first; return first;
} }
// Token rejected — fetch fresh and retry once // 2. Token rejected — clear cache, fetch new session with same key
const freshToken = (await this.fetchSession()).accessToken; const freshToken = await this.refreshToken();
return requestApi(method, url, freshToken, bodyStr); 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<string> {
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<string> {
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<SessionResponse> { private async fetchSession(): Promise<SessionResponse> {

View File

@ -4,15 +4,33 @@ import * as os from 'os';
const GLOBAL_ENV_PATH = path.join(os.homedir(), '.openclaw', '.env'); 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<string>();
let loaded = false; 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 { export function loadGlobalEnv(): void {
if (loaded) return; if (loaded) return;
loaded = true; 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; let content: string;
try { try {
content = fs.readFileSync(GLOBAL_ENV_PATH, 'utf-8'); content = fs.readFileSync(GLOBAL_ENV_PATH, 'utf-8');
@ -38,6 +56,7 @@ export function loadGlobalEnv(): void {
// don't overwrite explicitly set env vars // don't overwrite explicitly set env vars
if (process.env[key] === undefined) { if (process.env[key] === undefined) {
process.env[key] = value; process.env[key] = value;
loadedKeys.add(key);
} }
} }
} }

View File

@ -19,6 +19,7 @@ export type {
EnvConfig, EnvConfig,
SessionResponse, SessionResponse,
ClientConfig, ClientConfig,
CachedTokenData,
ApiResponse, ApiResponse,
HttpMethod, HttpMethod,
} from './types.js'; } from './types.js';

View File

@ -6,6 +6,10 @@ export interface EnvConfig {
authBase: string; authBase: string;
/** Client key for authentication */ /** Client key for authentication */
clientKey: string; 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<string, unknown>; metadata: Record<string, unknown>;
} }
/**
* Cached token data stored on disk
*/
export interface CachedTokenData {
accessToken: string;
expiresAtEpoch: number;
createdAtEpoch: number;
}
/** /**
* HTTP method used by requestApi * HTTP method used by requestApi
*/ */