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:
parent
153b05414e
commit
466a4303b2
35
src/auth.ts
35
src/auth.ts
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
21
src/env.ts
21
src/env.ts
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export type {
|
||||||
EnvConfig,
|
EnvConfig,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
ClientConfig,
|
ClientConfig,
|
||||||
|
CachedTokenData,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
|
||||||
13
src/types.ts
13
src/types.ts
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue