fix(desktop): never load a sign-in wall in the hidden link-title renderer

The cookieless title partition answers a Google Workspace link with Googles

(cherry picked from commit 31e7dd19fafb13b8350bf3271d91036f243079b3)
This commit is contained in:
Finn763
2026-09-21 01:20:34 +08:00
committed by Teknium
parent 04845f5f3e
commit 1c81ad3485
3 changed files with 260 additions and 24 deletions

View File

@@ -0,0 +1,136 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { isAuthWallBody, needsRendererFallback, resolveLinkTitle } from './link-title-wall'
const SIGNIN_IDENTIFIER_BODY =
'<html><head><title>Google Drive: Sign-in</title></head><body><input id="identifierId"></body></html>'
// A published Doc answers the cookieless curl tier with its own <title>, so these
// links must stay fetchable — the wall is proven at fetch time, not from the host.
function tier1(payload: { authWall?: boolean; title?: string } = {}) {
return async () => ({ authWall: payload.authWall ?? false, title: payload.title ?? '' })
}
test('a proven sign-in wall never escalates to the hidden renderer', async () => {
let rendererCalls = 0
const title = await resolveLinkTitle({
curl: tier1({ authWall: true, title: '' }),
renderer: async () => {
rendererCalls += 1
return 'Google Drive: Sign-in'
},
url: 'https://docs.google.com/document/d/1ExAmPlEdOcId0000000000000000000000/edit'
})
assert.equal(title, '')
assert.equal(rendererCalls, 0)
})
test('a title-less page on a host that can only answer with a wall skips the renderer', async () => {
// The measured domain-restricted Apps Script shape: a 2 KB body carrying none
// of the markers the identifier page does, so the host is what holds.
let rendererCalls = 0
const title = await resolveLinkTitle({
curl: tier1({ authWall: false, title: '' }),
renderer: async () => {
rendererCalls += 1
return ''
},
url: 'https://script.google.com/a/example.edu/macros/s/abc/exec'
})
assert.equal(title, '')
assert.equal(rendererCalls, 0)
})
test('an ordinary title-less page still escalates to the hidden renderer', async () => {
// The tier-2 behaviour that must survive: a JS-rendered page curl can't read
// gets its title from the renderer.
let rendererCalls = 0
const title = await resolveLinkTitle({
curl: tier1(),
renderer: async () => {
rendererCalls += 1
return 'Lab AI service — guides'
},
url: 'https://example.com/guides'
})
assert.equal(title, 'Lab AI service — guides')
assert.equal(rendererCalls, 1)
})
test('a usable tier-1 title never escalates', async () => {
let rendererCalls = 0
const title = await resolveLinkTitle({
curl: tier1({ title: 'Which model to use' }),
renderer: async () => {
rendererCalls += 1
return 'Something else'
},
url: 'https://docs.google.com/document/d/abc123/pub'
})
assert.equal(title, 'Which model to use')
assert.equal(rendererCalls, 0)
})
test('an error/captcha title is not usable and escalates on an ordinary host', async () => {
// usableTitle drops the captcha title, so it reads as "nothing found" — the
// renderer tier is still allowed to try (that is its documented purpose).
let rendererCalls = 0
const title = await resolveLinkTitle({
curl: tier1({ title: 'Just a moment...' }),
renderer: async () => {
rendererCalls += 1
return 'Real page title'
},
url: 'https://example.com/cloudflare-protected'
})
assert.equal(title, 'Real page title')
assert.equal(rendererCalls, 1)
// On a wall host the same empty answer must not escalate.
assert.equal(needsRendererFallback({ authWall: false, title: '', url: 'https://drive.google.com/x' }), false)
assert.equal(needsRendererFallback({ authWall: false, title: '', url: 'https://example.com/x' }), true)
})
test('the sign-in wall is recognised in the body curl already returned', () => {
assert.equal(isAuthWallBody(SIGNIN_IDENTIFIER_BODY), true)
assert.equal(isAuthWallBody('<a href="https://accounts.google.com/ServiceLogin?continue=x">Sign in</a>'), true)
assert.equal(isAuthWallBody('<html><head><title>Which model to use</title></head></html>'), false)
assert.equal(isAuthWallBody(''), false)
})
test('needsRendererFallback only refuses the hosts that can only answer with a wall', () => {
for (const url of [
'https://accounts.google.com/signin',
'https://docs.google.com/document/d/abc/edit',
'https://drive.google.com/drive/folders/abc',
'https://sheets.google.com/spreadsheets/d/abc',
'https://slides.google.com/presentation/d/abc',
'https://sites.google.com/example.edu/x/',
'https://script.google.com/a/example.edu/macros/s/abc/exec',
'https://console.cloud.google.com/apis/library/docs.googleapis.com'
]) {
assert.equal(needsRendererFallback({ authWall: false, title: '', url }), false, url)
}
// A lookalike host is not a Google host, and a URL that won't parse is not a refusal.
for (const url of ['https://example.com/docs', 'https://github.com/NousResearch/hermes-agent', 'not a url']) {
assert.equal(needsRendererFallback({ authWall: false, title: '', url }), true, url)
}
})

View File

@@ -0,0 +1,102 @@
// The ladder between the curl tier and the hidden-Chromium tier of link-title
// resolution: curl (tier 1) → hidden BrowserWindow (tier 2).
//
// The title partition is cookieless (`session.fromPartition('hermes:link-titles',
// { cache: false })`), so a Google Workspace link answers tier 1 with Google's
// sign-in page — and tier 2 used to be reached *exactly* when tier 1 produced no
// usable title, i.e. for every sign-in wall. Loading that wall in the real
// hidden Chromium makes it ask the OS authenticator for a passkey: a native
// credential dialog on the user's desktop for what is only a link title.
//
// Measured shapes of the wall (curl, the app's own flags):
// drive.google.com/drive/folders/<id> -> accounts.google.com/v3/signin/identifier?… (body: id="identifierId")
// docs.google.com/document/d/<id>/edit -> stays on docs.google.com, no <title>, body names accounts.google.com/ServiceLogin
// script.google.com/a/<d>/macros/s/<id>/exec -> www.google.com/a/<d>/ServiceLogin?… (no marker in the body at all)
// The last shape is why markup alone is not enough: the hosts whose cookieless
// answer can only ever be that wall are named too. A published Doc or Site still
// gets its title from the curl tier, which is what keeps these links fetchable.
const TITLE_MAX_CHARS = 240
// Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a
// moment...") so they don't get cached as the resolved title. An error title is
// '' here, which is also the one case the renderer tier is still allowed to try.
const TITLE_ERROR_RE =
/\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
function usableTitle(value: string): string {
return value && !TITLE_ERROR_RE.test(value) ? value : ''
}
/** Sign-in markup — the tell for a wall whose redirect curl already followed. */
const AUTH_WALL_BODY_RE =
/accounts\.google\.com(?:\/|&#47;)(?:ServiceLogin|signin)|id="identifierId"|id="gaia_loginform"/i
/** Hosts whose only cookieless answer is a sign-in page. */
const RENDERER_WALL_HOSTS = new Set([
'accounts.google.com',
'console.cloud.google.com',
'docs.google.com',
'drive.google.com',
'script.google.com',
'sheets.google.com',
'slides.google.com',
'sites.google.com'
])
function hostOf(rawUrl: string): string {
try {
return new URL(rawUrl).hostname.toLowerCase()
} catch {
return ''
}
}
export function isAuthWallBody(body: string): boolean {
return AUTH_WALL_BODY_RE.test(body || '')
}
/**
* May this answer still escalate to the tier-2 hidden Chromium?
*
* `title` is tier 1's title after `usableTitle` — '' for both "nothing found"
* and "found an error/captcha title". `authWall` is tier 1's proof that the page
* it landed on is a sign-in wall.
*/
export function needsRendererFallback(input: { authWall: boolean; title: string; url: string }): boolean {
// Tier 1 resolved a usable title — nothing left to escalate for.
if (input.title) {
return false
}
// A proven sign-in wall, or a host that can only answer with one: tier 2 would
// load the same signed-out page in a real browser and raise the OS passkey
// dialog. The link keeps its host/path label instead of a document title.
if (input.authWall || RENDERER_WALL_HOSTS.has(hostOf(input.url))) {
return false
}
return true
}
/**
* Tier 1 → tier 2, with the sign-in wall never reaching the renderer. Both tiers
* are injected so the ladder is provable without booting Electron: main.ts owns
* the I/O, this owns the decision.
*/
export async function resolveLinkTitle(input: {
curl: () => Promise<{ authWall: boolean; title: string }>
renderer: () => Promise<string>
url: string
}): Promise<string> {
const tier1 = await input.curl().catch(() => ({ authWall: false, title: '' }))
const title = usableTitle((tier1.title || '').slice(0, TITLE_MAX_CHARS))
if (!needsRendererFallback({ authWall: tier1.authWall, title, url: input.url })) {
return title
}
const rendered = await input.renderer().catch(() => '')
return usableTitle((rendered || '').slice(0, TITLE_MAX_CHARS))
}

View File

@@ -265,6 +265,7 @@ import { createHudSnapShortcut } from './hud-snap-shortcut'
import { buildHudWindowUrl } from './hud-url'
import { resolveHudWindowing } from './hud-windowing'
import { createIntroRevealWindowController } from './intro-reveal-window'
import { isAuthWallBody, resolveLinkTitle } from './link-title-wall'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { notifyLauncherWindowRevealed } from './linux-launcher-ready'
import { createLocalBackendLifecycle, waitForTeardown } from './local-backend-lifecycle'
@@ -5933,13 +5934,11 @@ const TITLE_MAX_REDIRECTS = 3
const TITLE_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
const TITLE_ERROR_RE =
/\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" }
// Tier-2 renderer fallback config. Only invoked when curl came back empty or
// matched TITLE_ERROR_RE — keeps cold/CDN-cached pages on the cheap path.
// Tier-2 renderer fallback config. Only invoked when curl came back with no
// usable title and no sign-in wall (electron/link-title-wall.ts) — keeps
// cold/CDN-cached pages on the cheap path.
const RENDER_TITLE_MAX_CONCURRENT = 2
const RENDER_TITLE_TIMEOUT_MS = 8000
const RENDER_TITLE_GRACE_MS = 700
@@ -6000,12 +5999,12 @@ function parseHtmlTitle(html) {
return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : ''
}
function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
function fetchHtmlTitleWithCurl(rawUrl: string): Promise<{ authWall: boolean; title: string }> {
return new Promise(resolve => {
const url = String(rawUrl || '').trim()
if (!url) {
return resolve('')
return resolve({ authWall: false, title: '' })
}
const args = [
@@ -6031,7 +6030,7 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
]
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const chunks = []
const chunks: Buffer[] = []
let bytes = 0
child.stdout.on('data', chunk => {
@@ -6046,13 +6045,17 @@ function fetchHtmlTitleWithCurl(rawUrl: string): Promise<string> {
bytes += next.length
})
child.on('error', () => resolve(''))
child.on('error', () => resolve({ authWall: false, title: '' }))
child.on('close', () => {
if (!chunks.length) {
return resolve('')
return resolve({ authWall: false, title: '' })
}
resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8')))
const html = Buffer.concat(chunks).toString('utf8')
// A sign-in wall answers the cookieless title partition, and tier 2 must
// never load it: the wall asks the OS for a passkey.
resolve({ authWall: isAuthWallBody(html), title: parseHtmlTitle(html) })
})
})
}
@@ -6171,12 +6174,8 @@ function fetchHtmlTitleWithRenderer(rawUrl: string): Promise<string> {
})
}
// Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a
// moment...") so they don't get cached as the resolved title.
function usableTitle(value: string): string {
return value && !TITLE_ERROR_RE.test(value) ? value : ''
}
// Tier ladder (curl → hidden renderer) and its sign-in-wall rule live in
// electron/link-title-wall.ts; main.ts only supplies the two tiers' I/O.
function fetchLinkTitle(rawUrl) {
const url = String(rawUrl || '').trim()
const key = canonicalTitleCacheKey(url)
@@ -6190,15 +6189,14 @@ function fetchLinkTitle(rawUrl) {
}
if (titleInflight.has(key)) {
return titleInflight.get(key)
return Promise.resolve(titleInflight.get(key))
}
const pending = fetchHtmlTitleWithCurl(url)
.catch(() => '')
.then(value => usableTitle((value || '').slice(0, 240)))
.then(
async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240))
)
const pending = resolveLinkTitle({
curl: () => fetchHtmlTitleWithCurl(url),
renderer: () => fetchHtmlTitleWithRenderer(url),
url
})
.then(clean => {
cacheTitle(key, clean)
titleInflight.delete(key)