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:
136
apps/desktop/electron/link-title-wall.test.ts
Normal file
136
apps/desktop/electron/link-title-wall.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
102
apps/desktop/electron/link-title-wall.ts
Normal file
102
apps/desktop/electron/link-title-wall.ts
Normal 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(?:\/|/)(?: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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user