249 lines
8.6 KiB
TypeScript
249 lines
8.6 KiB
TypeScript
import {
|
|
readClaudeCliCredentialsCached,
|
|
readCodexCliCredentialsCached,
|
|
readQwenCliCredentialsCached,
|
|
} from "../cli-credentials.js";
|
|
import {
|
|
CLAUDE_CLI_PROFILE_ID,
|
|
CODEX_CLI_PROFILE_ID,
|
|
EXTERNAL_CLI_NEAR_EXPIRY_MS,
|
|
EXTERNAL_CLI_SYNC_TTL_MS,
|
|
QWEN_CLI_PROFILE_ID,
|
|
log,
|
|
} from "./constants.js";
|
|
import type {
|
|
AuthProfileCredential,
|
|
AuthProfileStore,
|
|
OAuthCredential,
|
|
TokenCredential,
|
|
} from "./types.js";
|
|
|
|
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
|
|
if (!a) return false;
|
|
if (a.type !== "oauth") return false;
|
|
return (
|
|
a.provider === b.provider &&
|
|
a.access === b.access &&
|
|
a.refresh === b.refresh &&
|
|
a.expires === b.expires &&
|
|
a.email === b.email &&
|
|
a.enterpriseUrl === b.enterpriseUrl &&
|
|
a.projectId === b.projectId &&
|
|
a.accountId === b.accountId
|
|
);
|
|
}
|
|
|
|
function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean {
|
|
if (!a) return false;
|
|
if (a.type !== "token") return false;
|
|
return (
|
|
a.provider === b.provider &&
|
|
a.token === b.token &&
|
|
a.expires === b.expires &&
|
|
a.email === b.email
|
|
);
|
|
}
|
|
|
|
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
|
|
if (!cred) return false;
|
|
if (cred.type !== "oauth" && cred.type !== "token") return false;
|
|
if (
|
|
cred.provider !== "anthropic" &&
|
|
cred.provider !== "openai-codex" &&
|
|
cred.provider !== "qwen-portal"
|
|
) {
|
|
return false;
|
|
}
|
|
if (typeof cred.expires !== "number") return true;
|
|
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
|
|
}
|
|
|
|
/**
|
|
* Find any existing openai-codex profile (other than codex-cli) that has the same
|
|
* access and refresh tokens. This prevents creating a duplicate codex-cli profile
|
|
* when the user has already set up a custom profile with the same credentials.
|
|
*/
|
|
export function findDuplicateCodexProfile(
|
|
store: AuthProfileStore,
|
|
creds: OAuthCredential,
|
|
): string | undefined {
|
|
for (const [profileId, profile] of Object.entries(store.profiles)) {
|
|
if (profileId === CODEX_CLI_PROFILE_ID) continue;
|
|
if (profile.type !== "oauth") continue;
|
|
if (profile.provider !== "openai-codex") continue;
|
|
if (profile.access === creds.access && profile.refresh === creds.refresh) {
|
|
return profileId;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
|
|
* This allows clawdbot to use the same credentials as these tools without requiring
|
|
* separate authentication, and keeps credentials in sync when CLI tools refresh tokens.
|
|
*
|
|
* Returns true if any credentials were updated.
|
|
*/
|
|
export function syncExternalCliCredentials(
|
|
store: AuthProfileStore,
|
|
options?: { allowKeychainPrompt?: boolean },
|
|
): boolean {
|
|
let mutated = false;
|
|
const now = Date.now();
|
|
|
|
// Sync from Claude Code CLI (supports both OAuth and Token credentials)
|
|
const existingClaude = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
|
const shouldSyncClaude =
|
|
!existingClaude ||
|
|
existingClaude.provider !== "anthropic" ||
|
|
existingClaude.type === "token" ||
|
|
!isExternalProfileFresh(existingClaude, now);
|
|
const claudeCreds = shouldSyncClaude
|
|
? readClaudeCliCredentialsCached({
|
|
allowKeychainPrompt: options?.allowKeychainPrompt,
|
|
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
|
|
})
|
|
: null;
|
|
if (claudeCreds) {
|
|
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
|
const claudeCredsExpires = claudeCreds.expires ?? 0;
|
|
|
|
// Determine if we should update based on credential comparison
|
|
let shouldUpdate = false;
|
|
let isEqual = false;
|
|
|
|
if (claudeCreds.type === "oauth") {
|
|
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
|
isEqual = shallowEqualOAuthCredentials(existingOAuth, claudeCreds);
|
|
// Update if: no existing profile, type changed to oauth, expired, or CLI has newer token
|
|
shouldUpdate =
|
|
!existingOAuth ||
|
|
existingOAuth.provider !== "anthropic" ||
|
|
existingOAuth.expires <= now ||
|
|
(claudeCredsExpires > now && claudeCredsExpires > existingOAuth.expires);
|
|
} else {
|
|
const existingToken = existing?.type === "token" ? existing : undefined;
|
|
isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds);
|
|
// Update if: no existing profile, expired, or CLI has newer token
|
|
shouldUpdate =
|
|
!existingToken ||
|
|
existingToken.provider !== "anthropic" ||
|
|
(existingToken.expires ?? 0) <= now ||
|
|
(claudeCredsExpires > now && claudeCredsExpires > (existingToken.expires ?? 0));
|
|
}
|
|
|
|
// Also update if credential type changed (token -> oauth upgrade)
|
|
if (existing && existing.type !== claudeCreds.type) {
|
|
// Prefer oauth over token (enables auto-refresh)
|
|
if (claudeCreds.type === "oauth") {
|
|
shouldUpdate = true;
|
|
isEqual = false;
|
|
}
|
|
}
|
|
|
|
// Avoid downgrading from oauth to token-only credentials.
|
|
if (existing?.type === "oauth" && claudeCreds.type === "token") {
|
|
shouldUpdate = false;
|
|
}
|
|
|
|
if (shouldUpdate && !isEqual) {
|
|
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
|
mutated = true;
|
|
log.info("synced anthropic credentials from claude cli", {
|
|
profileId: CLAUDE_CLI_PROFILE_ID,
|
|
type: claudeCreds.type,
|
|
expires:
|
|
typeof claudeCreds.expires === "number"
|
|
? new Date(claudeCreds.expires).toISOString()
|
|
: "unknown",
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sync from Codex CLI
|
|
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
|
|
const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined;
|
|
const duplicateExistingId = existingCodexOAuth
|
|
? findDuplicateCodexProfile(store, existingCodexOAuth)
|
|
: undefined;
|
|
if (duplicateExistingId) {
|
|
delete store.profiles[CODEX_CLI_PROFILE_ID];
|
|
mutated = true;
|
|
log.info("removed codex-cli profile: credentials already exist in another profile", {
|
|
existingProfileId: duplicateExistingId,
|
|
removedProfileId: CODEX_CLI_PROFILE_ID,
|
|
});
|
|
}
|
|
const shouldSyncCodex =
|
|
!existingCodex ||
|
|
existingCodex.provider !== "openai-codex" ||
|
|
!isExternalProfileFresh(existingCodex, now);
|
|
const codexCreds =
|
|
shouldSyncCodex || duplicateExistingId
|
|
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
|
|
: null;
|
|
if (codexCreds) {
|
|
const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds);
|
|
if (duplicateProfileId) {
|
|
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
|
|
delete store.profiles[CODEX_CLI_PROFILE_ID];
|
|
mutated = true;
|
|
log.info("removed codex-cli profile: credentials already exist in another profile", {
|
|
existingProfileId: duplicateProfileId,
|
|
removedProfileId: CODEX_CLI_PROFILE_ID,
|
|
});
|
|
}
|
|
} else {
|
|
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
|
|
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
|
|
|
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
|
|
const shouldUpdate =
|
|
!existingOAuth ||
|
|
existingOAuth.provider !== "openai-codex" ||
|
|
existingOAuth.expires <= now ||
|
|
codexCreds.expires > existingOAuth.expires;
|
|
|
|
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
|
|
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
|
|
mutated = true;
|
|
log.info("synced openai-codex credentials from codex cli", {
|
|
profileId: CODEX_CLI_PROFILE_ID,
|
|
expires: new Date(codexCreds.expires).toISOString(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sync from Qwen Code CLI
|
|
const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
|
|
const shouldSyncQwen =
|
|
!existingQwen ||
|
|
existingQwen.provider !== "qwen-portal" ||
|
|
!isExternalProfileFresh(existingQwen, now);
|
|
const qwenCreds = shouldSyncQwen
|
|
? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
|
|
: null;
|
|
if (qwenCreds) {
|
|
const existing = store.profiles[QWEN_CLI_PROFILE_ID];
|
|
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
|
const shouldUpdate =
|
|
!existingOAuth ||
|
|
existingOAuth.provider !== "qwen-portal" ||
|
|
existingOAuth.expires <= now ||
|
|
qwenCreds.expires > existingOAuth.expires;
|
|
|
|
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
|
|
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
|
|
mutated = true;
|
|
log.info("synced qwen credentials from qwen cli", {
|
|
profileId: QWEN_CLI_PROFILE_ID,
|
|
expires: new Date(qwenCreds.expires).toISOString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
return mutated;
|
|
}
|