diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 141d47f..96c7e64 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -17,6 +17,19 @@ import { sessionFetchJson, sessionFetchText, } from './adapters/common' +import { + buildDoubaoSessionCredentialEvidence, + DOUBAO_AUTH_PROBE_URL, + doubaoHasBoundAccountPageSignal, + probeDoubaoAccountSession, + readDoubaoAuthPageState, +} from './adapters/doubao/auth-rules' +import { + detectKimiAccountFromSession, + KIMI_AUTH_PROBE_URL, + probeKimiAccountSession, + readKimiAuthPageState, +} from './adapters/kimi/auth-rules' import type { AccountHealthProfile, AuthProbeResult } from './auth-types' import { attachWindowDiagnostics, @@ -24,8 +37,9 @@ import { loadWindowURLSafely, } from './external-window' import { - buildGenericAIPageFingerprintFallback, - genericAIPageLooksAuthenticated, + genericAIHasVisibleAccountSignal, + genericAIPlatformRequiresVisibleCredentialSignal, + isLikelyPlatformAICredentialCookie, type GenericAIPageState, } from './generic-ai-auth' import { @@ -948,6 +962,38 @@ function sanitizeDetectedAccount(account: DetectedAccount): DetectedAccount { } } +function normalizeGenericAIPageStateSnapshot(state: unknown): GenericAIPageState | null { + if (!state || typeof state !== 'object') { + return null + } + + const typed = state as Partial + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeRemoteUrl(typed.avatarUrl), + fingerprint: normalizeText(typed.fingerprint), + credentialFingerprint: normalizeText(typed.credentialFingerprint), + currentPath: normalizeText(typed.currentPath), + strongAuthSignalCount: + typeof typed.strongAuthSignalCount === 'number' && + Number.isFinite(typed.strongAuthSignalCount) + ? typed.strongAuthSignalCount + : 0, + loginSignalCount: + typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount) + ? typed.loginSignalCount + : 0, + loggedOutSignalCount: + typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount) + ? typed.loggedOutSignalCount + : 0, + challengeSignalCount: + typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount) + ? typed.challengeSignalCount + : 0, + } +} + async function readGenericAIPageState( webContents: WebContents | null | undefined, ): Promise { @@ -1214,35 +1260,135 @@ async function readGenericAIPageState( ) .catch(() => null) - if (!state || typeof state !== 'object') { + return normalizeGenericAIPageStateSnapshot(state) +} + +async function readQwenPageState( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { return null } - const typed = state as Partial - return { - displayName: normalizeText(typed.displayName), - avatarUrl: normalizeRemoteUrl(typed.avatarUrl), - fingerprint: normalizeText(typed.fingerprint), - credentialFingerprint: normalizeText(typed.credentialFingerprint), - currentPath: normalizeText(typed.currentPath), - strongAuthSignalCount: - typeof typed.strongAuthSignalCount === 'number' && - Number.isFinite(typed.strongAuthSignalCount) - ? typed.strongAuthSignalCount - : 0, - loginSignalCount: - typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount) - ? typed.loginSignalCount - : 0, - loggedOutSignalCount: - typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount) - ? typed.loggedOutSignalCount - : 0, - challengeSignalCount: - typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount) - ? typed.challengeSignalCount - : 0, - } + const state = await webContents + .executeJavaScript( + `(() => { + try { + const normalize = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim().replace(/\\s+/g, " "); + return trimmed || null; + }; + const isVisible = (node) => { + if (!(node instanceof Element)) return false; + const style = window.getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isInsideAuthSurface = (node) => { + let current = node instanceof Element ? node : null; + for (let depth = 0; current && depth < 8; depth += 1) { + const descriptor = [ + current.getAttribute('role') || '', + current.getAttribute('aria-modal') || '', + current.getAttribute('data-testid') || '', + current.id || '', + current.className || '', + ].join(' '); + if (/(dialog|modal|popup|popover|login|signin|auth|scan|qrcode)/i.test(descriptor)) { + return true; + } + current = current.parentElement; + } + return false; + }; + const bodyText = normalize(document.body?.innerText || "") || ""; + const loginSignalCount = /(登录|注册|扫码登录|手机号登录|请先登录|立即登录)/.test(bodyText) + ? 1 + : 0; + const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录)/.test(bodyText) + ? 1 + : 0; + const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText) + ? 1 + : 0; + const stopWords = /^(千问|通义千问|Qwen|Qwen3\\.5-千问|新建对话|我的空间|智能体|新分组|最近对话|更多|设置|登录|注册)$/i; + const pickSidebarAccount = () => { + const lowerBandTop = window.innerHeight * 0.72; + const candidates = []; + for (const node of Array.from(document.querySelectorAll('aside *, nav *, [class*="side"] *, [class*="Sidebar"] *, body *'))) { + if (!(node instanceof HTMLElement) || !isVisible(node) || isInsideAuthSurface(node)) { + continue; + } + const rect = node.getBoundingClientRect(); + if (rect.top < lowerBandTop || rect.left > Math.max(360, window.innerWidth * 0.42)) { + continue; + } + const text = normalize(node.textContent || ""); + if (!text || text.length < 2 || text.length > 32 || stopWords.test(text)) { + continue; + } + if (!/(qwen|千问|通义|^[\\u4e00-\\u9fa5A-Za-z][\\u4e00-\\u9fa5A-Za-z0-9_-]{1,31}$)/i.test(text)) { + continue; + } + candidates.push({ text, top: rect.top, left: rect.left }); + } + candidates.sort((left, right) => right.top - left.top || left.left - right.left); + return candidates[0]?.text || null; + }; + const pickAvatar = () => { + const lowerBandTop = window.innerHeight * 0.72; + for (const node of Array.from(document.images)) { + if (!isVisible(node) || isInsideAuthSurface(node)) continue; + const rect = node.getBoundingClientRect(); + if (rect.top < lowerBandTop || rect.left > Math.max(360, window.innerWidth * 0.42)) { + continue; + } + const src = normalize(node.currentSrc || node.src || ""); + if (src && !/(logo|icon|favicon)/i.test(src)) { + return src; + } + } + return null; + }; + const displayName = pickSidebarAccount(); + const avatarUrl = pickAvatar(); + const credentialFingerprintParts = []; + try { + const state = window.__qianwenChatAPI?.sharedValues?.chatAPI?.state; + const userInfo = state?.userInfo || state?.user || state?.account || null; + if (userInfo && typeof userInfo === 'object') { + credentialFingerprintParts.push('qwen-state:' + JSON.stringify(userInfo).slice(0, 240)); + } + } catch {} + const strongSignals = []; + if (displayName) strongSignals.push('displayName'); + if (avatarUrl) strongSignals.push('avatar'); + return { + displayName, + avatarUrl, + fingerprint: null, + credentialFingerprint: credentialFingerprintParts.length + ? credentialFingerprintParts.join('||') + : null, + currentPath: normalize(window.location.pathname || ''), + strongAuthSignalCount: strongSignals.length, + loginSignalCount, + loggedOutSignalCount, + challengeSignalCount, + }; + } catch { + return null; + } + })();`, + true, + ) + .catch(() => null) + + return normalizeGenericAIPageStateSnapshot(state) } function isAIPlatformBinding(platformId: string): boolean { @@ -1254,27 +1400,6 @@ function normalizeURLPath(pathname: string): string { return normalized === '/' ? '' : normalized } -function isLikelyGenericAICredentialName(name: string): boolean { - const normalized = name.trim().toLowerCase() - if (/^(x[-_]?csrf[-_]?token|xsrf[-_]?token|csrf[-_]?token|csrf|_csrf)$/i.test(normalized)) { - return false - } - return /(token|session|auth|login|passport|ticket|jwt|refresh|access)/i.test(name) -} - -function isLikelyGenericAICredentialValue(value: string | null | undefined): boolean { - const normalized = normalizeText(value) - if (!normalized) { - return false - } - - if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) { - return false - } - - return normalized.length >= 8 -} - async function buildGenericAISessionFingerprint( platformId: string, session: Session, @@ -1283,6 +1408,12 @@ async function buildGenericAISessionFingerprint( const parts: string[] = [] if (pageState?.credentialFingerprint) { + if ( + genericAIPlatformRequiresVisibleCredentialSignal(platformId) && + !genericAIHasVisibleAccountSignal(pageState) + ) { + return null + } parts.push(pageState.credentialFingerprint) } @@ -1293,9 +1424,11 @@ async function buildGenericAISessionFingerprint( try { const cookies = await session.cookies.get({ url }) const authCookies = cookies.filter((cookie) => { - return ( - isLikelyGenericAICredentialName(cookie.name) && - isLikelyGenericAICredentialValue(cookie.value) + return isLikelyPlatformAICredentialCookie( + platformId, + cookie.name, + cookie.value, + pageState, ) }) if (authCookies.length > 0) { @@ -1312,13 +1445,13 @@ async function buildGenericAISessionFingerprint( } if (parts.length === 0) { - return buildGenericAIPageFingerprintFallback(platformId, pageState) + return null } return boundedIdentity(`${platformId}-session`, parts.join('||')) } -async function detectGenericAIPlatform( +async function detectCredentialBackedAIPlatform( platformId: string, label: string, context: DetectContext, @@ -1342,7 +1475,10 @@ async function detectGenericAIPlatform( if ((pageState?.challengeSignalCount ?? 0) > 0) { return null } - if (!genericAIPageLooksAuthenticated(currentURL, pageState)) { + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + return null + } + if ((pageState?.loginSignalCount ?? 0) > 0 && !genericAIHasVisibleAccountSignal(pageState)) { return null } const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState) @@ -1357,8 +1493,33 @@ async function detectGenericAIPlatform( }) } +async function detectDeepSeekPlatform( + platformId: string, + label: string, + context: DetectContext, +): Promise { + return await detectCredentialBackedAIPlatform(platformId, label, context) +} + +async function detectYuanbaoPlatform( + platformId: string, + label: string, + context: DetectContext, +): Promise { + return await detectCredentialBackedAIPlatform(platformId, label, context) +} + +async function detectWenxinPlatform( + platformId: string, + label: string, + context: DetectContext, +): Promise { + return await detectCredentialBackedAIPlatform(platformId, label, context) +} + async function detectQwenFromSession( session: Session, + pageState: GenericAIPageState | null, hints: { displayName?: string | null avatarUrl?: string | null @@ -1382,6 +1543,9 @@ async function detectQwenFromSession( if (!platformUid) { return null } + if (!genericAIHasVisibleAccountSignal(pageState)) { + return null + } return sanitizeDetectedAccount({ platformUid, @@ -1392,28 +1556,14 @@ async function detectQwenFromSession( async function detectKimiFromSession( session: Session, + pageState: GenericAIPageState | null, hints: { displayName?: string | null avatarUrl?: string | null } = {}, ): Promise { - const cookies = await session.cookies.get({ url: 'https://www.kimi.com/' }).catch(() => []) - if (!cookies.length) { - return null - } - - const authCookie = cookies.find( - (cookie) => cookie.name === 'kimi-auth' && isLikelyGenericAICredentialValue(cookie.value), - ) - if (!authCookie) { - return null - } - - return sanitizeDetectedAccount({ - platformUid: boundedIdentity('kimi-auth', authCookie.value), - displayName: hints.displayName ?? 'Kimi 会话', - avatarUrl: hints.avatarUrl ?? null, - }) + const detected = await detectKimiAccountFromSession(session, pageState, hints) + return detected ? sanitizeDetectedAccount(detected) : null } async function detectQwenPlatform( @@ -1421,11 +1571,6 @@ async function detectQwenPlatform( label: string, context: DetectContext, ): Promise { - const genericDetected = await detectGenericAIPlatform(platformId, label, context) - if (genericDetected) { - return genericDetected - } - if (!context.webContents || context.webContents.isDestroyed()) { return null } @@ -1440,7 +1585,7 @@ async function detectQwenPlatform( return null } - const pageState = await readGenericAIPageState(context.webContents).catch(() => null) + const pageState = await readQwenPageState(context.webContents).catch(() => null) if ((pageState?.challengeSignalCount ?? 0) > 0) { return null } @@ -1448,7 +1593,7 @@ async function detectQwenPlatform( return null } - const fromSession = await detectQwenFromSession(context.session, { + const fromSession = await detectQwenFromSession(context.session, pageState, { displayName: pageState?.displayName ?? `${label} 会话`, avatarUrl: pageState?.avatarUrl ?? null, }) @@ -1456,6 +1601,10 @@ async function detectQwenPlatform( return fromSession } + if (!genericAIHasVisibleAccountSignal(pageState)) { + return null + } + const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState) if (!fingerprint) { return null @@ -1463,7 +1612,57 @@ async function detectQwenPlatform( return sanitizeDetectedAccount({ platformUid: fingerprint, - displayName: pageState?.displayName ?? `${label} 会话`, + displayName: pageState?.displayName ?? `${label} 账号`, + avatarUrl: pageState?.avatarUrl ?? null, + }) +} + +async function detectDoubaoPlatform( + platformId: string, + label: string, + context: DetectContext, +): Promise { + if (!context.webContents || context.webContents.isDestroyed()) { + return null + } + + const currentURL = context.webContents.getURL() + const platformMeta = aiPlatformCatalog.find((item) => item.id === platformId) ?? null + if (!platformMeta) { + return null + } + + if (currentURL && looksLikeLoginRedirect(currentURL, platformMeta.loginUrl)) { + return null + } + + const pageState = await readDoubaoAuthPageState(context.webContents).catch(() => null) + if ((pageState?.challengeSignalCount ?? 0) > 0) { + return null + } + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + return null + } + + // Doubao is intentionally platform-specific. It allows anonymous chat, but + // our desktop binding requires the real sidebar account menu plus login cookies. + if (!doubaoHasBoundAccountPageSignal(pageState)) { + return null + } + + const urls = new Set([platformMeta.loginUrl, platformMeta.consoleUrl]) + const credentialEvidence = await buildDoubaoSessionCredentialEvidence( + context.session, + urls, + pageState, + ) + if (!credentialEvidence) { + return null + } + + return sanitizeDetectedAccount({ + platformUid: boundedIdentity('doubao-session', credentialEvidence), + displayName: pageState?.displayName ?? `${label} 账号`, avatarUrl: pageState?.avatarUrl ?? null, }) } @@ -1473,11 +1672,6 @@ async function detectKimiPlatform( label: string, context: DetectContext, ): Promise { - const genericDetected = await detectGenericAIPlatform(platformId, label, context) - if (genericDetected) { - return genericDetected - } - if (!context.webContents || context.webContents.isDestroyed()) { return null } @@ -1492,7 +1686,7 @@ async function detectKimiPlatform( return null } - const pageState = await readGenericAIPageState(context.webContents).catch(() => null) + const pageState = await readKimiAuthPageState(context.webContents).catch(() => null) if ((pageState?.challengeSignalCount ?? 0) > 0) { return null } @@ -1500,7 +1694,7 @@ async function detectKimiPlatform( return null } - const fromSession = await detectKimiFromSession(context.session, { + const fromSession = await detectKimiFromSession(context.session, pageState, { displayName: pageState?.displayName ?? `${label} 会话`, avatarUrl: pageState?.avatarUrl ?? null, }) @@ -1508,16 +1702,7 @@ async function detectKimiPlatform( return fromSession } - const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState) - if (!fingerprint) { - return null - } - - return sanitizeDetectedAccount({ - platformUid: fingerprint, - displayName: pageState?.displayName ?? `${label} 会话`, - avatarUrl: pageState?.avatarUrl ?? null, - }) + return null } async function detectAIPlatformAccount( @@ -1529,11 +1714,27 @@ async function detectAIPlatformAccount( return await detectQwenPlatform(platformId, label, context) } + if (platformId === 'doubao') { + return await detectDoubaoPlatform(platformId, label, context) + } + if (platformId === 'kimi') { return await detectKimiPlatform(platformId, label, context) } - return await detectGenericAIPlatform(platformId, label, context) + if (platformId === 'deepseek') { + return await detectDeepSeekPlatform(platformId, label, context) + } + + if (platformId === 'yuanbao') { + return await detectYuanbaoPlatform(platformId, label, context) + } + + if (platformId === 'wenxin') { + return await detectWenxinPlatform(platformId, label, context) + } + + return null } async function detectToutiao({ @@ -2093,10 +2294,252 @@ async function resolvePublishAccountProfileFromHandle( return toPublishAccountProfile(detected) } +export async function probeAIAccountSession( + account: PublishAccountIdentity, + partition?: string, +): Promise { + const handle = partition + ? createSessionHandleForPartition(account.id, partition) + : await findLocalPublishAccountSessionHandle(account) + + if (!handle) { + return { + verdict: 'expired', + reason: 'missing_partition', + profile: null, + sessionFingerprint: null, + } + } + + const platformMeta = aiPlatformCatalog.find((item) => item.id === account.platform) ?? null + if (!platformMeta) { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + evidence: { + platform: account.platform, + code: 'desktop_ai_platform_not_supported', + }, + } + } + + const aiProbeUrl = + account.platform === 'doubao' + ? DOUBAO_AUTH_PROBE_URL + : account.platform === 'kimi' + ? KIMI_AUTH_PROBE_URL + : platformMeta.consoleUrl + + const window = createBoundWindow( + `${platformMeta.label} AI 静默探针`, + aiProbeUrl, + handle.session, + { show: false }, + ) + + let mainFrameLoadError: + | { + code: number + description: string + url: string + } + | undefined + window.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (isMainFrame && errorCode !== -3) { + mainFrameLoadError = { + code: errorCode, + description: errorDescription, + url: validatedURL, + } + } + }, + ) + + try { + await waitForWindowNavigationSettled(window) + if (window.isDestroyed()) { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + } + } + + const loadError = mainFrameLoadError + if (loadError) { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + evidence: { + errorCode: loadError.code, + errorDescription: loadError.description, + url: loadError.url, + }, + } + } + + const currentURL = window.webContents.getURL() + if (!currentURL || currentURL.startsWith('data:text/html')) { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + }, + } + } + + if (account.platform === 'doubao') { + return await probeDoubaoAccountSession({ + session: handle.session, + webContents: window.webContents, + urls: new Set([platformMeta.loginUrl, platformMeta.consoleUrl]), + currentURL, + fallbackDisplayName: account.displayName, + expectedPlatformUid: account.platformUid, + }) + } + + if (account.platform === 'kimi') { + return await probeKimiAccountSession({ + session: handle.session, + webContents: window.webContents, + currentURL, + fallbackDisplayName: account.displayName, + expectedPlatformUid: account.platformUid, + }) + } + + if (definitionLooksLikeLoginRedirect(currentURL, { + id: platformMeta.id, + label: platformMeta.label, + loginUrl: platformMeta.loginUrl, + consoleUrl: platformMeta.consoleUrl, + detect: (context: DetectContext) => + detectAIPlatformAccount(platformMeta.id, platformMeta.label, context), + })) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + }, + } + } + + const pageState = await readGenericAIPageState(window.webContents) + if ((pageState?.challengeSignalCount ?? 0) > 0) { + return { + verdict: 'challenge_required', + reason: 'captcha_gate', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + challengeSignalCount: pageState?.challengeSignalCount ?? 0, + }, + } + } + + const detected = await detectAIPlatformAccount(account.platform, platformMeta.label, { + session: handle.session, + webContents: window.webContents, + }) + if (detected) { + return { + verdict: 'active', + reason: 'probe_success', + profile: toAccountHealthProfile(toPublishAccountProfile(detected)), + sessionFingerprint: detected.platformUid, + } + } + + if (!pageState) { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + }, + } + } + + if (pageState.loggedOutSignalCount > 0) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + loggedOutSignalCount: pageState.loggedOutSignalCount, + }, + } + } + + if ((pageState.loginSignalCount ?? 0) > 0 && (pageState.strongAuthSignalCount ?? 0) === 0) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + loginSignalCount: pageState.loginSignalCount, + }, + } + } + + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: currentURL, + strongAuthSignalCount: pageState.strongAuthSignalCount, + loginSignalCount: pageState.loginSignalCount, + loggedOutSignalCount: pageState.loggedOutSignalCount, + }, + } + } catch (error) { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + evidence: { + message: error instanceof Error ? error.message : String(error), + }, + } + } finally { + if (!window.isDestroyed()) { + window.destroy() + } + } +} + export async function probePublishAccountSession( account: PublishAccountIdentity, partition?: string, ): Promise { + if (isAIPlatformBinding(account.platform)) { + return await probeAIAccountSession(account, partition) + } + const handle = partition ? createSessionHandleForPartition(account.id, partition) : await findLocalPublishAccountSessionHandle(account) @@ -2298,106 +2741,6 @@ export async function probePublishAccountSession( } } - if (isAIPlatformBinding(account.platform)) { - const pageState = await readGenericAIPageState(window.webContents) - if ((pageState?.challengeSignalCount ?? 0) > 0) { - return { - verdict: 'challenge_required', - reason: 'captcha_gate', - profile: null, - sessionFingerprint: null, - evidence: { - url: currentURL, - challengeSignalCount: pageState?.challengeSignalCount ?? 0, - }, - } - } - - const detected = await detectAIPlatformAccount(account.platform, definition.label, { - session: handle.session, - webContents: window.webContents, - }) - if (detected) { - return { - verdict: 'active', - reason: 'probe_success', - profile: toAccountHealthProfile(toPublishAccountProfile(detected)), - sessionFingerprint: detected.platformUid, - } - } - - if (!pageState) { - return { - verdict: 'network_error', - reason: 'network_error', - profile: null, - sessionFingerprint: null, - evidence: { - url: currentURL, - }, - } - } - - if ( - pageState.loggedOutSignalCount > 0 || - definitionLooksLikeLoginRedirect(currentURL, definition) - ) { - return { - verdict: 'expired', - reason: 'login_redirect', - profile: null, - sessionFingerprint: null, - evidence: { - url: currentURL, - loggedOutSignalCount: pageState.loggedOutSignalCount, - }, - } - } - - if ((pageState.loginSignalCount ?? 0) > 0 && (pageState.strongAuthSignalCount ?? 0) === 0) { - return { - verdict: 'expired', - reason: 'login_redirect', - profile: null, - sessionFingerprint: null, - evidence: { - url: currentURL, - loginSignalCount: pageState.loginSignalCount, - }, - } - } - - const fingerprint = await buildGenericAISessionFingerprint( - account.platform, - handle.session, - pageState, - ) - if (genericAIPageLooksAuthenticated(currentURL, pageState)) { - return { - verdict: 'active', - reason: 'probe_success', - profile: fallbackAccountHealthProfile(account, { - displayName: pageState.displayName ?? account.displayName, - avatarUrl: pageState.avatarUrl ?? null, - }), - sessionFingerprint: fingerprint, - } - } - - return { - verdict: 'network_error', - reason: 'network_error', - profile: null, - sessionFingerprint: fingerprint, - evidence: { - url: currentURL, - strongAuthSignalCount: pageState.strongAuthSignalCount, - loginSignalCount: pageState.loginSignalCount, - loggedOutSignalCount: pageState.loggedOutSignalCount, - }, - } - } - const detected = await definition.detect({ session: handle.session, webContents: window.webContents, @@ -2479,10 +2822,22 @@ export async function probePublishAccountSession( } } +export async function silentRefreshAIAccountSession( + account: PublishAccountIdentity, + partition?: string, +): Promise { + const result = await probeAIAccountSession(account, partition).catch(() => null) + return result?.verdict === 'active' +} + export async function silentRefreshPublishAccountSession( account: PublishAccountIdentity, partition?: string, ): Promise { + if (isAIPlatformBinding(account.platform)) { + return await silentRefreshAIAccountSession(account, partition) + } + const handle = partition ? createSessionHandleForPartition(account.id, partition) : await findLocalPublishAccountSessionHandle(account) @@ -4049,7 +4404,10 @@ export async function openPublishAccountConsole(account: PublishAccountIdentity) if (!handle) { throw new Error(`desktop_account_session_expired:${account.platform}`) } - const consoleURL = await resolvePublishAccountConsoleURL(definition, handle.session) + const consoleURL = + account.platform === 'doubao' + ? DOUBAO_AUTH_PROBE_URL + : await resolvePublishAccountConsoleURL(definition, handle.session) if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) { const detected = await definition .detect({ diff --git a/apps/desktop-client/src/main/adapters/doubao/auth-rules.test.ts b/apps/desktop-client/src/main/adapters/doubao/auth-rules.test.ts new file mode 100644 index 0000000..be2c0c4 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/doubao/auth-rules.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import type { GenericAIPageState } from '../../generic-ai-auth' +import { + doubaoHasBoundAccountPageSignal, + isDoubaoLoggedInCredentialCookie, + isDoubaoLoggedInCredentialCookieName, +} from './auth-rules' + +function pageState(overrides: Partial = {}): GenericAIPageState { + return { + displayName: null, + avatarUrl: null, + fingerprint: null, + credentialFingerprint: null, + currentPath: '/chat/', + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + ...overrides, + } +} + +describe('doubao auth rules', () => { + it('requires the Doubao sidebar account signal before accepting cookies', () => { + const anonymousState = pageState({ + displayName: null, + avatarUrl: null, + strongAuthSignalCount: 0, + }) + const boundState = pageState({ + displayName: 'Geek', + avatarUrl: 'https://p3-passport.byteacctimg.com/img/user-avatar/demo.image', + strongAuthSignalCount: 3, + }) + + expect(doubaoHasBoundAccountPageSignal(anonymousState)).toBe(false) + expect(doubaoHasBoundAccountPageSignal(boundState)).toBe(true) + expect( + isDoubaoLoggedInCredentialCookie( + { name: 'sessionid', value: '53c11756399eceb02df08e7486230006' }, + anonymousState, + ), + ).toBe(false) + expect( + isDoubaoLoggedInCredentialCookie( + { name: 'sessionid', value: '53c11756399eceb02df08e7486230006' }, + boundState, + ), + ).toBe(true) + }) + + it('keeps Doubao login cookie names explicit and rejects anonymous cookies', () => { + expect(isDoubaoLoggedInCredentialCookieName('sessionid')).toBe(true) + expect(isDoubaoLoggedInCredentialCookieName('sid_guard')).toBe(true) + expect(isDoubaoLoggedInCredentialCookieName('flow_cur_user_sec_id')).toBe(true) + expect(isDoubaoLoggedInCredentialCookieName('passport_csrf_token')).toBe(false) + expect(isDoubaoLoggedInCredentialCookieName('hook_slardar_session_id')).toBe(false) + expect(isDoubaoLoggedInCredentialCookieName('ttwid')).toBe(false) + expect(isDoubaoLoggedInCredentialCookieName('s_v_web_id')).toBe(false) + }) +}) diff --git a/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts b/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts new file mode 100644 index 0000000..7cac3c3 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/doubao/auth-rules.ts @@ -0,0 +1,407 @@ +import type { Cookie, Session, WebContents } from 'electron/main' + +import { normalizeRemoteUrl } from '../common' +import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types' +import type { GenericAIPageState } from '../../generic-ai-auth' + +// Doubao binding rules are deliberately isolated here. +// Doubao can chat anonymously, while Geo Rankly requires a real logged-in Doubao +// account before binding. Do not move these DOM selectors or cookie names into +// generic AI auth helpers, and do not reuse them for Qwen/Kimi/DeepSeek. +export const DOUBAO_AUTH_PROBE_URL = 'https://www.doubao.com/chat/' + +const DOUBAO_AUTH_COOKIE_NAMES = new Set([ + 'flow_cur_user_sec_id', + 'flow_multi_user_sec_info', + 'multi_sids', + 'n_mh', + 'passport_auth_token', + 'passport_auth_token_default', + 'session_tlb_tag', + 'sessionid', + 'sessionid_ss', + 'sid_guard', + 'sid_tt', + 'sid_ucp_sso_v1', + 'sid_ucp_v1', + 'sso_uid_tt', + 'sso_uid_tt_ss', + 'ssid_ucp_sso_v1', + 'ssid_ucp_v1', + 'uid_tt', + 'uid_tt_ss', + 'x-tt-multi-sids', +]) + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null +} + +function hasCredentialValue(value: string | null | undefined): boolean { + const normalized = normalizeText(value) + if (!normalized) { + return false + } + + if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) { + return false + } + + return normalized.length >= 8 +} + +function hashText(value: string): string { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 + } + return Math.abs(hash).toString(16).padStart(8, '0') +} + +function boundedIdentity(prefix: string, raw: string): string { + const normalizedPrefix = prefix.trim().replace(/:+$/, '') + const normalizedRaw = raw.trim() + if (!normalizedRaw) { + return `${normalizedPrefix}:${hashText(prefix)}` + } + + const candidate = `${normalizedPrefix}:${normalizedRaw}` + if (candidate.length <= 120) { + return candidate + } + + return `${normalizedPrefix}:${hashText(normalizedRaw)}` +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function normalizeDoubaoPageStateSnapshot(state: unknown): GenericAIPageState | null { + if (!state || typeof state !== 'object') { + return null + } + + const typed = state as Partial + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeRemoteUrl(typed.avatarUrl), + fingerprint: null, + credentialFingerprint: null, + currentPath: normalizeText(typed.currentPath), + strongAuthSignalCount: + typeof typed.strongAuthSignalCount === 'number' && + Number.isFinite(typed.strongAuthSignalCount) + ? typed.strongAuthSignalCount + : 0, + loginSignalCount: + typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount) + ? typed.loginSignalCount + : 0, + loggedOutSignalCount: + typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount) + ? typed.loggedOutSignalCount + : 0, + challengeSignalCount: + typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount) + ? typed.challengeSignalCount + : 0, + } +} + +export function doubaoHasBoundAccountPageSignal( + pageState: GenericAIPageState | null | undefined, +): boolean { + return Boolean( + pageState && + pageState.strongAuthSignalCount >= 2 && + (pageState.displayName || pageState.avatarUrl), + ) +} + +export function isDoubaoLoggedInCredentialCookieName(name: string): boolean { + return DOUBAO_AUTH_COOKIE_NAMES.has(name.trim().toLowerCase()) +} + +export function isDoubaoLoggedInCredentialCookie( + cookie: Pick, + pageState: GenericAIPageState | null | undefined, +): boolean { + return ( + doubaoHasBoundAccountPageSignal(pageState) && + isDoubaoLoggedInCredentialCookieName(cookie.name) && + hasCredentialValue(cookie.value) + ) +} + +export async function buildDoubaoSessionCredentialEvidence( + session: Session, + urls: Iterable, + pageState: GenericAIPageState | null | undefined, +): Promise { + if (!doubaoHasBoundAccountPageSignal(pageState)) { + return null + } + + const parts: string[] = [] + for (const url of urls) { + try { + const cookies = await session.cookies.get({ url }) + const authCookies = cookies.filter((cookie) => + isDoubaoLoggedInCredentialCookie(cookie, pageState), + ) + if (authCookies.length === 0) { + continue + } + + const serialized = authCookies + .map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`) + .sort() + .join(';') + parts.push(`${url}:${serialized}`) + } catch { + continue + } + } + + return parts.length > 0 ? parts.join('||') : null +} + +export async function readDoubaoAuthPageState( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return null + } + + const state = await webContents + .executeJavaScript( + `(() => { + try { + const normalize = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim().replace(/\\s+/g, " "); + return trimmed || null; + }; + const isVisible = (node) => { + if (!(node instanceof Element)) return false; + const style = window.getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isInsideAuthSurface = (node) => { + let current = node instanceof Element ? node : null; + for (let depth = 0; current && depth < 8; depth += 1) { + const descriptor = [ + current.getAttribute('role') || '', + current.getAttribute('aria-modal') || '', + current.getAttribute('data-testid') || '', + current.id || '', + current.className || '', + ].join(' '); + if (/(dialog|modal|popup|popover|login|signin|auth|scan|qrcode)/i.test(descriptor)) { + return true; + } + current = current.parentElement; + } + return false; + }; + const isDoubaoAvatarUrl = (src) => { + if (!src) return false; + return /passport\\.byteacctimg\\.com\\/img\\/user-avatar\\//i.test(src); + }; + const isAccountAvatarUrl = (src) => { + if (!src) return false; + if (isDoubaoAvatarUrl(src)) return true; + return !/(logo|icon|favicon|doubao_avatar|flow-doubao)/i.test(src); + }; + const accountTextStopWords = /^(豆包|Doubao|新对话|最近对话|历史对话|AI 创作|云盘|更多|设置|收藏夹|豆包官网|API 服务|下载电脑版|下载手机应用|退出登录|登录|注册)$/i; + const pickAccountText = (trigger) => { + const candidates = []; + for (const node of Array.from(trigger.querySelectorAll('div, span, p'))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) continue; + const text = normalize(node.textContent || ""); + if (!text || text.length > 32 || accountTextStopWords.test(text)) continue; + const rect = node.getBoundingClientRect(); + candidates.push({ text, width: rect.width, left: rect.left }); + } + candidates.sort((left, right) => right.width - left.width || left.left - right.left); + return candidates[0]?.text || null; + }; + const pickAccountTrigger = () => { + const sidebar = document.querySelector('#flow_chat_sidebar'); + if (!(sidebar instanceof HTMLElement) || !isVisible(sidebar)) return null; + const triggers = Array.from( + sidebar.querySelectorAll('button[data-slot="dropdown-menu-trigger"][aria-haspopup="menu"], button[aria-haspopup="menu"]'), + ); + const sidebarRect = sidebar.getBoundingClientRect(); + const lowerBandTop = sidebarRect.top + sidebarRect.height * 0.6; + for (const node of triggers) { + if (!(node instanceof HTMLElement) || !isVisible(node) || isInsideAuthSurface(node)) { + continue; + } + const rect = node.getBoundingClientRect(); + if (rect.top < lowerBandTop) { + continue; + } + const img = node.querySelector('img'); + const avatarUrl = img instanceof HTMLImageElement + ? normalize(img.currentSrc || img.src || "") + : null; + const displayName = pickAccountText(node); + if (displayName || isAccountAvatarUrl(avatarUrl)) { + return { trigger: node, displayName, avatarUrl }; + } + } + return null; + }; + const bodyText = normalize(document.body?.innerText || "") || ""; + const loginSignalCount = /(登录|注册|扫码登录|手机号登录|请先登录|立即登录|登录后)/.test(bodyText) + ? 1 + : 0; + const loggedOutSignalCount = /(未登录|登录后继续|请先登录|扫码登录|手机号登录)/.test(bodyText) + ? 1 + : 0; + const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification)/i.test(bodyText) + ? 1 + : 0; + const account = pickAccountTrigger(); + const displayName = account?.displayName || null; + const avatarUrl = isAccountAvatarUrl(account?.avatarUrl || null) + ? account.avatarUrl + : null; + const strongSignals = []; + if (account) strongSignals.push('doubaoSidebarAccountMenu'); + if (displayName) strongSignals.push('displayName'); + if (avatarUrl) strongSignals.push('avatar'); + if (isDoubaoAvatarUrl(avatarUrl)) strongSignals.push('doubaoPassportAvatar'); + return { + displayName, + avatarUrl, + fingerprint: null, + credentialFingerprint: null, + currentPath: normalize(window.location.pathname || ''), + strongAuthSignalCount: strongSignals.length, + loginSignalCount, + loggedOutSignalCount, + challengeSignalCount, + }; + } catch { + return null; + } + })();`, + true, + ) + .catch(() => null) + + return normalizeDoubaoPageStateSnapshot(state) +} + +export async function probeDoubaoAccountSession(input: { + session: Session + webContents: WebContents | null | undefined + urls: Iterable + currentURL: string + fallbackDisplayName: string + expectedPlatformUid?: string | null +}): Promise { + const startedAt = Date.now() + let pageState: GenericAIPageState | null = null + let credentialEvidence: string | null = null + + while (Date.now() - startedAt < 8_000) { + pageState = await readDoubaoAuthPageState(input.webContents).catch(() => null) + if ((pageState?.challengeSignalCount ?? 0) > 0) { + break + } + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + break + } + if (doubaoHasBoundAccountPageSignal(pageState)) { + credentialEvidence = await buildDoubaoSessionCredentialEvidence( + input.session, + input.urls, + pageState, + ) + if (credentialEvidence) { + break + } + } + await delay(250) + } + + if ((pageState?.challengeSignalCount ?? 0) > 0) { + return { + verdict: 'challenge_required', + reason: 'captcha_gate', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + challengeSignalCount: pageState?.challengeSignalCount ?? 0, + }, + } + } + + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + loggedOutSignalCount: pageState?.loggedOutSignalCount ?? 0, + }, + } + } + + if (!doubaoHasBoundAccountPageSignal(pageState)) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + strongAuthSignalCount: pageState?.strongAuthSignalCount ?? 0, + loginSignalCount: pageState?.loginSignalCount ?? 0, + code: 'doubao_account_menu_missing', + }, + } + } + + if (!credentialEvidence) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + code: 'doubao_login_cookie_missing', + }, + } + } + + const sessionFingerprint = boundedIdentity('doubao-session', credentialEvidence) + const profile: AccountHealthProfile = { + subjectUid: sessionFingerprint, + displayName: pageState?.displayName ?? input.fallbackDisplayName, + avatarUrl: pageState?.avatarUrl ?? null, + } + + return { + verdict: 'active', + reason: 'probe_success', + profile, + sessionFingerprint, + } +} diff --git a/apps/desktop-client/src/main/adapters/kimi/auth-rules.test.ts b/apps/desktop-client/src/main/adapters/kimi/auth-rules.test.ts new file mode 100644 index 0000000..c4d1deb --- /dev/null +++ b/apps/desktop-client/src/main/adapters/kimi/auth-rules.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import type { GenericAIPageState } from '../../generic-ai-auth' +import { + isKimiLoggedInCredentialCookie, + isKimiLoggedInCredentialCookieName, + kimiHasBoundAccountPageSignal, +} from './auth-rules' + +function pageState(overrides: Partial = {}): GenericAIPageState { + return { + displayName: null, + avatarUrl: null, + fingerprint: null, + credentialFingerprint: null, + currentPath: '/', + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + ...overrides, + } +} + +describe('kimi auth rules', () => { + it('rejects anonymous Kimi auth tokens without the signed-in account surface', () => { + const anonymousState = pageState({ + displayName: null, + avatarUrl: null, + credentialFingerprint: null, + strongAuthSignalCount: 0, + loginSignalCount: 1, + loggedOutSignalCount: 1, + }) + + expect(kimiHasBoundAccountPageSignal(anonymousState)).toBe(false) + expect( + isKimiLoggedInCredentialCookie( + { name: 'kimi-auth', value: 'anonymous-kimi-token-that-is-long-enough' }, + anonymousState, + ), + ).toBe(false) + }) + + it('requires Kimi user-info and logged-in storage before accepting kimi-auth', () => { + const boundState = pageState({ + displayName: '阿白', + avatarUrl: 'https://avatar.moonshot.cn/avatar/cotkv2iul7227tqh5fo0/1723644963.jpeg', + credentialFingerprint: 'msh_user_id=cotkv2iul7227tqh5fp0||access_token=jwt', + strongAuthSignalCount: 4, + }) + + expect(kimiHasBoundAccountPageSignal(boundState)).toBe(true) + expect( + isKimiLoggedInCredentialCookie( + { name: 'kimi-auth', value: 'signed-in-kimi-token-that-is-long-enough' }, + boundState, + ), + ).toBe(true) + }) + + it('keeps Kimi credential cookie names explicit', () => { + expect(isKimiLoggedInCredentialCookieName('kimi-auth')).toBe(true) + expect(isKimiLoggedInCredentialCookieName('anonymous_access_token')).toBe(false) + expect(isKimiLoggedInCredentialCookieName('anonymous_refresh_token')).toBe(false) + expect(isKimiLoggedInCredentialCookieName('__snaker__id')).toBe(false) + }) +}) diff --git a/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts b/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts new file mode 100644 index 0000000..255d0b1 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/kimi/auth-rules.ts @@ -0,0 +1,400 @@ +import type { Cookie, Session, WebContents } from 'electron/main' + +import { normalizeRemoteUrl } from '../common' +import type { AccountHealthProfile, AuthProbeResult } from '../../auth-types' +import type { GenericAIPageState } from '../../generic-ai-auth' + +// Kimi binding rules are deliberately isolated here. +// Kimi creates anonymous tokens/cookies before login (`kimi-auth`, +// `anonymous_access_token`, `anonymous_refresh_token`). Geo Rankly must require +// the real signed-in account surface, so do not move these selectors or storage +// keys into generic AI auth helpers. +export const KIMI_AUTH_PROBE_URL = 'https://www.kimi.com/' + +const KIMI_LOGGED_IN_COOKIE_NAMES = new Set(['kimi-auth']) + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null +} + +function hasCredentialValue(value: string | null | undefined): boolean { + const normalized = normalizeText(value) + if (!normalized) { + return false + } + + if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) { + return false + } + + return normalized.length >= 8 +} + +function hashText(value: string): string { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 + } + return Math.abs(hash).toString(16).padStart(8, '0') +} + +function boundedIdentity(prefix: string, raw: string): string { + const normalizedPrefix = prefix.trim().replace(/:+$/, '') + const normalizedRaw = raw.trim() + if (!normalizedRaw) { + return `${normalizedPrefix}:${hashText(prefix)}` + } + + const candidate = `${normalizedPrefix}:${normalizedRaw}` + if (candidate.length <= 120) { + return candidate + } + + return `${normalizedPrefix}:${hashText(normalizedRaw)}` +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function normalizeKimiPageStateSnapshot(state: unknown): GenericAIPageState | null { + if (!state || typeof state !== 'object') { + return null + } + + const typed = state as Partial + return { + displayName: normalizeText(typed.displayName), + avatarUrl: normalizeRemoteUrl(typed.avatarUrl), + fingerprint: normalizeText(typed.fingerprint), + credentialFingerprint: normalizeText(typed.credentialFingerprint), + currentPath: normalizeText(typed.currentPath), + strongAuthSignalCount: + typeof typed.strongAuthSignalCount === 'number' && + Number.isFinite(typed.strongAuthSignalCount) + ? typed.strongAuthSignalCount + : 0, + loginSignalCount: + typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount) + ? typed.loginSignalCount + : 0, + loggedOutSignalCount: + typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount) + ? typed.loggedOutSignalCount + : 0, + challengeSignalCount: + typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount) + ? typed.challengeSignalCount + : 0, + } +} + +export function kimiHasBoundAccountPageSignal( + pageState: GenericAIPageState | null | undefined, +): boolean { + return Boolean( + pageState && + pageState.strongAuthSignalCount >= 2 && + pageState.displayName && + pageState.credentialFingerprint, + ) +} + +export function isKimiLoggedInCredentialCookieName(name: string): boolean { + return KIMI_LOGGED_IN_COOKIE_NAMES.has(name.trim().toLowerCase()) +} + +export function isKimiLoggedInCredentialCookie( + cookie: Pick, + pageState: GenericAIPageState | null | undefined, +): boolean { + return ( + kimiHasBoundAccountPageSignal(pageState) && + isKimiLoggedInCredentialCookieName(cookie.name) && + hasCredentialValue(cookie.value) + ) +} + +export async function buildKimiSessionCredentialEvidence( + session: Session, + pageState: GenericAIPageState | null | undefined, +): Promise { + if (!kimiHasBoundAccountPageSignal(pageState)) { + return null + } + + const parts: string[] = [] + const credentialFingerprint = normalizeText(pageState?.credentialFingerprint) + if (credentialFingerprint) { + parts.push(`page:${credentialFingerprint}`) + } + + const cookies = await session.cookies.get({ url: KIMI_AUTH_PROBE_URL }).catch(() => []) + const authCookies = cookies.filter((cookie) => isKimiLoggedInCredentialCookie(cookie, pageState)) + if (authCookies.length > 0) { + const serialized = authCookies + .map((cookie) => `${cookie.domain}|${cookie.name}=${cookie.value}`) + .sort() + .join(';') + parts.push(`cookies:${serialized}`) + } + + return parts.length > 0 ? parts.join('||') : null +} + +export async function readKimiAuthPageState( + webContents: WebContents | null | undefined, +): Promise { + if (!webContents || webContents.isDestroyed()) { + return null + } + + const state = await webContents + .executeJavaScript( + `(() => { + try { + const normalize = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim().replace(/\\s+/g, " "); + return trimmed || null; + }; + const isVisible = (node) => { + if (!(node instanceof Element)) return false; + const style = window.getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = node.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isInsideAuthSurface = (node) => { + let current = node instanceof Element ? node : null; + for (let depth = 0; current && depth < 8; depth += 1) { + const descriptor = [ + current.getAttribute('role') || '', + current.getAttribute('aria-modal') || '', + current.getAttribute('data-testid') || '', + current.id || '', + current.className || '', + ].join(' '); + if (/(dialog|modal|popup|popover|login|signin|auth|scan|qrcode|phone|captcha)/i.test(descriptor)) { + return true; + } + current = current.parentElement; + } + return false; + }; + const bodyText = normalize(document.body?.innerText || "") || ""; + const loginSignalCount = /(登录|注册|扫码登录|手机号快捷登录|手机号登录|微信扫码登录|登录以同步历史会话|请先登录|立即登录)/.test(bodyText) + ? 1 + : 0; + const loggedOutSignalCount = /(登录以同步历史会话|微信扫码登录|手机号快捷登录|请输入手机号|请输入验证码|扫码登录|未登录|登录后继续|请先登录)/.test(bodyText) + ? 1 + : 0; + const challengeSignalCount = /(验证码|滑块|人机验证|安全验证|短信验证|captcha|verification|NECAPTCHA)/i.test(bodyText) + ? 1 + : 0; + const stopWords = /^(Kimi|Kimi Code|Kimi Claw|历史会话|新建会话|查看全部|设置|会员计划|关于我们|Language|用户反馈|获取应用程序|升级|升级套餐|登录|注册|虚拟用户)$/i; + const isKimiAvatarUrl = (src) => { + if (!src) return false; + return /avatar\\.moonshot\\.cn\\/avatar\\//i.test(src); + }; + const pickAccount = () => { + const triggers = Array.from( + document.querySelectorAll('.user-info, [class*="user-info"], [class*="UserInfo"]'), + ); + for (const node of triggers) { + if (!(node instanceof HTMLElement) || !isVisible(node) || isInsideAuthSurface(node)) { + continue; + } + const nameNode = node.querySelector('.user-name, [class*="user-name"], [class*="UserName"]'); + const rawName = normalize(nameNode?.textContent || node.textContent || ""); + const displayName = rawName + ? normalize(rawName.replace(/\\b升级\\b/g, '').replace(/升级/g, '')) + : null; + const image = node.querySelector('img.user-avatar, img[class*="user-avatar"], img'); + const avatarUrl = image instanceof HTMLImageElement + ? normalize(image.currentSrc || image.src || "") + : null; + if (!displayName || displayName.length > 48 || stopWords.test(displayName)) { + continue; + } + if (/^(登录|虚拟用户|登录以同步历史会话)$/i.test(displayName)) { + continue; + } + return { + displayName, + avatarUrl: avatarUrl && !/(logo|icon|favicon)/i.test(avatarUrl) ? avatarUrl : null, + kimiAvatar: isKimiAvatarUrl(avatarUrl), + }; + } + return null; + }; + const credentialParts = []; + try { + const mshUserId = normalize(window.localStorage.getItem('msh_user_id') || ""); + const accessToken = normalize(window.localStorage.getItem('access_token') || ""); + const refreshToken = normalize(window.localStorage.getItem('refresh_token') || ""); + if (mshUserId) credentialParts.push('msh_user_id=' + mshUserId); + if (accessToken) credentialParts.push('access_token=' + accessToken.slice(0, 240)); + if (refreshToken) credentialParts.push('refresh_token=' + refreshToken.slice(0, 240)); + } catch {} + const anonymousParts = []; + try { + if (window.localStorage.getItem('anonymous_access_token')) { + anonymousParts.push('anonymous_access_token'); + } + if (window.localStorage.getItem('anonymous_refresh_token')) { + anonymousParts.push('anonymous_refresh_token'); + } + } catch {} + const account = pickAccount(); + const strongSignals = []; + if (account) strongSignals.push('kimiUserInfo'); + if (account?.displayName) strongSignals.push('displayName'); + if (account?.avatarUrl) strongSignals.push('avatar'); + if (account?.kimiAvatar) strongSignals.push('moonshotAvatar'); + return { + displayName: account?.displayName || null, + avatarUrl: account?.avatarUrl || null, + fingerprint: null, + credentialFingerprint: credentialParts.length ? credentialParts.join('||') : null, + currentPath: normalize(window.location.pathname || ''), + strongAuthSignalCount: strongSignals.length, + loginSignalCount, + loggedOutSignalCount: loggedOutSignalCount && !account ? loggedOutSignalCount : 0, + challengeSignalCount, + anonymousSignalCount: anonymousParts.length, + }; + } catch { + return null; + } + })();`, + true, + ) + .catch(() => null) + + return normalizeKimiPageStateSnapshot(state) +} + +export async function detectKimiAccountFromSession( + session: Session, + pageState: GenericAIPageState | null, + hints: { + displayName?: string | null + avatarUrl?: string | null + } = {}, +): Promise<{ platformUid: string; displayName: string; avatarUrl: string | null } | null> { + const credentialEvidence = await buildKimiSessionCredentialEvidence(session, pageState) + if (!credentialEvidence) { + return null + } + + return { + platformUid: boundedIdentity('kimi-session', credentialEvidence), + displayName: hints.displayName ?? pageState?.displayName ?? 'Kimi 账号', + avatarUrl: hints.avatarUrl ?? pageState?.avatarUrl ?? null, + } +} + +export async function probeKimiAccountSession(input: { + session: Session + webContents: WebContents | null | undefined + currentURL: string + fallbackDisplayName: string + expectedPlatformUid?: string | null +}): Promise { + const startedAt = Date.now() + let pageState: GenericAIPageState | null = null + let credentialEvidence: string | null = null + + while (Date.now() - startedAt < 8_000) { + pageState = await readKimiAuthPageState(input.webContents).catch(() => null) + if ((pageState?.challengeSignalCount ?? 0) > 0) { + break + } + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + break + } + if (kimiHasBoundAccountPageSignal(pageState)) { + credentialEvidence = await buildKimiSessionCredentialEvidence(input.session, pageState) + if (credentialEvidence) { + break + } + } + await delay(250) + } + + if ((pageState?.challengeSignalCount ?? 0) > 0) { + return { + verdict: 'challenge_required', + reason: 'captcha_gate', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + challengeSignalCount: pageState?.challengeSignalCount ?? 0, + }, + } + } + + if ((pageState?.loggedOutSignalCount ?? 0) > 0) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + loggedOutSignalCount: pageState?.loggedOutSignalCount ?? 0, + }, + } + } + + if (!kimiHasBoundAccountPageSignal(pageState)) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + code: 'kimi_account_surface_missing', + strongAuthSignalCount: pageState?.strongAuthSignalCount ?? 0, + loginSignalCount: pageState?.loginSignalCount ?? 0, + loggedOutSignalCount: pageState?.loggedOutSignalCount ?? 0, + }, + } + } + + if (!credentialEvidence) { + return { + verdict: 'expired', + reason: 'login_redirect', + profile: null, + sessionFingerprint: null, + evidence: { + url: input.currentURL, + code: 'kimi_login_storage_missing', + }, + } + } + + const sessionFingerprint = boundedIdentity('kimi-session', credentialEvidence) + const profile: AccountHealthProfile = { + subjectUid: sessionFingerprint, + displayName: pageState?.displayName ?? input.fallbackDisplayName, + avatarUrl: pageState?.avatarUrl ?? null, + } + + return { + verdict: 'active', + reason: 'probe_success', + profile, + sessionFingerprint, + } +} diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index be7ff41..68b6e7b 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from '@geo/shared-types' +import { isAIPlatformId } from '@geo/shared-types' import { shell } from 'electron' import type { BrowserWindow as ElectronBrowserWindow, @@ -753,6 +754,9 @@ function registerBridgeHandlers(): void { account: { id: string; platform: string; platformUid: string; displayName: string }, ) => { await openPublishAccountConsole(account) + if (isAIPlatformId(account.platform)) { + return null + } void requestRuntimeAccountProbe(account.id, { account, force: true, diff --git a/apps/desktop-client/src/main/generic-ai-auth.test.ts b/apps/desktop-client/src/main/generic-ai-auth.test.ts index 6975045..6a132e3 100644 --- a/apps/desktop-client/src/main/generic-ai-auth.test.ts +++ b/apps/desktop-client/src/main/generic-ai-auth.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest' import { - buildGenericAIPageFingerprintFallback, genericAIPageLooksAuthenticated, + isLikelyGenericAICredentialName, + isLikelyPlatformAICredentialCookie, } from './generic-ai-auth' describe('generic ai auth detection', () => { @@ -22,12 +23,48 @@ describe('generic ai auth detection', () => { ).toBe(true) }) - it('does not use a non-auth fingerprint as a fallback without strong ui signals', () => { + it('does not treat anonymous conversation URLs as authenticated', () => { expect( - buildGenericAIPageFingerprintFallback('deepseek', { + genericAIPageLooksAuthenticated('https://www.doubao.com/chat/', { displayName: null, avatarUrl: null, - fingerprint: 'local:user_unique_id=123', + fingerprint: 'https://www.doubao.com/chat/|Doubao', + credentialFingerprint: null, + currentPath: '/chat/', + strongAuthSignalCount: 0, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + }), + ).toBe(false) + }) + + it('keeps anonymous telemetry cookies out of generic credential matching', () => { + expect(isLikelyGenericAICredentialName('hook_slardar_session_id')).toBe(false) + expect(isLikelyGenericAICredentialName('ttwid')).toBe(false) + expect(isLikelyGenericAICredentialName('XSRF-TOKEN')).toBe(false) + expect(isLikelyGenericAICredentialName('passport_auth_token')).toBe(true) + }) + + it('requires visible account signals for platform cookies that can exist anonymously', () => { + expect( + isLikelyPlatformAICredentialCookie('qwen', 'tongyi_sso_ticket', 'anonymous-qwen-cookie', { + displayName: '通义千问账号', + avatarUrl: null, + fingerprint: null, + credentialFingerprint: null, + currentPath: '/', + strongAuthSignalCount: 1, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + }), + ).toBe(true) + expect( + isLikelyPlatformAICredentialCookie('deepseek', 'userToken', 'real-token-value', { + displayName: null, + avatarUrl: null, + fingerprint: null, credentialFingerprint: null, currentPath: '/', strongAuthSignalCount: 0, @@ -35,22 +72,38 @@ describe('generic ai auth detection', () => { loggedOutSignalCount: 0, challengeSignalCount: 0, }), - ).toBeNull() + ).toBe(true) }) - it('builds a stable fingerprint from credential storage when cookies are absent', () => { + it('does not run Kimi through generic credential cookies', () => { expect( - buildGenericAIPageFingerprintFallback('deepseek', { - displayName: null, - avatarUrl: null, - fingerprint: 'local:user_unique_id=123', - credentialFingerprint: 'local:userToken=masked', + isLikelyPlatformAICredentialCookie('kimi', 'kimi-auth', 'anonymous-kimi-cookie', { + displayName: '阿白', + avatarUrl: 'https://avatar.moonshot.cn/avatar/demo.jpeg', + fingerprint: null, + credentialFingerprint: 'local:access_token=real-token', currentPath: '/', - strongAuthSignalCount: 0, + strongAuthSignalCount: 3, loginSignalCount: 0, loggedOutSignalCount: 0, challengeSignalCount: 0, }), - ).toBe('deepseek-session:local:userToken=masked') + ).toBe(false) + }) + + it('does not run Doubao through generic credential cookies', () => { + expect( + isLikelyPlatformAICredentialCookie('doubao', 'passport_auth_token', 'real-auth-token', { + displayName: '豆包用户', + avatarUrl: 'https://p3-passport.byteacctimg.com/img/user-avatar/demo.image', + fingerprint: null, + credentialFingerprint: null, + currentPath: '/chat/', + strongAuthSignalCount: 3, + loginSignalCount: 0, + loggedOutSignalCount: 0, + challengeSignalCount: 0, + }), + ).toBe(false) }) }) diff --git a/apps/desktop-client/src/main/generic-ai-auth.ts b/apps/desktop-client/src/main/generic-ai-auth.ts index 25a74a2..e93b8f4 100644 --- a/apps/desktop-client/src/main/generic-ai-auth.ts +++ b/apps/desktop-client/src/main/generic-ai-auth.ts @@ -6,29 +6,6 @@ function normalizeText(value: unknown): string | null { return trimmed ? trimmed : null } -function hashText(value: string): string { - let hash = 0 - for (let index = 0; index < value.length; index += 1) { - hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 - } - return Math.abs(hash).toString(16).padStart(8, '0') -} - -function boundedIdentity(prefix: string, raw: string): string { - const normalizedPrefix = prefix.trim().replace(/:+$/, '') - const normalizedRaw = raw.trim() - if (!normalizedRaw) { - return `${normalizedPrefix}:${hashText(prefix)}` - } - - const candidate = `${normalizedPrefix}:${normalizedRaw}` - if (candidate.length <= 120) { - return candidate - } - - return `${normalizedPrefix}:${hashText(normalizedRaw)}` -} - function normalizeURLPath(pathname: string): string { const normalized = pathname.replace(/\/+$/, '') return normalized === '/' ? '' : normalized @@ -58,6 +35,79 @@ export interface GenericAIPageState { challengeSignalCount: number } +export function isLikelyGenericAICredentialName(name: string): boolean { + const normalized = name.trim().toLowerCase() + if (!normalized) { + return false + } + + if (/(?:^|[-_.])(?:x[-_]?csrf|xsrf|csrf)(?:[-_]?token)?(?:$|[-_.])/.test(normalized)) { + return false + } + + if ( + /(?:^|[-_.])(?:slardar|analytics|monitor|telemetry|trace|tracing|log|logger|sentry|rum|perf|performance|abtest|experiment|visitor|guest|anonymous|device|webid|web_id|ttwid|hook|mstoken|ms_token|odin_tt|s_v_web_id|__ac_nonce|__ac_signature)(?:$|[-_.])/i.test( + normalized, + ) + ) { + return false + } + + return /(token|session|auth|login|passport|ticket|jwt|refresh|access|sso)/i.test(normalized) +} + +export function isLikelyGenericAICredentialValue(value: string | null | undefined): boolean { + const normalized = normalizeText(value) + if (!normalized) { + return false + } + + if (/^(true|false|null|undefined|0|1)$/i.test(normalized)) { + return false + } + + return normalized.length >= 8 +} + +export function genericAIHasVisibleAccountSignal(pageState: GenericAIPageState | null): boolean { + return ( + Boolean(pageState?.displayName || pageState?.avatarUrl) || + (pageState?.strongAuthSignalCount ?? 0) >= 2 + ) +} + +export function genericAIPlatformRequiresVisibleCredentialSignal(platformId: string): boolean { + const normalizedPlatformId = platformId.trim().toLowerCase() + return normalizedPlatformId === 'qwen' +} + +export function isLikelyPlatformAICredentialCookie( + platformId: string, + name: string, + value: string | null | undefined, + pageState?: GenericAIPageState | null, +): boolean { + const normalizedPlatformId = platformId.trim().toLowerCase() + const hasVisibleAccountSignal = genericAIHasVisibleAccountSignal(pageState ?? null) + + if (normalizedPlatformId === 'doubao' || normalizedPlatformId === 'kimi') { + return false + } + + if (!isLikelyGenericAICredentialName(name) || !isLikelyGenericAICredentialValue(value)) { + return false + } + + if ( + genericAIPlatformRequiresVisibleCredentialSignal(platformId) && + !hasVisibleAccountSignal + ) { + return false + } + + return true +} + export function genericAIPageLooksAuthenticated( currentURL: string, pageState: GenericAIPageState | null, @@ -92,27 +142,5 @@ export function genericAIPageLooksAuthenticated( return (strongAuthSignalCount >= 2 || hasCredentialFingerprint) && !onConversationPath } - return strongAuthSignalCount > 0 || onConversationPath || hasCredentialFingerprint -} - -export function buildGenericAIPageFingerprintFallback( - platformId: string, - pageState?: GenericAIPageState | null, -): string | null { - const credentialFingerprint = normalizeText(pageState?.credentialFingerprint) - if (credentialFingerprint) { - return boundedIdentity(`${platformId}-session`, credentialFingerprint) - } - - const fallbackFingerprint = normalizeText(pageState?.fingerprint) - if (!fallbackFingerprint) { - return null - } - - const strongAuthSignalCount = pageState?.strongAuthSignalCount ?? 0 - if (strongAuthSignalCount <= 0) { - return null - } - - return boundedIdentity(`${platformId}-page`, fallbackFingerprint) + return strongAuthSignalCount > 0 || hasCredentialFingerprint } diff --git a/apps/desktop-client/src/main/platform-auth-adapters.test.ts b/apps/desktop-client/src/main/platform-auth-adapters.test.ts index 95e8b75..3c8a378 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.test.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.test.ts @@ -1,8 +1,15 @@ import { beforeAll, describe, expect, it, vi } from 'vitest' +const probeAIAccountSession = vi.fn() +const probePublishAccountSession = vi.fn() +const silentRefreshAIAccountSession = vi.fn() +const silentRefreshPublishAccountSession = vi.fn() + vi.mock('./account-binder', () => ({ - probePublishAccountSession: vi.fn(), - silentRefreshPublishAccountSession: vi.fn(), + probeAIAccountSession, + probePublishAccountSession, + silentRefreshAIAccountSession, + silentRefreshPublishAccountSession, })) let getPlatformAdapter: typeof import('./platform-auth-adapters').getPlatformAdapter @@ -12,6 +19,47 @@ beforeAll(async () => { }) describe('platform auth adapters', () => { + it('routes AI account probes away from publish account probes', async () => { + probeAIAccountSession.mockResolvedValueOnce({ + verdict: 'active', + reason: 'probe_success', + profile: null, + sessionFingerprint: 'ai-session', + }) + const adapter = getPlatformAdapter('doubao') + + await adapter.probe( + { + id: 'account-1', + platform: 'doubao', + platformUid: 'doubao-session:demo', + displayName: 'Geek', + }, + 'persist:test', + ) + + expect(probeAIAccountSession).toHaveBeenCalledTimes(1) + expect(probePublishAccountSession).not.toHaveBeenCalled() + }) + + it('routes AI silent refresh away from publish account refresh', async () => { + silentRefreshAIAccountSession.mockResolvedValueOnce(true) + const adapter = getPlatformAdapter('qwen') + + await adapter.silentRefresh( + { + id: 'account-2', + platform: 'qwen', + platformUid: 'qwen-session:demo', + displayName: '通义千问账号', + }, + 'persist:test', + ) + + expect(silentRefreshAIAccountSession).toHaveBeenCalledTimes(1) + expect(silentRefreshPublishAccountSession).not.toHaveBeenCalled() + }) + it('classifies Wenxin environment abnormal messages as risk control', () => { const adapter = getPlatformAdapter('wenxin') diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index f2140b4..ab86b66 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -1,7 +1,9 @@ import { aiPlatformCatalog } from '@geo/shared-types' import { + probeAIAccountSession, probePublishAccountSession, + silentRefreshAIAccountSession, silentRefreshPublishAccountSession, type PublishAccountIdentity, } from './account-binder' @@ -227,6 +229,16 @@ function classifyDongchediFailure(input: PlatformFailureInput): PlatformFailureC return classifyFailure(input) } +const aiAdapter: PlatformAdapter = { + async probe(account, partition) { + return await probeAIAccountSession(account, partition) + }, + async silentRefresh(account, partition) { + return await silentRefreshAIAccountSession(account, partition) + }, + classifyFailure, +} + const genericAdapter: PlatformAdapter = { async probe(account, partition) { return await probePublishAccountSession(account, partition) @@ -239,20 +251,20 @@ const genericAdapter: PlatformAdapter = { const doubaoAdapter: PlatformAdapter = { async probe(account, partition) { - return await probePublishAccountSession(account, partition) + return await probeAIAccountSession(account, partition) }, async silentRefresh(account, partition) { - return await silentRefreshPublishAccountSession(account, partition) + return await silentRefreshAIAccountSession(account, partition) }, classifyFailure: classifyDoubaoFailure, } const wenxinAdapter: PlatformAdapter = { async probe(account, partition) { - return await probePublishAccountSession(account, partition) + return await probeAIAccountSession(account, partition) }, async silentRefresh(account, partition) { - return await silentRefreshPublishAccountSession(account, partition) + return await silentRefreshAIAccountSession(account, partition) }, classifyFailure: classifyWenxinFailure, } @@ -384,10 +396,12 @@ const adapterRegistry = new Map( : platform === 'weixin_gzh' ? weixinGzhAdapter : platform === 'zol' - ? zolAdapter - : platform === 'dongchedi' - ? dongchediAdapter - : genericAdapter, + ? zolAdapter + : platform === 'dongchedi' + ? dongchediAdapter + : aiPlatformCatalog.some((item) => item.id === platform) + ? aiAdapter + : genericAdapter, ]), ) diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index cecebf5..8ce6bf2 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -15,7 +15,7 @@ import type { MonitoringSourceItem, MonitoringTaskResultPayload, } from '@geo/shared-types' -import { getAIPlatformCatalogItem, isAIPlatformId } from '@geo/shared-types' +import { aiPlatformCatalog, getAIPlatformCatalogItem, isAIPlatformId } from '@geo/shared-types' import { buildAccountIdentityKey, @@ -150,7 +150,7 @@ const pullIntervalMs = 60_000 const leaseExtendIntervalMs = 60_000 const maxActivityItems = 60 const monitorBusinessTimeZone = 'Asia/Shanghai' -const authRequiredMonitorPlatforms = new Set(['yuanbao', 'kimi', 'deepseek', 'doubao', 'wenxin']) +const authRequiredMonitorPlatforms = new Set(aiPlatformCatalog.map((platform) => platform.id)) const publishReservedSlots = 1 const minimumForegroundTotalConcurrency = 2 const maximumRuntimeTotalConcurrency = 4