fix(desktop): isolate AI account auth probes

This commit is contained in:
2026-05-06 18:20:10 +08:00
parent a99d7a4971
commit f3487bcacf
11 changed files with 1713 additions and 270 deletions
@@ -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> = {}): 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)
})
})
@@ -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<void> {
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<GenericAIPageState>
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<Cookie, 'name' | 'value'>,
pageState: GenericAIPageState | null | undefined,
): boolean {
return (
doubaoHasBoundAccountPageSignal(pageState) &&
isDoubaoLoggedInCredentialCookieName(cookie.name) &&
hasCredentialValue(cookie.value)
)
}
export async function buildDoubaoSessionCredentialEvidence(
session: Session,
urls: Iterable<string>,
pageState: GenericAIPageState | null | undefined,
): Promise<string | null> {
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<GenericAIPageState | null> {
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<string>
currentURL: string
fallbackDisplayName: string
expectedPlatformUid?: string | null
}): Promise<AuthProbeResult> {
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,
}
}
@@ -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> = {}): 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)
})
})
@@ -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<void> {
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<GenericAIPageState>
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<Cookie, 'name' | 'value'>,
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<string | null> {
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<GenericAIPageState | null> {
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<AuthProbeResult> {
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,
}
}