diff --git a/apps/desktop/electron/link-title-wall.test.ts b/apps/desktop/electron/link-title-wall.test.ts new file mode 100644 index 0000000000..fa191c310a --- /dev/null +++ b/apps/desktop/electron/link-title-wall.test.ts @@ -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 = + 'Google Drive: Sign-in' + +// A published Doc answers the cookieless curl tier with its own , 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'), 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) + } +}) diff --git a/apps/desktop/electron/link-title-wall.ts b/apps/desktop/electron/link-title-wall.ts new file mode 100644 index 0000000000..d339fcfd4c --- /dev/null +++ b/apps/desktop/electron/link-title-wall.ts @@ -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/ -> accounts.google.com/v3/signin/identifier?… (body: id="identifierId") +// docs.google.com/document/d//edit -> stays on docs.google.com, no , 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)) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 290ae6e44f..a68b6bf412 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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)