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('Sign in'), true)
+ assert.equal(isAuthWallBody('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//macros/s//exec -> www.google.com/a//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
+ url: string
+}): Promise {
+ 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 {
+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 {
]
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 {
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 {
})
}
-// 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)