diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b8696d7..e601960 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.7", + "version": "0.6.8", "description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.", "author": { "name": "AgentLinkOps" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d9881dd..ad8d21e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.7", + "version": "0.6.8", "description": "Connect an agent to AgentLinkOps and run backlink work with it: the connect skill (views, the three discovery verbs, evidence rules), local ledger and campaign skills, a portable CRM, the source-cited content pipeline, user-managed disavow lists, HTML reports and monitoring-event sync.", "author": { "name": "AgentLinkOps" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7179a..4510b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `agentlinkops` package are recorded here. The version follows the plugin manifests in `.claude-plugin/` and `.codex-plugin/`. +## 0.6.8 (2026-09-20) + +- Recognize the anonymous ChatGPT response layout and use the latest visible assistant response consistently when reading answers and citations. Exclude user messages and response controls from answer text. +- Read citation URLs from a newly opened anonymous Sources panel after one normal click on the latest response's Sources control. Require the exact controlled panel layout, visible publisher-label coverage and three matching valid reads; unresolved, hidden, ambiguous or changed evidence remains unknown. +- Correct the bundled agent reference version. Package, web and documentation preflight checks now reject stale reference versions or mismatched generated copies. + ## 0.6.7 (2026-09-20) - Default browser citation measurement to accountless mode. Saved session files are ignored unless `AGENTLINKOPS_BROWSER_AUTH=saved-session` is explicitly selected. diff --git a/cli/client.js b/cli/client.js index 46c83b3..7ed8583 100644 --- a/cli/client.js +++ b/cli/client.js @@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js'; // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here. // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers. // The version follows the plugin manifests; bump it with them. -export const CLI_VERSION = '0.6.7'; +export const CLI_VERSION = '0.6.8'; export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`; export class CloudError extends Error { constructor(code, status, details = null) { diff --git a/package-lock.json b/package-lock.json index 888035a..fe6c9ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentlinkops", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentlinkops", - "version": "0.6.7", + "version": "0.6.8", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index 7d5cc71..8694f0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.7", + "version": "0.6.8", "description": "A backlink and AI citation ledger that lives in your repository: local link and citation checks with dated evidence, supplier-export imports and optional sync to the hosted AgentLinkOps service.", "author": "AgentLinkOps (https://agentlinkops.com)", "license": "Apache-2.0", diff --git a/skills/agentlinkops-connect/references/agentlinkops.md b/skills/agentlinkops-connect/references/agentlinkops.md index 90f2621..dd3b49b 100644 --- a/skills/agentlinkops-connect/references/agentlinkops.md +++ b/skills/agentlinkops-connect/references/agentlinkops.md @@ -9,7 +9,7 @@ records. This page is the complete agent reference: the same text prints from full once per session, then use `describe` for any command's exact schema. -Pack version 0.6.5. 125 commands in 13 toolsets. +Pack version 0.6.8. 125 commands in 13 toolsets. ## Install and connect (once per machine) diff --git a/src/citations/browser/chatgpt-citations.js b/src/citations/browser/chatgpt-citations.js new file mode 100644 index 0000000..5946d3e --- /dev/null +++ b/src/citations/browser/chatgpt-citations.js @@ -0,0 +1,215 @@ +import { PROVIDER_MODEL_RESPONSE_SELECTORS } from './shims/utils.js'; + +// This function is serialized into the browser. Keep all lookups within the +// latest visible, explicitly identified assistant response. +export function readChatgptCitationState(input) { + const selectors = Array.isArray(input) ? input : input.selectors; + const visible = element => { + if (!(element instanceof HTMLElement) || !element.isConnected) return false; + const style = window.getComputedStyle(element); + if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const latestResponse = () => { + const elements = Array.from(document.querySelectorAll(selectors.join(', '))).filter(visible); + return elements.filter(element => !elements.some(other => other !== element && other.contains(element))).at(-1); + }; + const root = latestResponse(); + if (!root || root.getAttribute('aria-busy') === 'true' || + (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') || + (root.innerText || '').trim().length <= 50) return { found: false, groups: 0, unresolved: true, sources: [] }; + if (input.prepareBinding) { + const text = root.innerText; + return {isCurrent:() => latestResponse() === root && visible(root) && root.innerText === text && + root.getAttribute('aria-busy') !== 'true' && !(root.getAttribute('data-message-id') || '').startsWith('request-placeholder')}; + } + const groups = Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"]')); + const concealed = element => { + for (let node = element; node; node = node.parentElement) { + const style = window.getComputedStyle(node); + if (node.hidden || node.getAttribute('aria-hidden') === 'true' || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return true; + if (node === root) break; + } + return !visible(element); + }; + const sources = []; + let unresolved = false; + const anonymous = root.matches(selectors.at(-1)); + let panelEligible = anonymous && groups.length > 0 && groups.length <= 50; + const publisherLabels = []; + if (anonymous) { + // A new anonymous layout with unrecognized citation controls is not proof + // of absence. Never use unrelated page-wide links to fill this gap. + const actions = root.querySelector('[role="group"][aria-label="Response actions"]'); + const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]')); + if (unsupported.some(element => !actions?.contains(element) && !groups.some(group => group.contains(element)))) { + unresolved = true; panelEligible = false; + } + } + for (const group of groups) { + if (concealed(group)) { unresolved = true; panelEligible = false; continue; } + if (anonymous) { + const buttons = Array.from(group.querySelectorAll('button')); + if (!buttons.length || buttons.length > 50 || group.querySelector('a[href], [role="button"]:not(button)')) panelEligible = false; + for (const button of buttons) { + const label = (button.getAttribute('aria-label') || '').trim(); + if (!label || label.length > 200 || concealed(button)) panelEligible = false; + else publisherLabels.push(label); + } + } + let resolved = 0; + for (const anchor of group.querySelectorAll('a[href]')) { + if (concealed(anchor)) { unresolved = true; continue; } + try { + const url = new URL(anchor.href); + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw Error(); + sources.push({ url: url.href, title: (anchor.textContent || '').trim() }); + resolved++; + } catch { unresolved = true; } + } + // Observed anonymous pills are buttons without URLs. Their labels are + // not URL evidence; a partial anchor list cannot resolve those controls. + if (!resolved || group.querySelector('button, [role="button"]')) unresolved = true; + } + const labels = [...new Set(publisherLabels)]; + panelEligible = panelEligible && labels.length > 0 && labels.length <= 50; + const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible }; + if (!input.prepare) return state; + if (!panelEligible || !unresolved) return {state}; + // Keep the exact assistant and control references in-page. No DOM identities, + // tokens or control IDs are persisted in observations. + const footer = root.querySelector(':scope > [role="group"][aria-label="Response actions"]'); + const buttons = Array.from(footer?.querySelectorAll('button') || []).filter(button => + !concealed(button) && !button.disabled && Array.from(button.children).some(child => + !concealed(child) && child.children.length === 0 && (child.textContent || '').trim() === 'Sources')); + if (buttons.length !== 1) return {state}; + const button = buttons[0], controlledId = button.getAttribute('aria-controls'); + if (!controlledId || controlledId.length > 256) return {state}; + const before = document.getElementById(controlledId); + // Only the observed absent/empty target is supported. A populated old panel + // must not be mistaken for freshly resolved citations from this answer. + if (before && (visible(before) || (before.textContent || '').trim() || before.querySelector('a'))) return {state}; + const responseText = root.innerText; + const unavailable = () => ({status:'unavailable'}); + const inspect = () => { + if (latestResponse() !== root || !root.isConnected || !root.matches(selectors.at(-1)) || concealed(root) || + root.innerText !== responseText || root.getAttribute('aria-busy') === 'true' || + (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') || + !button.isConnected || !footer.contains(button) || !root.contains(footer) || button.getAttribute('aria-controls') !== controlledId) return unavailable(); + const currentState = readChatgptCitationState(selectors); + if (!currentState.panelEligible || currentState.groups !== state.groups) return unavailable(); + const currentLabels = [...new Set(Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"] button')).map(node => (node.getAttribute('aria-label') || '').trim()))]; + if (currentLabels.length !== labels.length || currentLabels.some(label => !labels.includes(label))) return unavailable(); + const target = document.getElementById(controlledId); + if (!target || !visible(target)) return {status:'pending'}; + if (target === root || target.contains(root) || ['BODY','MAIN','HTML','NAV'].includes(target.tagName)) return unavailable(); + const articles = (target.matches('article') ? [target] : Array.from(target.querySelectorAll('article'))).filter(visible); + if (articles.length !== 1) return unavailable(); + const article = articles[0]; + if (concealed(article) || article.querySelector('form,input,textarea,select,[contenteditable="true"],button,[role="button"],[data-message-author-role="user"],[data-turn="user"],[aria-busy="true"],[role="progressbar"]')) return unavailable(); + const headings = article.querySelectorAll('h1,h2,h3,[role="heading"]'); + const header = article.querySelector(':scope > header > h2'); + if (headings.length !== 1 || headings[0] !== header || concealed(header) || (header.textContent || '').trim() !== 'Sources') return unavailable(); + const lists = article.querySelectorAll('ol'); + if (lists.length !== 1 || article.querySelector('ul')) return unavailable(); + const list = lists[0], cards = Array.from(list.children); + if (concealed(list) || !cards.length || cards.length > 50 || list.querySelectorAll('li').length !== cards.length || article.querySelectorAll('a').length !== cards.length) return unavailable(); + const foundLabels = new Set(), results = []; + for (const card of cards) { + if (card.tagName !== 'LI' || concealed(card) || card.children.length !== 1 || card.firstElementChild.tagName !== 'A') return unavailable(); + const anchor = card.firstElementChild, titles = anchor.querySelectorAll('strong'); + if (concealed(anchor) || titles.length !== 1 || concealed(titles[0])) return unavailable(); + const title = (titles[0].textContent || '').trim(); + if (!title || title.length > 1000) return unavailable(); + let url; + try { + const raw = anchor.getAttribute('href'); + if (!raw || raw.length > 8192 || /[\u0000-\u0020\u007f]/.test(raw)) return unavailable(); + url = new URL(raw); + if (!['http:','https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return unavailable(); + } catch { return unavailable(); } + // Exact visible publisher labels, not guessed domains or URL derivation. + const matched = [...new Set(Array.from(anchor.querySelectorAll('span')).filter(node => + node.children.length === 0 && !concealed(node)).map(node => (node.textContent || '').trim()).filter(label => labels.includes(label)))]; + if (matched.length !== 1) return unavailable(); + foundLabels.add(matched[0]); + results.push({url:url.href,title}); + } + if (foundLabels.size !== labels.length) return unavailable(); + return {status:'resolved',sources:results}; + }; + return {state,button,inspect}; +} + +const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'}); +const responseBindings = new WeakMap(); +const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent()); +export async function releaseChatgptResponseBinding(page) { + const binding = responseBindings.get(page); + responseBindings.delete(page); + await binding?.dispose().catch(() => {}); +} +export async function extractBoundChatgptResponse(page, legacyExtract) { + const previous = responseBindings.get(page); + responseBindings.delete(page); + if (previous) await previous.dispose().catch(() => {}); + let binding; + try { + binding = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepareBinding:true}); + if (!await bindingCurrent(binding)) throw unavailable(); + const answer = await legacyExtract(page); + if (!await bindingCurrent(binding)) throw unavailable(); + responseBindings.set(page,binding); + return answer; + } catch { + await binding?.dispose().catch(() => {}); + throw unavailable(); + } +} +async function extractControlledPanel(page, binding) { + let handle, buttonHandle; + try { + handle = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepare:true}); + buttonHandle = await handle.getProperty('button'); + const button = buttonHandle.asElement(); + if (!button) throw unavailable(); + await button.click({timeout:3000}); + // Bounded DOM render polling only; never another click/provider invocation. + let previous = null, stable = 0; + for (let attempt=0; attempt<12; attempt++) { + const result = await handle.evaluate((state,guard) => guard && !guard.isCurrent() ? {status:'unavailable'} : state.inspect(),binding || null); + if (result.status === 'resolved') { + const signature = JSON.stringify(result.sources); + stable = signature === previous ? stable + 1 : 1; + previous = signature; + // Publisher coverage alone is insufficient while more same-publisher + // cards may still be rendering. Require three identical valid reads. + if (stable >= 3) return [...new Map(result.sources.map(source => [source.url,source])).values()]; + } else if (result.status === 'pending') { previous = null; stable = 0; } + else throw unavailable(); + await page.waitForTimeout(250); + } + throw unavailable(); + } catch { throw unavailable(); } + finally { await Promise.allSettled([buttonHandle?.dispose(),handle?.dispose()]); } +} + +export async function extractChatgptSources(page, legacyExtract) { + const binding = responseBindings.get(page); + try { + if (binding && !await bindingCurrent(binding)) throw unavailable(); + const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt); + let sources; + if (!state.found || state.unresolved) { + if (!state.found || !state.panelEligible) throw unavailable(); + sources = await extractControlledPanel(page,binding); + } else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()]; + else sources = await legacyExtract(page); + if (binding && !await bindingCurrent(binding)) throw unavailable(); + return sources; + } finally { + responseBindings.delete(page); + await binding?.dispose().catch(() => {}); + } +} diff --git a/src/citations/browser/engine.js b/src/citations/browser/engine.js index 0d8930c..9087e42 100644 --- a/src/citations/browser/engine.js +++ b/src/citations/browser/engine.js @@ -15,6 +15,7 @@ import { join } from 'node:path'; import { EngineError } from '../adapter.js'; import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js'; +import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js'; const MODULE_LOADERS = { 'playwright-core': () => import('playwright-core'), @@ -101,6 +102,13 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( const { OWN_PROVIDER_CONFIGS } = await load('./providers.js'); providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName]; if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`); + if (engineName === 'chatgpt') { + const legacyExtract = providerConfig.extractSources; + const legacyResponse = providerConfig.extractResponse; + providerConfig = { ...providerConfig, + extractResponse: page => extractBoundChatgptResponse(page, legacyResponse), + extractSources: page => extractChatgptSources(page, legacyExtract) }; + } return providerConfig; } @@ -221,6 +229,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( locale: 'en-US', timezoneId: 'America/New_York', }); + let page; try { // Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1): // images and media are the bulk of the 3-8 MB measured per observation. @@ -232,7 +241,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( return route.continue(); }); } - const page = await context.newPage(); + page = await context.newPage(); const { runPageDomOp } = await load('./gen/lib/browser/domOps.js'); page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params); let answer = ''; @@ -317,7 +326,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( await config.beforeSubmitHook?.(page); // Submit: the surface's own send button when it exposes one; Enter fallback. - // Submission is verified: the composer clears or a user turn appears. + // Allow the surface to settle; response extraction remains fail-closed. const send = await page.locator('button[aria-label="Send message"], button[data-testid="send-button"], button[aria-label*="Submit" i], button[aria-label*="Send" i]').first(); if (await send.isVisible().catch(() => false)) { await send.click(); } else { await editor.locator.press('Enter'); } @@ -355,6 +364,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( screenshotPng, }; } catch (cause) { + if (cause?.code === 'CITATION_EXTRACTION_UNAVAILABLE') { + throw Object.assign(new EngineError('Citation response could not be bound.'), {code:'CITATION_EXTRACTION_UNAVAILABLE',unknown:true}); + } if (cause instanceof EngineError) throw cause; const msg = String(cause?.message ?? cause); const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg); @@ -362,6 +374,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( error.unknown = true; throw error; } finally { + if (engineName === 'chatgpt' && page) await closeQuietly(() => releaseChatgptResponseBinding(page)); if(!await closeQuietly(() => context.close()))contextCleanupFailed=true; } }, @@ -378,10 +391,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( }, }; const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine); - const policyCodes=new Set(['BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']); + const policyCodes=new Set(['CITATION_EXTRACTION_UNAVAILABLE','BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']); const safeError=cause=>{ const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED'; - const error=Object.assign(new EngineError(code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code}); + const error=Object.assign(new EngineError(code==='CITATION_EXTRACTION_UNAVAILABLE'?'Citation binding or extraction was unavailable.':code==='BROWSER_RUN_FAILED'?'Browser sample failed.':'Browser admission or cleanup failed.',{retriable:code==='BROWSER_RUN_FAILED'&&cause?.retriable===true}),{code}); if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true; return error; }; diff --git a/src/citations/browser/gen/lib/browser/domOps.js b/src/citations/browser/gen/lib/browser/domOps.js index 5515446..9f83ae8 100644 --- a/src/citations/browser/gen/lib/browser/domOps.js +++ b/src/citations/browser/gen/lib/browser/domOps.js @@ -96,9 +96,11 @@ async function runPageDomOp(page, operation, params) { function findLatestResponseElement(selectors) { const selector = (selectors || []).join(", "); if (!selector.trim()) return null; - const element = Array.from(document.querySelectorAll(selector)).filter( - (el) => el instanceof HTMLElement && isVisible(el) && !isResponsePlaceholder(el) && el.innerText.trim().length > 50 - ).pop() ?? null; + const elements = Array.from(document.querySelectorAll(selector)).filter( + (el) => el instanceof HTMLElement && isVisible(el) + ); + const element = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null; + if (!element || isResponsePlaceholder(element) || element.innerText.trim().length <= 50) return null; return element ? { selector, element } : null; } function getCachedRawSources(key) { diff --git a/src/citations/browser/gen/lib/input/response/isGenerating.js b/src/citations/browser/gen/lib/input/response/isGenerating.js index 6d7c890..a71ab03 100644 --- a/src/citations/browser/gen/lib/input/response/isGenerating.js +++ b/src/citations/browser/gen/lib/input/response/isGenerating.js @@ -34,15 +34,16 @@ async function hasVisibleGenerationIndicator(page, provider) { async function getResponseStateSignature(page, provider) { return await page.evaluate((selectors) => { const visible = (element) => { - if (!(element instanceof HTMLElement)) return false; + if (!(element instanceof HTMLElement) || !element.isConnected) return false; const style = window.getComputedStyle(element); - return element.offsetParent !== null && style.visibility !== "hidden" && style.display !== "none"; + if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; }; - const elements = (selectors || []).flatMap((selector) => Array.from(document.querySelectorAll(selector))).filter( - (el) => visible(el) && (el.innerText || "").trim().length > 0 - ); - const latest = elements.at(-1) ?? null; - if (!latest) { + const selector = (selectors || []).join(", "); + const elements = selector.trim() ? Array.from(document.querySelectorAll(selector)).filter(visible) : []; + const latest = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null; + if (!latest || latest.getAttribute("aria-busy") === "true" || (latest.getAttribute("data-message-id") || "").startsWith("request-placeholder") || (latest.innerText || "").trim().length <= 50) { return { signature: "", textLength: 0 }; } const text = (latest.innerText || "").replace(/\s+/g, " ").trim(); diff --git a/src/citations/browser/shims/utils.js b/src/citations/browser/shims/utils.js index c49c3dd..4f7c0b2 100644 --- a/src/citations/browser/shims/utils.js +++ b/src/citations/browser/shims/utils.js @@ -1,15 +1,23 @@ // Shim for @oneglanse/utils runtime exports. The selector/timeout constants -// are re-exported from the vendored agent-constants module unchanged; logger, +// are re-exported except the evidenced anonymous ChatGPT response boundary; logger, // getDomain and getFaviconUrls are minimal equivalents. export { PROVIDER_NO_OUTPUT_TIMEOUT_MS, PROVIDER_FORCE_EXIT_STABLE_MS, PROVIDER_EDITOR_SELECTORS, PROVIDER_SUBMIT_BTN_SELECTORS, - PROVIDER_MODEL_RESPONSE_SELECTORS, PROVIDER_RESPONSE_GENERATION_SELECTORS, RETRYABLE_ERRORS, } from '../gen/utils/agent-constants.js'; +import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } from '../gen/utils/agent-constants.js'; + +// Anonymous surface observed 2026-09-20: conversation LI, distinct from its +// user sibling, with response actions. Never select the conversation itself. +export const CHATGPT_ANONYMOUS_RESPONSE_SELECTOR = 'ol[aria-label="Conversation"] > li:has(> [role="group"][aria-label="Response actions"] button[aria-label="Copy response"]):not(:has(button[aria-label="Copy message"], [data-message-author-role="user"], [data-turn="user"], form, input, textarea, [contenteditable="true"]))'; +export const PROVIDER_MODEL_RESPONSE_SELECTORS = Object.freeze({ + ...vendoredResponseSelectors, + chatgpt: Object.freeze([...vendoredResponseSelectors.chatgpt, CHATGPT_ANONYMOUS_RESPONSE_SELECTOR]), +}); const envQuiet = () => Boolean(process.env.AGENTLINKOPS_BROWSER_QUIET); diff --git a/test/citation-browser-provider.test.js b/test/citation-browser-provider.test.js index 22cde52..f0cd6b4 100644 --- a/test/citation-browser-provider.test.js +++ b/test/citation-browser-provider.test.js @@ -4,14 +4,16 @@ import { createBrowserEngine, BROWSER_PROVIDERS } from '../src/citations/browser import { OWN_PROVIDER_CONFIGS } from '../src/citations/browser/providers.js'; let fixtureSequence=0; -function harness({ authPolicy, stalledCleanup = false, sourcesFail = false, unavailable = false, answer = 'A retained fixture answer long enough to be interpreted independently.' } = {}) { - const contexts = []; const launches = []; const resolves = []; const events = []; let closed = 0; let displayClosed = 0; let stateReads = 0; +function harness({ authPolicy, bindingValid = () => true, citationState = {found:true,groups:0,unresolved:false,sources:[]}, stalledCleanup = false, sourcesFail = false, unavailable = false, answer = 'A retained fixture answer long enough to be interpreted independently.' } = {}) { + const contexts = []; const launches = []; const resolves = []; const events = []; let closed = 0; let displayClosed = 0; let stateReads = 0; let bindingsDisposed = 0; const config = { navigateToPrompt: async () => {}, waitForResponse: async () => {}, extractResponse: async page => page.runDomOp('response-text', {}), ...(unavailable ? { citationExtraction: 'unsupported' } : {}), extractSources: async () => { if (sourcesFail) throw Error('secret-proxy-password'); return [{ url: 'https://example.com' }]; } }; const browser = { async newContext(options) { const context = { options, closed: false, async route() {}, async close() { this.closed = true; if (stalledCleanup) return new Promise(() => {}); }, - async newPage() { return { setDefaultTimeout() {}, screenshot: async () => Buffer.from('fixture-pixels') }; } }; + async newPage() { return { setDefaultTimeout() {}, evaluate: async () => citationState, + evaluateHandle: async()=>({evaluate:async fn=>fn({isCurrent:bindingValid}),dispose:async()=>{bindingsDisposed++;}}), + screenshot: async () => Buffer.from('fixture-pixels') }; } }; contexts.push(context); return context; }, async close() { closed++; if (stalledCleanup) return new Promise(() => {}); } }; const runtime = { cleanupTimeoutMs: 5, platform: 'linux', sessionDir: '/fixture/sessions', firefox: { async launch(options) { launches.push(options); return browser; } }, @@ -25,7 +27,7 @@ function harness({ authPolicy, stalledCleanup = false, sourcesFail = false, unav throw Error('Unexpected import ' + path); } }; const engine = createBrowserEngine({ engineName: 'chatgpt', runtime, env: { ...(authPolicy === undefined ? {} : {AGENTLINKOPS_BROWSER_AUTH: authPolicy}), AGENTLINKOPS_PROXY_URL: `http://fixture-user-${++fixtureSequence}:fixture-secret@proxy.example.com:1234` }, onEvent: text => events.push(text) }); - return { engine, contexts, launches, resolves, events, closed: () => closed, displayClosed: () => displayClosed, stateReads: () => stateReads }; + return { engine, contexts, launches, resolves, events, closed: () => closed, displayClosed: () => displayClosed, stateReads: () => stateReads, bindingsDisposed:()=>bindingsDisposed }; } test('actual browser provider launches BrowserType, propagates display and isolates every sample', async () => { const h = harness(); @@ -47,8 +49,24 @@ test('failed or unsupported citation extraction retains evidence as unknown, nev test('empty scoped answer cannot fall back to page chrome; context still closes', async () => { const h = harness({ answer: '' }); await assert.rejects(h.engine.run({ prompt: 'fixture' }), error=>error.code==='BROWSER_RUN_FAILED'); + assert.equal(h.bindingsDisposed(),1,'early empty-answer rejection explicitly releases its response binding'); assert.equal(h.contexts[0].closed, true); await h.engine.close(); }); +test('real ChatGPT citation guard retains answer and screenshot as unknown for unresolved anonymous pills', async () => { + const h=harness({citationState:{found:true,anonymous:true,groups:3,unresolved:true,sources:[]}}); + const result=await h.engine.run({prompt:'fixture'}); + assert.equal(result.unknown,true);assert.equal(result.failure.code,'CITATION_EXTRACTION_UNAVAILABLE'); + assert.match(result.answer,/retained fixture answer/);assert.ok(result.screenshotPng.length); + assert.deepEqual(result.citations,[]);assert.equal(h.contexts[0].closed,true);await h.engine.close(); +}); +test('response binding changes retain the specific unknown code and never pair stale answer with citations',async()=>{ + for(const invalidAt of [2,3]){ + let checks=0;const h=harness({bindingValid:()=>++checks!==invalidAt}); + if(invalidAt===2)await assert.rejects(h.engine.run({prompt:'fixture'}),e=>e.code==='CITATION_EXTRACTION_UNAVAILABLE'&&e.unknown===true&&e.retriable===false&&e.message==='Citation binding or extraction was unavailable.'); + else {const result=await h.engine.run({prompt:'fixture'});assert.equal(result.unknown,true);assert.equal(result.failure.code,'CITATION_EXTRACTION_UNAVAILABLE');assert.deepEqual(result.citations,[]);assert.ok(result.screenshotPng.length);} + assert.equal(h.contexts[0].closed,true);await h.engine.close(); + } +}); test('vendored response waiter has the required debug logger', async () => { const { logger } = await import('../src/citations/browser/shims/utils.js'); diff --git a/test/citation-browser-proxy.test.js b/test/citation-browser-proxy.test.js index 8c60599..69ca06e 100644 --- a/test/citation-browser-proxy.test.js +++ b/test/citation-browser-proxy.test.js @@ -10,7 +10,7 @@ function fixture({env={},supplier,runError,launchError,eventError,receiptError}= const calls={launch:[],resolve:[],release:[],quarantine:[],acquire:0,events:[],receipts:[],contextClose:0,browserClose:0,displayClose:0}; supplier??={async acquire(){calls.acquire++;return {id:'lease_'+calls.acquire,proxyUrl,expiresAt:new Date(now+1000).toISOString()};},async release(value){calls.release.push(value);},async quarantine(value){calls.quarantine.push(value);},async readMeter(){meter+=100;return {bytes:meter,costMicrousd:meter*2};}}; const config={navigateToPrompt:async()=>{if(runError)throw runError;},waitForResponse:async()=>{},extractResponse:async()=> 'A fixture answer with enough source evidence text for this test.',extractSources:async()=>[]}; - const browser={async newContext(){return {route:async()=>{},close:async()=>{calls.contextClose++;},newPage:async()=>({setDefaultTimeout(){},screenshot:async()=>Buffer.from('fixture')})};},async close(){calls.browserClose++;if(runtime.browserCleanup)await runtime.browserCleanup();}}; + const browser={async newContext(){return {route:async()=>{},close:async()=>{calls.contextClose++;},newPage:async()=>({setDefaultTimeout(){},evaluate:async()=>({found:true,groups:0,unresolved:false,sources:[]}),evaluateHandle:async()=>({evaluate:async fn=>fn({isCurrent:()=>true}),dispose:async()=>{}}),screenshot:async()=>Buffer.from('fixture')})};},async close(){calls.browserClose++;if(runtime.browserCleanup)await runtime.browserCleanup();}}; const runtime={proxySupplier:env.AGENTLINKOPS_BROWSER_EGRESS==='direct-diagnostic'?undefined:supplier,now:()=>now,supplierTimeoutMs:20,cleanupTimeoutMs:20,platform:'linux',onEgressReceipt:async row=>{calls.receipts.push(row);if(receiptError)throw Error(secret);},firefox:{async launch(options){calls.launch.push(options);if(launchError)throw launchError;return browser;}},async loadModule(path){ if(path.endsWith('/providers/index.js'))return {PROVIDER_CONFIGS:{chatgpt:config}}; if(path==='./providers.js')return {OWN_PROVIDER_CONFIGS:{}}; diff --git a/test/citation-chatgpt-surface.test.js b/test/citation-chatgpt-surface.test.js new file mode 100644 index 0000000..fcdd870 --- /dev/null +++ b/test/citation-chatgpt-surface.test.js @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readChatgptCitationState, extractChatgptSources } from '../src/citations/browser/chatgpt-citations.js'; +import { PROVIDER_MODEL_RESPONSE_SELECTORS, CHATGPT_ANONYMOUS_RESPONSE_SELECTOR } from '../src/citations/browser/shims/utils.js'; +import { PROVIDER_MODEL_RESPONSE_SELECTORS as legacy } from '../src/citations/browser/gen/utils/agent-constants.js'; + +const visibleNode=()=>({isConnected:true,getBoundingClientRect:()=>({width:30,height:20}),getAttribute:()=>null}); +const group = (urls = [], buttons = false) => ({ + ...visibleNode(), + querySelectorAll: selector => { assert.equal(selector, 'a[href]'); return urls.map(url => ({...visibleNode(),href:url,textContent:'Evidence source'})); }, + querySelector: selector => { assert.equal(selector, 'button, [role="button"]'); return buttons ? {} : null; }, +}); +function state(groups, {missing = false, hidden = false, pending = false} = {}) { + const root = {isConnected:true,getBoundingClientRect:()=>({width:hidden?0:600,height:300}),innerText:'A fixture assistant answer with enough text to qualify as a scoped response.', + matches:()=>false, + getAttribute: key => key==='aria-busy' && pending ? 'true' : null, + querySelectorAll: selector => {assert.equal(selector,'[role="group"][aria-label="Sources"]');return groups;}}; + const previous = {document:globalThis.document,window:globalThis.window,HTMLElement:globalThis.HTMLElement}; + globalThis.HTMLElement=class {static [Symbol.hasInstance](value){return typeof value?.getBoundingClientRect==='function';}}; + globalThis.document = {querySelectorAll: selector => {assert.equal(selector,PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt.join(', '));return missing?[]:[root];}}; + globalThis.window = {getComputedStyle:()=>({visibility:'visible',display:'block'})}; + try{return readChatgptCitationState(PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);} + finally{for(const key of ['document','window','HTMLElement']){if(previous[key]===undefined)delete globalThis[key];else globalThis[key]=previous[key];}} +} +test('shared ChatGPT selector keeps legacy boundaries and adds a response-only anonymous LI',()=>{ + assert.deepEqual(PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt.slice(0,-1),legacy.chatgpt); + assert.equal(PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt.at(-1),CHATGPT_ANONYMOUS_RESPONSE_SELECTOR); + assert.match(CHATGPT_ANONYMOUS_RESPONSE_SELECTOR,/ol\[aria-label="Conversation"\] > li/); + for(const boundary of ['Response actions','Copy response','Copy message','contenteditable','textarea'])assert.ok(CHATGPT_ANONYMOUS_RESPONSE_SELECTOR.includes(boundary)); +}); +test('anonymous button-only and partially resolved source groups remain unknown',async()=>{ + for(const groups of [[group([],true)],[group(['https://example.com/source'],true)],[group(['https://example.com/source']),group([],true)]]){ + const observed=state(groups);assert.equal(observed.unresolved,true);let fallback=0; + await assert.rejects(extractChatgptSources({evaluate:async()=>observed},async()=>{fallback++;return [];}),e=>e.code==='CITATION_EXTRACTION_UNAVAILABLE'); + assert.equal(fallback,0); + } +}); +test('malformed, credential-bearing and non-HTTP source URLs cannot become confirmed absence',async()=>{ + for(const url of ['not a URL','javascript:alert(1)','https://user:secret@example.com/']){ + const observed=state([group([url])]);assert.equal(observed.unresolved,true);assert.deepEqual(observed.sources,[]); + await assert.rejects(extractChatgptSources({evaluate:async()=>observed},async()=>[]),e=>e.code==='CITATION_EXTRACTION_UNAVAILABLE'&&!String(e).includes('secret')); + } +}); +test('only fully resolved scoped source groups are returned and duplicates collapse',async()=>{ + const observed=state([group(['https://example.com/source']),group(['https://example.com/source'])]); + assert.equal(observed.unresolved,false); + const result=await extractChatgptSources({evaluate:async()=>observed},async()=>{throw Error('must not inspect page-wide sources');}); + assert.deepEqual(result,[{url:'https://example.com/source',title:'Evidence source'}]); +}); +test('missing, hidden or pending assistant cannot become a source-free observation',async()=>{ + for(const options of [{missing:true},{hidden:true},{pending:true}]){ + const observed=state([],options);assert.equal(observed.found,false); + await assert.rejects(extractChatgptSources({evaluate:async()=>observed},async()=>[]),{code:'CITATION_EXTRACTION_UNAVAILABLE'}); + } +}); +test('recognized response without anonymous source groups keeps legacy source extraction',async()=>{ + const page={evaluate:async()=>state([])};let calls=0; + assert.deepEqual(await extractChatgptSources(page,async actual=>{calls++;assert.equal(actual,page);return [{url:'https://legacy.example/source'}];}),[{url:'https://legacy.example/source'}]); + assert.equal(calls,1); + await assert.rejects(extractChatgptSources(page,async()=>{throw Error('legacy unavailable');}),/legacy unavailable/); +}); +test('source-free anonymous response never falls back to old page-wide citation controls',async()=>{ + let called=0; + const page={evaluate:async()=>({found:true,anonymous:true,groups:0,unresolved:false,sources:[]})}; + assert.deepEqual(await extractChatgptSources(page,async()=>{called++;return [{url:'https://old.example/'}];}),[]); + assert.equal(called,0); +}); + +function controlledPanel({result={status:'unavailable'},clickError,hasButton=true}={}){ + const calls={clicks:0,polls:0,waits:0,disposed:0}; + const page={evaluate:async()=>({found:true,anonymous:true,unresolved:true,panelEligible:true}), + evaluateHandle:async(fn,input)=>{ + assert.equal(fn,readChatgptCitationState);assert.equal(input.prepare,true); + return {getProperty:async name=>{assert.equal(name,'button');return {asElement:()=>hasButton?{click:async options=>{assert.deepEqual(options,{timeout:3000});calls.clicks++;if(clickError)throw clickError;}}:null,dispose:async()=>{calls.disposed++;}};}, + evaluate:async fn=>fn({inspect:()=>{calls.polls++;return result;}}),dispose:async()=>{calls.disposed++;}}; + },waitForTimeout:async ms=>{assert.equal(ms,250);calls.waits++;}}; + return {page,calls}; +} +test('controlled panel makes one normal bounded click and preserves full live URLs',async()=>{ + const source={url:'https://example.com/source?ref=answer#cited-section',title:'Cited title'}; + const h=controlledPanel({result:{status:'resolved',sources:[source,source]}}); + assert.deepEqual(await extractChatgptSources(h.page,async()=>{throw Error('must not use global fallback');}),[source]); + assert.deepEqual(h.calls,{clicks:1,polls:3,waits:2,disposed:2}); +}); +test('pointer failure is secret-safe and never triggers force, fallback or another click',async()=>{ + const h=controlledPanel({clickError:Error('credential-sentinel')}); + await assert.rejects(extractChatgptSources(h.page,async()=>[]),e=>e.code==='CITATION_EXTRACTION_UNAVAILABLE'&&!String(e).includes('credential-sentinel')&&!e.cause); + assert.deepEqual(h.calls,{clicks:1,polls:0,waits:0,disposed:2}); +}); +test('unavailable controlled panel stops immediately; render polling has a finite bound',async()=>{ + for(const status of ['unavailable','pending']){ + const h=controlledPanel({result:{status}}); + await assert.rejects(extractChatgptSources(h.page,async()=>[]),{code:'CITATION_EXTRACTION_UNAVAILABLE'}); + assert.equal(h.calls.clicks,1);assert.equal(h.calls.disposed,2); + assert.equal(h.calls.polls,status==='pending'?12:1);assert.equal(h.calls.waits,status==='pending'?12:0); + } +}); +test('missing verified footer handle refuses interaction and still disposes handles',async()=>{ + const h=controlledPanel({hasButton:false}); + await assert.rejects(extractChatgptSources(h.page,async()=>[]),{code:'CITATION_EXTRACTION_UNAVAILABLE'}); + assert.deepEqual(h.calls,{clicks:0,polls:0,waits:0,disposed:2}); +}); diff --git a/vendor/oneglanse/lib/browser/domOps.ts b/vendor/oneglanse/lib/browser/domOps.ts index f8e0639..bea7ce3 100644 --- a/vendor/oneglanse/lib/browser/domOps.ts +++ b/vendor/oneglanse/lib/browser/domOps.ts @@ -138,16 +138,13 @@ export async function runPageDomOp( const selector = (selectors || []).join(", "); if (!selector.trim()) return null; - const element = - Array.from(document.querySelectorAll(selector)) - .filter( - (el): el is HTMLElement => - el instanceof HTMLElement && - isVisible(el) && - !isResponsePlaceholder(el) && - el.innerText.trim().length > 50, - ) - .pop() ?? null; + // Local compatibility patch: same DOM-order/outermost boundary as + // the response waiter and scoped ChatGPT citation guard. + const elements = Array.from(document.querySelectorAll(selector)).filter( + (el): el is HTMLElement => el instanceof HTMLElement && isVisible(el), + ); + const element = elements.filter(el => !elements.some(other => other !== el && other.contains(el))).at(-1) ?? null; + if (!element || isResponsePlaceholder(element) || element.innerText.trim().length <= 50) return null; return element ? { selector, element } : null; } diff --git a/vendor/oneglanse/lib/input/response/isGenerating.ts b/vendor/oneglanse/lib/input/response/isGenerating.ts index 45dcdea..5888779 100644 --- a/vendor/oneglanse/lib/input/response/isGenerating.ts +++ b/vendor/oneglanse/lib/input/response/isGenerating.ts @@ -57,24 +57,23 @@ export async function getResponseStateSignature( ): Promise<{ signature: string; textLength: number }> { return await page.evaluate((selectors) => { const visible = (element: Element | null): element is HTMLElement => { - if (!(element instanceof HTMLElement)) return false; + if (!(element instanceof HTMLElement) || !element.isConnected) return false; const style = window.getComputedStyle(element); - return ( - element.offsetParent !== null && - style.visibility !== "hidden" && - style.display !== "none" - ); + if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; }; - const elements = (selectors || []) - .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .filter( - (el): el is HTMLElement => - visible(el) && (el.innerText || "").trim().length > 0, - ); - - const latest = elements.at(-1) ?? null; - if (!latest) { + // Local compatibility patch: use DOM order and keep a complete outer + // response when legacy and anonymous selectors overlap. + const selector = (selectors || []).join(", "); + const elements = selector.trim() + ? Array.from(document.querySelectorAll(selector)).filter(visible) + : []; + const latest = elements.filter(el => !elements.some(other => other !== el && other.contains(el))).at(-1) ?? null; + if (!latest || latest.getAttribute("aria-busy") === "true" || + (latest.getAttribute("data-message-id") || "").startsWith("request-placeholder") || + (latest.innerText || "").trim().length <= 50) { return { signature: "", textLength: 0 }; }