fix(desktop): isolate AI account auth probes
This commit is contained in:
@@ -17,6 +17,19 @@ import {
|
|||||||
sessionFetchJson,
|
sessionFetchJson,
|
||||||
sessionFetchText,
|
sessionFetchText,
|
||||||
} from './adapters/common'
|
} 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 type { AccountHealthProfile, AuthProbeResult } from './auth-types'
|
||||||
import {
|
import {
|
||||||
attachWindowDiagnostics,
|
attachWindowDiagnostics,
|
||||||
@@ -24,8 +37,9 @@ import {
|
|||||||
loadWindowURLSafely,
|
loadWindowURLSafely,
|
||||||
} from './external-window'
|
} from './external-window'
|
||||||
import {
|
import {
|
||||||
buildGenericAIPageFingerprintFallback,
|
genericAIHasVisibleAccountSignal,
|
||||||
genericAIPageLooksAuthenticated,
|
genericAIPlatformRequiresVisibleCredentialSignal,
|
||||||
|
isLikelyPlatformAICredentialCookie,
|
||||||
type GenericAIPageState,
|
type GenericAIPageState,
|
||||||
} from './generic-ai-auth'
|
} from './generic-ai-auth'
|
||||||
import {
|
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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readGenericAIPageState(
|
async function readGenericAIPageState(
|
||||||
webContents: WebContents | null | undefined,
|
webContents: WebContents | null | undefined,
|
||||||
): Promise<GenericAIPageState | null> {
|
): Promise<GenericAIPageState | null> {
|
||||||
@@ -1214,35 +1260,135 @@ async function readGenericAIPageState(
|
|||||||
)
|
)
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|
||||||
if (!state || typeof state !== 'object') {
|
return normalizeGenericAIPageStateSnapshot(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readQwenPageState(
|
||||||
|
webContents: WebContents | null | undefined,
|
||||||
|
): Promise<GenericAIPageState | null> {
|
||||||
|
if (!webContents || webContents.isDestroyed()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const typed = state as Partial<GenericAIPageState>
|
const state = await webContents
|
||||||
return {
|
.executeJavaScript(
|
||||||
displayName: normalizeText(typed.displayName),
|
`(() => {
|
||||||
avatarUrl: normalizeRemoteUrl(typed.avatarUrl),
|
try {
|
||||||
fingerprint: normalizeText(typed.fingerprint),
|
const normalize = (value) => {
|
||||||
credentialFingerprint: normalizeText(typed.credentialFingerprint),
|
if (typeof value !== "string") return null;
|
||||||
currentPath: normalizeText(typed.currentPath),
|
const trimmed = value.trim().replace(/\\s+/g, " ");
|
||||||
strongAuthSignalCount:
|
return trimmed || null;
|
||||||
typeof typed.strongAuthSignalCount === 'number' &&
|
};
|
||||||
Number.isFinite(typed.strongAuthSignalCount)
|
const isVisible = (node) => {
|
||||||
? typed.strongAuthSignalCount
|
if (!(node instanceof Element)) return false;
|
||||||
: 0,
|
const style = window.getComputedStyle(node);
|
||||||
loginSignalCount:
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
||||||
typeof typed.loginSignalCount === 'number' && Number.isFinite(typed.loginSignalCount)
|
return false;
|
||||||
? typed.loginSignalCount
|
}
|
||||||
: 0,
|
const rect = node.getBoundingClientRect();
|
||||||
loggedOutSignalCount:
|
return rect.width > 0 && rect.height > 0;
|
||||||
typeof typed.loggedOutSignalCount === 'number' && Number.isFinite(typed.loggedOutSignalCount)
|
};
|
||||||
? typed.loggedOutSignalCount
|
const isInsideAuthSurface = (node) => {
|
||||||
: 0,
|
let current = node instanceof Element ? node : null;
|
||||||
challengeSignalCount:
|
for (let depth = 0; current && depth < 8; depth += 1) {
|
||||||
typeof typed.challengeSignalCount === 'number' && Number.isFinite(typed.challengeSignalCount)
|
const descriptor = [
|
||||||
? typed.challengeSignalCount
|
current.getAttribute('role') || '',
|
||||||
: 0,
|
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 {
|
function isAIPlatformBinding(platformId: string): boolean {
|
||||||
@@ -1254,27 +1400,6 @@ function normalizeURLPath(pathname: string): string {
|
|||||||
return normalized === '/' ? '' : normalized
|
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(
|
async function buildGenericAISessionFingerprint(
|
||||||
platformId: string,
|
platformId: string,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -1283,6 +1408,12 @@ async function buildGenericAISessionFingerprint(
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (pageState?.credentialFingerprint) {
|
if (pageState?.credentialFingerprint) {
|
||||||
|
if (
|
||||||
|
genericAIPlatformRequiresVisibleCredentialSignal(platformId) &&
|
||||||
|
!genericAIHasVisibleAccountSignal(pageState)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
parts.push(pageState.credentialFingerprint)
|
parts.push(pageState.credentialFingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1293,9 +1424,11 @@ async function buildGenericAISessionFingerprint(
|
|||||||
try {
|
try {
|
||||||
const cookies = await session.cookies.get({ url })
|
const cookies = await session.cookies.get({ url })
|
||||||
const authCookies = cookies.filter((cookie) => {
|
const authCookies = cookies.filter((cookie) => {
|
||||||
return (
|
return isLikelyPlatformAICredentialCookie(
|
||||||
isLikelyGenericAICredentialName(cookie.name) &&
|
platformId,
|
||||||
isLikelyGenericAICredentialValue(cookie.value)
|
cookie.name,
|
||||||
|
cookie.value,
|
||||||
|
pageState,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
if (authCookies.length > 0) {
|
if (authCookies.length > 0) {
|
||||||
@@ -1312,13 +1445,13 @@ async function buildGenericAISessionFingerprint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return buildGenericAIPageFingerprintFallback(platformId, pageState)
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return boundedIdentity(`${platformId}-session`, parts.join('||'))
|
return boundedIdentity(`${platformId}-session`, parts.join('||'))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectGenericAIPlatform(
|
async function detectCredentialBackedAIPlatform(
|
||||||
platformId: string,
|
platformId: string,
|
||||||
label: string,
|
label: string,
|
||||||
context: DetectContext,
|
context: DetectContext,
|
||||||
@@ -1342,7 +1475,10 @@ async function detectGenericAIPlatform(
|
|||||||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (!genericAIPageLooksAuthenticated(currentURL, pageState)) {
|
if ((pageState?.loggedOutSignalCount ?? 0) > 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if ((pageState?.loginSignalCount ?? 0) > 0 && !genericAIHasVisibleAccountSignal(pageState)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState)
|
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<DetectedAccount | null> {
|
||||||
|
return await detectCredentialBackedAIPlatform(platformId, label, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectYuanbaoPlatform(
|
||||||
|
platformId: string,
|
||||||
|
label: string,
|
||||||
|
context: DetectContext,
|
||||||
|
): Promise<DetectedAccount | null> {
|
||||||
|
return await detectCredentialBackedAIPlatform(platformId, label, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectWenxinPlatform(
|
||||||
|
platformId: string,
|
||||||
|
label: string,
|
||||||
|
context: DetectContext,
|
||||||
|
): Promise<DetectedAccount | null> {
|
||||||
|
return await detectCredentialBackedAIPlatform(platformId, label, context)
|
||||||
|
}
|
||||||
|
|
||||||
async function detectQwenFromSession(
|
async function detectQwenFromSession(
|
||||||
session: Session,
|
session: Session,
|
||||||
|
pageState: GenericAIPageState | null,
|
||||||
hints: {
|
hints: {
|
||||||
displayName?: string | null
|
displayName?: string | null
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
@@ -1382,6 +1543,9 @@ async function detectQwenFromSession(
|
|||||||
if (!platformUid) {
|
if (!platformUid) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
if (!genericAIHasVisibleAccountSignal(pageState)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return sanitizeDetectedAccount({
|
return sanitizeDetectedAccount({
|
||||||
platformUid,
|
platformUid,
|
||||||
@@ -1392,28 +1556,14 @@ async function detectQwenFromSession(
|
|||||||
|
|
||||||
async function detectKimiFromSession(
|
async function detectKimiFromSession(
|
||||||
session: Session,
|
session: Session,
|
||||||
|
pageState: GenericAIPageState | null,
|
||||||
hints: {
|
hints: {
|
||||||
displayName?: string | null
|
displayName?: string | null
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<DetectedAccount | null> {
|
): Promise<DetectedAccount | null> {
|
||||||
const cookies = await session.cookies.get({ url: 'https://www.kimi.com/' }).catch(() => [])
|
const detected = await detectKimiAccountFromSession(session, pageState, hints)
|
||||||
if (!cookies.length) {
|
return detected ? sanitizeDetectedAccount(detected) : null
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectQwenPlatform(
|
async function detectQwenPlatform(
|
||||||
@@ -1421,11 +1571,6 @@ async function detectQwenPlatform(
|
|||||||
label: string,
|
label: string,
|
||||||
context: DetectContext,
|
context: DetectContext,
|
||||||
): Promise<DetectedAccount | null> {
|
): Promise<DetectedAccount | null> {
|
||||||
const genericDetected = await detectGenericAIPlatform(platformId, label, context)
|
|
||||||
if (genericDetected) {
|
|
||||||
return genericDetected
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.webContents || context.webContents.isDestroyed()) {
|
if (!context.webContents || context.webContents.isDestroyed()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1440,7 +1585,7 @@ async function detectQwenPlatform(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageState = await readGenericAIPageState(context.webContents).catch(() => null)
|
const pageState = await readQwenPageState(context.webContents).catch(() => null)
|
||||||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1448,7 +1593,7 @@ async function detectQwenPlatform(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromSession = await detectQwenFromSession(context.session, {
|
const fromSession = await detectQwenFromSession(context.session, pageState, {
|
||||||
displayName: pageState?.displayName ?? `${label} 会话`,
|
displayName: pageState?.displayName ?? `${label} 会话`,
|
||||||
avatarUrl: pageState?.avatarUrl ?? null,
|
avatarUrl: pageState?.avatarUrl ?? null,
|
||||||
})
|
})
|
||||||
@@ -1456,6 +1601,10 @@ async function detectQwenPlatform(
|
|||||||
return fromSession
|
return fromSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!genericAIHasVisibleAccountSignal(pageState)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState)
|
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState)
|
||||||
if (!fingerprint) {
|
if (!fingerprint) {
|
||||||
return null
|
return null
|
||||||
@@ -1463,7 +1612,57 @@ async function detectQwenPlatform(
|
|||||||
|
|
||||||
return sanitizeDetectedAccount({
|
return sanitizeDetectedAccount({
|
||||||
platformUid: fingerprint,
|
platformUid: fingerprint,
|
||||||
displayName: pageState?.displayName ?? `${label} 会话`,
|
displayName: pageState?.displayName ?? `${label} 账号`,
|
||||||
|
avatarUrl: pageState?.avatarUrl ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectDoubaoPlatform(
|
||||||
|
platformId: string,
|
||||||
|
label: string,
|
||||||
|
context: DetectContext,
|
||||||
|
): Promise<DetectedAccount | null> {
|
||||||
|
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,
|
avatarUrl: pageState?.avatarUrl ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1473,11 +1672,6 @@ async function detectKimiPlatform(
|
|||||||
label: string,
|
label: string,
|
||||||
context: DetectContext,
|
context: DetectContext,
|
||||||
): Promise<DetectedAccount | null> {
|
): Promise<DetectedAccount | null> {
|
||||||
const genericDetected = await detectGenericAIPlatform(platformId, label, context)
|
|
||||||
if (genericDetected) {
|
|
||||||
return genericDetected
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.webContents || context.webContents.isDestroyed()) {
|
if (!context.webContents || context.webContents.isDestroyed()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1492,7 +1686,7 @@ async function detectKimiPlatform(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageState = await readGenericAIPageState(context.webContents).catch(() => null)
|
const pageState = await readKimiAuthPageState(context.webContents).catch(() => null)
|
||||||
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
if ((pageState?.challengeSignalCount ?? 0) > 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1500,7 +1694,7 @@ async function detectKimiPlatform(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromSession = await detectKimiFromSession(context.session, {
|
const fromSession = await detectKimiFromSession(context.session, pageState, {
|
||||||
displayName: pageState?.displayName ?? `${label} 会话`,
|
displayName: pageState?.displayName ?? `${label} 会话`,
|
||||||
avatarUrl: pageState?.avatarUrl ?? null,
|
avatarUrl: pageState?.avatarUrl ?? null,
|
||||||
})
|
})
|
||||||
@@ -1508,16 +1702,7 @@ async function detectKimiPlatform(
|
|||||||
return fromSession
|
return fromSession
|
||||||
}
|
}
|
||||||
|
|
||||||
const fingerprint = await buildGenericAISessionFingerprint(platformId, context.session, pageState)
|
return null
|
||||||
if (!fingerprint) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitizeDetectedAccount({
|
|
||||||
platformUid: fingerprint,
|
|
||||||
displayName: pageState?.displayName ?? `${label} 会话`,
|
|
||||||
avatarUrl: pageState?.avatarUrl ?? null,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectAIPlatformAccount(
|
async function detectAIPlatformAccount(
|
||||||
@@ -1529,11 +1714,27 @@ async function detectAIPlatformAccount(
|
|||||||
return await detectQwenPlatform(platformId, label, context)
|
return await detectQwenPlatform(platformId, label, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (platformId === 'doubao') {
|
||||||
|
return await detectDoubaoPlatform(platformId, label, context)
|
||||||
|
}
|
||||||
|
|
||||||
if (platformId === 'kimi') {
|
if (platformId === 'kimi') {
|
||||||
return await detectKimiPlatform(platformId, label, context)
|
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({
|
async function detectToutiao({
|
||||||
@@ -2093,10 +2294,252 @@ async function resolvePublishAccountProfileFromHandle(
|
|||||||
return toPublishAccountProfile(detected)
|
return toPublishAccountProfile(detected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function probeAIAccountSession(
|
||||||
|
account: PublishAccountIdentity,
|
||||||
|
partition?: string,
|
||||||
|
): Promise<AuthProbeResult> {
|
||||||
|
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(
|
export async function probePublishAccountSession(
|
||||||
account: PublishAccountIdentity,
|
account: PublishAccountIdentity,
|
||||||
partition?: string,
|
partition?: string,
|
||||||
): Promise<AuthProbeResult> {
|
): Promise<AuthProbeResult> {
|
||||||
|
if (isAIPlatformBinding(account.platform)) {
|
||||||
|
return await probeAIAccountSession(account, partition)
|
||||||
|
}
|
||||||
|
|
||||||
const handle = partition
|
const handle = partition
|
||||||
? createSessionHandleForPartition(account.id, partition)
|
? createSessionHandleForPartition(account.id, partition)
|
||||||
: await findLocalPublishAccountSessionHandle(account)
|
: 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({
|
const detected = await definition.detect({
|
||||||
session: handle.session,
|
session: handle.session,
|
||||||
webContents: window.webContents,
|
webContents: window.webContents,
|
||||||
@@ -2479,10 +2822,22 @@ export async function probePublishAccountSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function silentRefreshAIAccountSession(
|
||||||
|
account: PublishAccountIdentity,
|
||||||
|
partition?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await probeAIAccountSession(account, partition).catch(() => null)
|
||||||
|
return result?.verdict === 'active'
|
||||||
|
}
|
||||||
|
|
||||||
export async function silentRefreshPublishAccountSession(
|
export async function silentRefreshPublishAccountSession(
|
||||||
account: PublishAccountIdentity,
|
account: PublishAccountIdentity,
|
||||||
partition?: string,
|
partition?: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (isAIPlatformBinding(account.platform)) {
|
||||||
|
return await silentRefreshAIAccountSession(account, partition)
|
||||||
|
}
|
||||||
|
|
||||||
const handle = partition
|
const handle = partition
|
||||||
? createSessionHandleForPartition(account.id, partition)
|
? createSessionHandleForPartition(account.id, partition)
|
||||||
: await findLocalPublishAccountSessionHandle(account)
|
: await findLocalPublishAccountSessionHandle(account)
|
||||||
@@ -4049,7 +4404,10 @@ export async function openPublishAccountConsole(account: PublishAccountIdentity)
|
|||||||
if (!handle) {
|
if (!handle) {
|
||||||
throw new Error(`desktop_account_session_expired:${account.platform}`)
|
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)) {
|
if (platformRequiresDetectedAccountForConsoleAccess(account.platform)) {
|
||||||
const detected = await definition
|
const detected = await definition
|
||||||
.detect({
|
.detect({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from '@geo/shared-types'
|
import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from '@geo/shared-types'
|
||||||
|
import { isAIPlatformId } from '@geo/shared-types'
|
||||||
import { shell } from 'electron'
|
import { shell } from 'electron'
|
||||||
import type {
|
import type {
|
||||||
BrowserWindow as ElectronBrowserWindow,
|
BrowserWindow as ElectronBrowserWindow,
|
||||||
@@ -753,6 +754,9 @@ function registerBridgeHandlers(): void {
|
|||||||
account: { id: string; platform: string; platformUid: string; displayName: string },
|
account: { id: string; platform: string; platformUid: string; displayName: string },
|
||||||
) => {
|
) => {
|
||||||
await openPublishAccountConsole(account)
|
await openPublishAccountConsole(account)
|
||||||
|
if (isAIPlatformId(account.platform)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
void requestRuntimeAccountProbe(account.id, {
|
void requestRuntimeAccountProbe(account.id, {
|
||||||
account,
|
account,
|
||||||
force: true,
|
force: true,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildGenericAIPageFingerprintFallback,
|
|
||||||
genericAIPageLooksAuthenticated,
|
genericAIPageLooksAuthenticated,
|
||||||
|
isLikelyGenericAICredentialName,
|
||||||
|
isLikelyPlatformAICredentialCookie,
|
||||||
} from './generic-ai-auth'
|
} from './generic-ai-auth'
|
||||||
|
|
||||||
describe('generic ai auth detection', () => {
|
describe('generic ai auth detection', () => {
|
||||||
@@ -22,12 +23,48 @@ describe('generic ai auth detection', () => {
|
|||||||
).toBe(true)
|
).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(
|
expect(
|
||||||
buildGenericAIPageFingerprintFallback('deepseek', {
|
genericAIPageLooksAuthenticated('https://www.doubao.com/chat/', {
|
||||||
displayName: null,
|
displayName: null,
|
||||||
avatarUrl: 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,
|
credentialFingerprint: null,
|
||||||
currentPath: '/',
|
currentPath: '/',
|
||||||
strongAuthSignalCount: 0,
|
strongAuthSignalCount: 0,
|
||||||
@@ -35,22 +72,38 @@ describe('generic ai auth detection', () => {
|
|||||||
loggedOutSignalCount: 0,
|
loggedOutSignalCount: 0,
|
||||||
challengeSignalCount: 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(
|
expect(
|
||||||
buildGenericAIPageFingerprintFallback('deepseek', {
|
isLikelyPlatformAICredentialCookie('kimi', 'kimi-auth', 'anonymous-kimi-cookie', {
|
||||||
displayName: null,
|
displayName: '阿白',
|
||||||
avatarUrl: null,
|
avatarUrl: 'https://avatar.moonshot.cn/avatar/demo.jpeg',
|
||||||
fingerprint: 'local:user_unique_id=123',
|
fingerprint: null,
|
||||||
credentialFingerprint: 'local:userToken=masked',
|
credentialFingerprint: 'local:access_token=real-token',
|
||||||
currentPath: '/',
|
currentPath: '/',
|
||||||
strongAuthSignalCount: 0,
|
strongAuthSignalCount: 3,
|
||||||
loginSignalCount: 0,
|
loginSignalCount: 0,
|
||||||
loggedOutSignalCount: 0,
|
loggedOutSignalCount: 0,
|
||||||
challengeSignalCount: 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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,29 +6,6 @@ function normalizeText(value: unknown): string | null {
|
|||||||
return trimmed ? trimmed : 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 {
|
function normalizeURLPath(pathname: string): string {
|
||||||
const normalized = pathname.replace(/\/+$/, '')
|
const normalized = pathname.replace(/\/+$/, '')
|
||||||
return normalized === '/' ? '' : normalized
|
return normalized === '/' ? '' : normalized
|
||||||
@@ -58,6 +35,79 @@ export interface GenericAIPageState {
|
|||||||
challengeSignalCount: number
|
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(
|
export function genericAIPageLooksAuthenticated(
|
||||||
currentURL: string,
|
currentURL: string,
|
||||||
pageState: GenericAIPageState | null,
|
pageState: GenericAIPageState | null,
|
||||||
@@ -92,27 +142,5 @@ export function genericAIPageLooksAuthenticated(
|
|||||||
return (strongAuthSignalCount >= 2 || hasCredentialFingerprint) && !onConversationPath
|
return (strongAuthSignalCount >= 2 || hasCredentialFingerprint) && !onConversationPath
|
||||||
}
|
}
|
||||||
|
|
||||||
return strongAuthSignalCount > 0 || onConversationPath || hasCredentialFingerprint
|
return strongAuthSignalCount > 0 || 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
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', () => ({
|
vi.mock('./account-binder', () => ({
|
||||||
probePublishAccountSession: vi.fn(),
|
probeAIAccountSession,
|
||||||
silentRefreshPublishAccountSession: vi.fn(),
|
probePublishAccountSession,
|
||||||
|
silentRefreshAIAccountSession,
|
||||||
|
silentRefreshPublishAccountSession,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let getPlatformAdapter: typeof import('./platform-auth-adapters').getPlatformAdapter
|
let getPlatformAdapter: typeof import('./platform-auth-adapters').getPlatformAdapter
|
||||||
@@ -12,6 +19,47 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('platform auth adapters', () => {
|
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', () => {
|
it('classifies Wenxin environment abnormal messages as risk control', () => {
|
||||||
const adapter = getPlatformAdapter('wenxin')
|
const adapter = getPlatformAdapter('wenxin')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { aiPlatformCatalog } from '@geo/shared-types'
|
import { aiPlatformCatalog } from '@geo/shared-types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
probeAIAccountSession,
|
||||||
probePublishAccountSession,
|
probePublishAccountSession,
|
||||||
|
silentRefreshAIAccountSession,
|
||||||
silentRefreshPublishAccountSession,
|
silentRefreshPublishAccountSession,
|
||||||
type PublishAccountIdentity,
|
type PublishAccountIdentity,
|
||||||
} from './account-binder'
|
} from './account-binder'
|
||||||
@@ -227,6 +229,16 @@ function classifyDongchediFailure(input: PlatformFailureInput): PlatformFailureC
|
|||||||
return classifyFailure(input)
|
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 = {
|
const genericAdapter: PlatformAdapter = {
|
||||||
async probe(account, partition) {
|
async probe(account, partition) {
|
||||||
return await probePublishAccountSession(account, partition)
|
return await probePublishAccountSession(account, partition)
|
||||||
@@ -239,20 +251,20 @@ const genericAdapter: PlatformAdapter = {
|
|||||||
|
|
||||||
const doubaoAdapter: PlatformAdapter = {
|
const doubaoAdapter: PlatformAdapter = {
|
||||||
async probe(account, partition) {
|
async probe(account, partition) {
|
||||||
return await probePublishAccountSession(account, partition)
|
return await probeAIAccountSession(account, partition)
|
||||||
},
|
},
|
||||||
async silentRefresh(account, partition) {
|
async silentRefresh(account, partition) {
|
||||||
return await silentRefreshPublishAccountSession(account, partition)
|
return await silentRefreshAIAccountSession(account, partition)
|
||||||
},
|
},
|
||||||
classifyFailure: classifyDoubaoFailure,
|
classifyFailure: classifyDoubaoFailure,
|
||||||
}
|
}
|
||||||
|
|
||||||
const wenxinAdapter: PlatformAdapter = {
|
const wenxinAdapter: PlatformAdapter = {
|
||||||
async probe(account, partition) {
|
async probe(account, partition) {
|
||||||
return await probePublishAccountSession(account, partition)
|
return await probeAIAccountSession(account, partition)
|
||||||
},
|
},
|
||||||
async silentRefresh(account, partition) {
|
async silentRefresh(account, partition) {
|
||||||
return await silentRefreshPublishAccountSession(account, partition)
|
return await silentRefreshAIAccountSession(account, partition)
|
||||||
},
|
},
|
||||||
classifyFailure: classifyWenxinFailure,
|
classifyFailure: classifyWenxinFailure,
|
||||||
}
|
}
|
||||||
@@ -384,10 +396,12 @@ const adapterRegistry = new Map<string, PlatformAdapter>(
|
|||||||
: platform === 'weixin_gzh'
|
: platform === 'weixin_gzh'
|
||||||
? weixinGzhAdapter
|
? weixinGzhAdapter
|
||||||
: platform === 'zol'
|
: platform === 'zol'
|
||||||
? zolAdapter
|
? zolAdapter
|
||||||
: platform === 'dongchedi'
|
: platform === 'dongchedi'
|
||||||
? dongchediAdapter
|
? dongchediAdapter
|
||||||
: genericAdapter,
|
: aiPlatformCatalog.some((item) => item.id === platform)
|
||||||
|
? aiAdapter
|
||||||
|
: genericAdapter,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
MonitoringSourceItem,
|
MonitoringSourceItem,
|
||||||
MonitoringTaskResultPayload,
|
MonitoringTaskResultPayload,
|
||||||
} from '@geo/shared-types'
|
} from '@geo/shared-types'
|
||||||
import { getAIPlatformCatalogItem, isAIPlatformId } from '@geo/shared-types'
|
import { aiPlatformCatalog, getAIPlatformCatalogItem, isAIPlatformId } from '@geo/shared-types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAccountIdentityKey,
|
buildAccountIdentityKey,
|
||||||
@@ -150,7 +150,7 @@ const pullIntervalMs = 60_000
|
|||||||
const leaseExtendIntervalMs = 60_000
|
const leaseExtendIntervalMs = 60_000
|
||||||
const maxActivityItems = 60
|
const maxActivityItems = 60
|
||||||
const monitorBusinessTimeZone = 'Asia/Shanghai'
|
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 publishReservedSlots = 1
|
||||||
const minimumForegroundTotalConcurrency = 2
|
const minimumForegroundTotalConcurrency = 2
|
||||||
const maximumRuntimeTotalConcurrency = 4
|
const maximumRuntimeTotalConcurrency = 4
|
||||||
|
|||||||
Reference in New Issue
Block a user