mirror of https://github.com/openclaw/openclaw.git
refactor(auth): unify external CLI credential sync
This commit is contained in:
parent
27448c3113
commit
8e9e2d2f4e
|
|
@ -1,21 +1,70 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readCodexCliCredentialsCached: vi.fn(),
|
||||
readQwenCliCredentialsCached: vi.fn(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn(() => null),
|
||||
readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
readQwenCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
|
||||
let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials;
|
||||
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential;
|
||||
let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID;
|
||||
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
|
||||
let QWEN_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").QWEN_CLI_PROFILE_ID;
|
||||
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
|
||||
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
function makeOAuthCredential(
|
||||
overrides: Partial<OAuthCredential> & Pick<OAuthCredential, "provider">,
|
||||
) {
|
||||
return {
|
||||
type: "oauth" as const,
|
||||
provider: overrides.provider,
|
||||
access: overrides.access ?? `${overrides.provider}-access`,
|
||||
refresh: overrides.refresh ?? `${overrides.provider}-refresh`,
|
||||
expires: overrides.expires ?? Date.now() + 60_000,
|
||||
accountId: overrides.accountId,
|
||||
email: overrides.email,
|
||||
enterpriseUrl: overrides.enterpriseUrl,
|
||||
projectId: overrides.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
profiles: profileId && credential ? { [profileId]: credential } : {},
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderCases() {
|
||||
return [
|
||||
{
|
||||
label: "Codex",
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
provider: "openai-codex" as const,
|
||||
readMock: mocks.readCodexCliCredentialsCached,
|
||||
legacyProfileId: CODEX_CLI_PROFILE_ID,
|
||||
},
|
||||
{
|
||||
label: "Qwen",
|
||||
profileId: QWEN_CLI_PROFILE_ID,
|
||||
provider: "qwen-portal" as const,
|
||||
readMock: mocks.readQwenCliCredentialsCached,
|
||||
},
|
||||
{
|
||||
label: "MiniMax",
|
||||
profileId: MINIMAX_CLI_PROFILE_ID,
|
||||
provider: "minimax-portal" as const,
|
||||
readMock: mocks.readMiniMaxCliCredentialsCached,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("syncExternalCliCredentials", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mocks.readCodexCliCredentialsCached.mockReset();
|
||||
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
mocks.readQwenCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
vi.doMock("./cli-credentials.js", () => ({
|
||||
|
|
@ -23,68 +72,118 @@ describe("syncExternalCliCredentials", () => {
|
|||
readQwenCliCredentialsCached: mocks.readQwenCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
|
||||
}));
|
||||
({ syncExternalCliCredentials } = await import("./auth-profiles/external-cli-sync.js"));
|
||||
({ CODEX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js"));
|
||||
({ syncExternalCliCredentials, shouldReplaceStoredOAuthCredential } =
|
||||
await import("./auth-profiles/external-cli-sync.js"));
|
||||
({
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
} = await import("./auth-profiles/constants.js"));
|
||||
});
|
||||
|
||||
it("syncs Codex CLI credentials into the supported default auth profile", () => {
|
||||
const expires = Date.now() + 60_000;
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires,
|
||||
accountId: "acct_123",
|
||||
describe("shouldReplaceStoredOAuthCredential", () => {
|
||||
it("keeps equivalent stored credentials", () => {
|
||||
const stored = makeOAuthCredential({ provider: "openai-codex", access: "a", refresh: "r" });
|
||||
const incoming = makeOAuthCredential({ provider: "openai-codex", access: "a", refresh: "r" });
|
||||
|
||||
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
|
||||
});
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
it("keeps the newer stored credential", () => {
|
||||
const incoming = makeOAuthCredential({
|
||||
provider: "openai-codex",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
const stored = makeOAuthCredential({
|
||||
provider: "openai-codex",
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const mutated = syncExternalCliCredentials(store);
|
||||
|
||||
expect(mutated).toBe(true);
|
||||
expect(mocks.readCodexCliCredentialsCached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ttlMs: expect.any(Number) }),
|
||||
);
|
||||
expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires,
|
||||
accountId: "acct_123",
|
||||
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces when incoming credentials are fresher", () => {
|
||||
const stored = makeOAuthCredential({
|
||||
provider: "openai-codex",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
const incoming = makeOAuthCredential({
|
||||
provider: "openai-codex",
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
expect(shouldReplaceStoredOAuthCredential(stored, incoming)).toBe(true);
|
||||
expect(shouldReplaceStoredOAuthCredential(undefined, incoming)).toBe(true);
|
||||
});
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([{ providerLabel: "Codex" }, { providerLabel: "Qwen" }, { providerLabel: "MiniMax" }])(
|
||||
"syncs $providerLabel CLI credentials into the target auth profile",
|
||||
({ providerLabel }) => {
|
||||
const providerCase = getProviderCases().find((entry) => entry.label === providerLabel);
|
||||
expect(providerCase).toBeDefined();
|
||||
const current = providerCase!;
|
||||
const expires = Date.now() + 60_000;
|
||||
current.readMock.mockReturnValue(
|
||||
makeOAuthCredential({
|
||||
provider: current.provider,
|
||||
access: `${current.provider}-access-token`,
|
||||
refresh: `${current.provider}-refresh-token`,
|
||||
expires,
|
||||
accountId: "acct_123",
|
||||
}),
|
||||
);
|
||||
|
||||
const store = makeStore();
|
||||
|
||||
const mutated = syncExternalCliCredentials(store);
|
||||
|
||||
expect(mutated).toBe(true);
|
||||
expect(current.readMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ttlMs: expect.any(Number) }),
|
||||
);
|
||||
expect(store.profiles[current.profileId]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: current.provider,
|
||||
access: `${current.provider}-access-token`,
|
||||
refresh: `${current.provider}-refresh-token`,
|
||||
expires,
|
||||
accountId: "acct_123",
|
||||
});
|
||||
if (current.legacyProfileId) {
|
||||
expect(store.profiles[current.legacyProfileId]).toBeUndefined();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => {
|
||||
const staleExpiry = Date.now() + 30 * 60_000;
|
||||
const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000;
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "new-access-token",
|
||||
refresh: "new-refresh-token",
|
||||
expires: freshExpiry,
|
||||
accountId: "acct_456",
|
||||
});
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue(
|
||||
makeOAuthCredential({
|
||||
provider: "openai-codex",
|
||||
access: "new-access-token",
|
||||
refresh: "new-refresh-token",
|
||||
expires: freshExpiry,
|
||||
accountId: "acct_456",
|
||||
}),
|
||||
);
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "old-access-token",
|
||||
refresh: "old-refresh-token",
|
||||
expires: staleExpiry,
|
||||
accountId: "acct_456",
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = makeStore(
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
makeOAuthCredential({
|
||||
provider: "openai-codex",
|
||||
access: "old-access-token",
|
||||
refresh: "old-refresh-token",
|
||||
expires: staleExpiry,
|
||||
accountId: "acct_456",
|
||||
}),
|
||||
);
|
||||
|
||||
const mutated = syncExternalCliCredentials(store);
|
||||
|
||||
|
|
@ -96,39 +195,43 @@ describe("syncExternalCliCredentials", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not overwrite newer stored Codex credentials with older external CLI credentials", () => {
|
||||
const staleExpiry = Date.now() + 30 * 60_000;
|
||||
const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000;
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "stale-access-token",
|
||||
refresh: "stale-refresh-token",
|
||||
expires: staleExpiry,
|
||||
accountId: "acct_789",
|
||||
});
|
||||
it.each([{ providerLabel: "Codex" }, { providerLabel: "Qwen" }, { providerLabel: "MiniMax" }])(
|
||||
"does not overwrite newer stored $providerLabel credentials",
|
||||
({ providerLabel }) => {
|
||||
const providerCase = getProviderCases().find((entry) => entry.label === providerLabel);
|
||||
expect(providerCase).toBeDefined();
|
||||
const current = providerCase!;
|
||||
const staleExpiry = Date.now() + 30 * 60_000;
|
||||
const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000;
|
||||
current.readMock.mockReturnValue(
|
||||
makeOAuthCredential({
|
||||
provider: current.provider,
|
||||
access: `stale-${current.provider}-access-token`,
|
||||
refresh: `stale-${current.provider}-refresh-token`,
|
||||
expires: staleExpiry,
|
||||
accountId: "acct_789",
|
||||
}),
|
||||
);
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "fresh-access-token",
|
||||
refresh: "fresh-refresh-token",
|
||||
const store = makeStore(
|
||||
current.profileId,
|
||||
makeOAuthCredential({
|
||||
provider: current.provider,
|
||||
access: `fresh-${current.provider}-access-token`,
|
||||
refresh: `fresh-${current.provider}-refresh-token`,
|
||||
expires: freshExpiry,
|
||||
accountId: "acct_789",
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const mutated = syncExternalCliCredentials(store);
|
||||
const mutated = syncExternalCliCredentials(store);
|
||||
|
||||
expect(mutated).toBe(false);
|
||||
expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
|
||||
access: "fresh-access-token",
|
||||
refresh: "fresh-refresh-token",
|
||||
expires: freshExpiry,
|
||||
});
|
||||
});
|
||||
expect(mutated).toBe(false);
|
||||
expect(store.profiles[current.profileId]).toMatchObject({
|
||||
access: `fresh-${current.provider}-access-token`,
|
||||
refresh: `fresh-${current.provider}-refresh-token`,
|
||||
expires: freshExpiry,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json";
|
|||
|
||||
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
|
||||
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
|
||||
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli";
|
||||
export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli";
|
||||
|
||||
|
|
|
|||
|
|
@ -5,19 +5,27 @@ import {
|
|||
} from "../cli-credentials.js";
|
||||
import {
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
|
||||
type ExternalCliSyncOptions = {
|
||||
log?: boolean;
|
||||
};
|
||||
|
||||
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
|
||||
type ExternalCliSyncProvider = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
readCredentials: () => OAuthCredential | null;
|
||||
};
|
||||
|
||||
function areOAuthCredentialsEquivalent(
|
||||
a: OAuthCredential | undefined,
|
||||
b: OAuthCredential,
|
||||
): boolean {
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -48,14 +56,44 @@ function hasNewerStoredOAuthCredential(
|
|||
);
|
||||
}
|
||||
|
||||
export function shouldReplaceStoredOAuthCredential(
|
||||
existing: OAuthCredential | undefined,
|
||||
incoming: OAuthCredential,
|
||||
): boolean {
|
||||
if (!existing || existing.type !== "oauth") {
|
||||
return true;
|
||||
}
|
||||
if (areOAuthCredentialsEquivalent(existing, incoming)) {
|
||||
return false;
|
||||
}
|
||||
return !hasNewerStoredOAuthCredential(existing, incoming);
|
||||
}
|
||||
|
||||
const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
|
||||
{
|
||||
profileId: QWEN_CLI_PROFILE_ID,
|
||||
provider: "qwen-portal",
|
||||
readCredentials: () => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
},
|
||||
{
|
||||
profileId: MINIMAX_CLI_PROFILE_ID,
|
||||
provider: "minimax-portal",
|
||||
readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
},
|
||||
{
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
provider: "openai-codex",
|
||||
readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
},
|
||||
];
|
||||
|
||||
/** Sync external CLI credentials into the store for a given provider. */
|
||||
function syncExternalCliCredentialsForProvider(
|
||||
store: AuthProfileStore,
|
||||
profileId: string,
|
||||
provider: string,
|
||||
readCredentials: () => OAuthCredential | null,
|
||||
providerConfig: ExternalCliSyncProvider,
|
||||
options: ExternalCliSyncOptions,
|
||||
): boolean {
|
||||
const { profileId, provider, readCredentials } = providerConfig;
|
||||
const existing = store.profiles[profileId];
|
||||
const creds = readCredentials();
|
||||
if (!creds) {
|
||||
|
|
@ -63,18 +101,17 @@ function syncExternalCliCredentialsForProvider(
|
|||
}
|
||||
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
if (shallowEqualOAuthCredentials(existingOAuth, creds)) {
|
||||
return false;
|
||||
}
|
||||
if (hasNewerStoredOAuthCredential(existingOAuth, creds)) {
|
||||
if (!shouldReplaceStoredOAuthCredential(existingOAuth, creds)) {
|
||||
if (options.log !== false) {
|
||||
log.debug(`kept newer stored ${provider} credentials over external cli sync`, {
|
||||
profileId,
|
||||
storedExpires: new Date(existingOAuth!.expires).toISOString(),
|
||||
externalExpires: Number.isFinite(creds.expires)
|
||||
? new Date(creds.expires).toISOString()
|
||||
: null,
|
||||
});
|
||||
if (!areOAuthCredentialsEquivalent(existingOAuth, creds) && existingOAuth) {
|
||||
log.debug(`kept newer stored ${provider} credentials over external cli sync`, {
|
||||
profileId,
|
||||
storedExpires: new Date(existingOAuth.expires).toISOString(),
|
||||
externalExpires: Number.isFinite(creds.expires)
|
||||
? new Date(creds.expires).toISOString()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -101,38 +138,10 @@ export function syncExternalCliCredentials(
|
|||
): boolean {
|
||||
let mutated = false;
|
||||
|
||||
if (
|
||||
syncExternalCliCredentialsForProvider(
|
||||
store,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
"qwen-portal",
|
||||
() => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
options,
|
||||
)
|
||||
) {
|
||||
mutated = true;
|
||||
}
|
||||
if (
|
||||
syncExternalCliCredentialsForProvider(
|
||||
store,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
"minimax-portal",
|
||||
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
options,
|
||||
)
|
||||
) {
|
||||
mutated = true;
|
||||
}
|
||||
if (
|
||||
syncExternalCliCredentialsForProvider(
|
||||
store,
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
"openai-codex",
|
||||
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
options,
|
||||
)
|
||||
) {
|
||||
mutated = true;
|
||||
for (const provider of EXTERNAL_CLI_SYNC_PROVIDERS) {
|
||||
if (syncExternalCliCredentialsForProvider(store, provider, options)) {
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return mutated;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const execFileSyncMock = vi.fn();
|
|||
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
|
||||
let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
|
||||
let readQwenCliCredentialsCached: typeof import("./cli-credentials.js").readQwenCliCredentialsCached;
|
||||
let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest;
|
||||
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
|
||||
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
|
||||
|
|
@ -53,11 +54,28 @@ function createJwtWithExp(expSeconds: number): string {
|
|||
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
|
||||
}
|
||||
|
||||
function writePortalCliCredentialFile(
|
||||
filePath: string,
|
||||
options: { access: string; refresh: string; expires: number },
|
||||
) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
access_token: options.access,
|
||||
refresh_token: options.refresh,
|
||||
expiry_date: options.expires,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("cli credentials", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readQwenCliCredentialsCached,
|
||||
resetCliCredentialCachesForTest,
|
||||
writeClaudeCliKeychainCredentials,
|
||||
writeClaudeCliCredentials,
|
||||
|
|
@ -354,4 +372,50 @@ describe("cli credentials", () => {
|
|||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("invalidates cached Qwen credentials when oauth_creds.json changes within the TTL window", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qwen-cache-"));
|
||||
const credPath = path.join(tempHome, ".qwen", "oauth_creds.json");
|
||||
try {
|
||||
writePortalCliCredentialFile(credPath, {
|
||||
access: "stale-access",
|
||||
refresh: "stale-refresh",
|
||||
expires: 1_000,
|
||||
});
|
||||
fs.utimesSync(credPath, new Date("2026-03-24T10:00:00Z"), new Date("2026-03-24T10:00:00Z"));
|
||||
vi.setSystemTime(new Date("2026-03-24T10:00:00Z"));
|
||||
|
||||
const first = readQwenCliCredentialsCached({
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
homeDir: tempHome,
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
access: "stale-access",
|
||||
refresh: "stale-refresh",
|
||||
expires: 1_000,
|
||||
});
|
||||
|
||||
writePortalCliCredentialFile(credPath, {
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: 2_000,
|
||||
});
|
||||
fs.utimesSync(credPath, new Date("2026-03-24T10:05:00Z"), new Date("2026-03-24T10:05:00Z"));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
const second = readQwenCliCredentialsCached({
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
homeDir: tempHome,
|
||||
});
|
||||
|
||||
expect(second).toMatchObject({
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: 2_000,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ type CachedValue<T> = {
|
|||
value: T | null;
|
||||
readAt: number;
|
||||
cacheKey: string;
|
||||
sourceMtimeMs?: number | null;
|
||||
sourceFingerprint?: number | string | null;
|
||||
};
|
||||
|
||||
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
||||
|
|
@ -157,6 +157,45 @@ function readFileMtimeMs(filePath: string): number | null {
|
|||
}
|
||||
}
|
||||
|
||||
function readCachedCliCredential<T>(options: {
|
||||
ttlMs: number;
|
||||
cache: CachedValue<T> | null;
|
||||
cacheKey: string;
|
||||
read: () => T | null;
|
||||
setCache: (next: CachedValue<T> | null) => void;
|
||||
readSourceFingerprint?: () => number | string | null;
|
||||
}): T | null {
|
||||
const { ttlMs, cache, cacheKey, read, setCache, readSourceFingerprint } = options;
|
||||
if (ttlMs <= 0) {
|
||||
return read();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sourceFingerprint = readSourceFingerprint?.();
|
||||
if (
|
||||
cache &&
|
||||
cache.cacheKey === cacheKey &&
|
||||
cache.sourceFingerprint === sourceFingerprint &&
|
||||
now - cache.readAt < ttlMs
|
||||
) {
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
const value = read();
|
||||
const cachedSourceFingerprint = readSourceFingerprint?.();
|
||||
if (!readSourceFingerprint || cachedSourceFingerprint === sourceFingerprint) {
|
||||
setCache({
|
||||
value,
|
||||
readAt: now,
|
||||
cacheKey,
|
||||
sourceFingerprint: cachedSourceFingerprint,
|
||||
});
|
||||
} else {
|
||||
setCache(null);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function computeCodexKeychainAccount(codexHome: string) {
|
||||
const hash = createHash("sha256").update(codexHome).digest("hex");
|
||||
return `cli|${hash.slice(0, 16)}`;
|
||||
|
|
@ -334,27 +373,21 @@ export function readClaudeCliCredentialsCached(options?: {
|
|||
homeDir?: string;
|
||||
execSync?: ExecSyncFn;
|
||||
}): ClaudeCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
const cacheKey = resolveClaudeCliCredentialsPath(options?.homeDir);
|
||||
if (
|
||||
ttlMs > 0 &&
|
||||
claudeCliCache &&
|
||||
claudeCliCache.cacheKey === cacheKey &&
|
||||
now - claudeCliCache.readAt < ttlMs
|
||||
) {
|
||||
return claudeCliCache.value;
|
||||
}
|
||||
const value = readClaudeCliCredentials({
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
platform: options?.platform,
|
||||
homeDir: options?.homeDir,
|
||||
execSync: options?.execSync,
|
||||
return readCachedCliCredential({
|
||||
ttlMs: options?.ttlMs ?? 0,
|
||||
cache: claudeCliCache,
|
||||
cacheKey: resolveClaudeCliCredentialsPath(options?.homeDir),
|
||||
read: () =>
|
||||
readClaudeCliCredentials({
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
platform: options?.platform,
|
||||
homeDir: options?.homeDir,
|
||||
execSync: options?.execSync,
|
||||
}),
|
||||
setCache: (next) => {
|
||||
claudeCliCache = next;
|
||||
},
|
||||
});
|
||||
if (ttlMs > 0) {
|
||||
claudeCliCache = { value, readAt: now, cacheKey };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function writeClaudeCliKeychainCredentials(
|
||||
|
|
@ -533,78 +566,53 @@ export function readCodexCliCredentialsCached(options?: {
|
|||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
const authPath = resolveCodexCliAuthPath();
|
||||
const cacheKey = `${options?.platform ?? process.platform}|${authPath}`;
|
||||
const sourceMtimeMs = readFileMtimeMs(authPath);
|
||||
if (
|
||||
ttlMs > 0 &&
|
||||
codexCliCache &&
|
||||
codexCliCache.cacheKey === cacheKey &&
|
||||
codexCliCache.sourceMtimeMs === sourceMtimeMs &&
|
||||
now - codexCliCache.readAt < ttlMs
|
||||
) {
|
||||
return codexCliCache.value;
|
||||
}
|
||||
const value = readCodexCliCredentials({
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
return readCachedCliCredential({
|
||||
ttlMs: options?.ttlMs ?? 0,
|
||||
cache: codexCliCache,
|
||||
cacheKey: `${options?.platform ?? process.platform}|${authPath}`,
|
||||
read: () =>
|
||||
readCodexCliCredentials({
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
}),
|
||||
setCache: (next) => {
|
||||
codexCliCache = next;
|
||||
},
|
||||
readSourceFingerprint: () => readFileMtimeMs(authPath),
|
||||
});
|
||||
const cachedSourceMtimeMs = readFileMtimeMs(authPath);
|
||||
if (ttlMs > 0 && cachedSourceMtimeMs === sourceMtimeMs) {
|
||||
codexCliCache = {
|
||||
value,
|
||||
readAt: now,
|
||||
cacheKey,
|
||||
sourceMtimeMs: cachedSourceMtimeMs,
|
||||
};
|
||||
} else if (ttlMs > 0) {
|
||||
codexCliCache = null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readQwenCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
homeDir?: string;
|
||||
}): QwenCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
const cacheKey = resolveQwenCliCredentialsPath(options?.homeDir);
|
||||
if (
|
||||
ttlMs > 0 &&
|
||||
qwenCliCache &&
|
||||
qwenCliCache.cacheKey === cacheKey &&
|
||||
now - qwenCliCache.readAt < ttlMs
|
||||
) {
|
||||
return qwenCliCache.value;
|
||||
}
|
||||
const value = readQwenCliCredentials({ homeDir: options?.homeDir });
|
||||
if (ttlMs > 0) {
|
||||
qwenCliCache = { value, readAt: now, cacheKey };
|
||||
}
|
||||
return value;
|
||||
const credPath = resolveQwenCliCredentialsPath(options?.homeDir);
|
||||
return readCachedCliCredential({
|
||||
ttlMs: options?.ttlMs ?? 0,
|
||||
cache: qwenCliCache,
|
||||
cacheKey: credPath,
|
||||
read: () => readQwenCliCredentials({ homeDir: options?.homeDir }),
|
||||
setCache: (next) => {
|
||||
qwenCliCache = next;
|
||||
},
|
||||
readSourceFingerprint: () => readFileMtimeMs(credPath),
|
||||
});
|
||||
}
|
||||
|
||||
export function readMiniMaxCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
homeDir?: string;
|
||||
}): MiniMaxCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
const cacheKey = resolveMiniMaxCliCredentialsPath(options?.homeDir);
|
||||
if (
|
||||
ttlMs > 0 &&
|
||||
minimaxCliCache &&
|
||||
minimaxCliCache.cacheKey === cacheKey &&
|
||||
now - minimaxCliCache.readAt < ttlMs
|
||||
) {
|
||||
return minimaxCliCache.value;
|
||||
}
|
||||
const value = readMiniMaxCliCredentials({ homeDir: options?.homeDir });
|
||||
if (ttlMs > 0) {
|
||||
minimaxCliCache = { value, readAt: now, cacheKey };
|
||||
}
|
||||
return value;
|
||||
const credPath = resolveMiniMaxCliCredentialsPath(options?.homeDir);
|
||||
return readCachedCliCredential({
|
||||
ttlMs: options?.ttlMs ?? 0,
|
||||
cache: minimaxCliCache,
|
||||
cacheKey: credPath,
|
||||
read: () => readMiniMaxCliCredentials({ homeDir: options?.homeDir }),
|
||||
setCache: (next) => {
|
||||
minimaxCliCache = next;
|
||||
},
|
||||
readSourceFingerprint: () => readFileMtimeMs(credPath),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue