agentlinkops 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "AgentLinkOps"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "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.",
5
5
  "author": {
6
6
  "name": "AgentLinkOps"
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to the `agentlinkops` package are recorded here. The version follows the
4
4
  plugin manifests in `.claude-plugin/` and `.codex-plugin/`.
5
5
 
6
+ ## 0.6.8 (2026-09-20)
7
+
8
+ - 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.
9
+ - 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.
10
+ - Correct the bundled agent reference version. Package, web and documentation preflight checks now reject stale reference versions or mismatched generated copies.
11
+
12
+ ## 0.6.7 (2026-09-20)
13
+
14
+ - Default browser citation measurement to accountless mode. Saved session files are ignored unless `AGENTLINKOPS_BROWSER_AUTH=saved-session` is explicitly selected.
15
+ - Reject custom launch, environment, extension and Firefox-preference overrides in accountless mode. Proxy admission remains required; direct diagnostics still need explicit authorization and configuration.
16
+ - Keep anonymous login walls, blocked pages and failed answers unknown. Accountless mode does not guarantee that a provider will answer.
17
+
6
18
  ## 0.6.6 (2026-09-20)
7
19
 
8
20
  - Retain supplied browser usage receipts in a bounded local journal after each attempt and cleanup. Missing meter values remain unknown; failed writes stop the run.
package/cli/citation.js CHANGED
@@ -28,8 +28,8 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
28
28
  tallies land under .agentlinkops/citations/
29
29
  agentlinkops citation run PANEL.json --engine chatgpt:web-own-browser
30
30
  measure the consumer UI through Camoufox
31
- (dedicated account, personal cadence; evidence
32
- includes a screenshot of every answer)
31
+ (accountless by default; saved browser sessions
32
+ are not loaded)
33
33
  agentlinkops citation sweep PANEL.json --max-usd USD --out INVENTORY.json [--dir DIR]
34
34
  run a bounded epoch and freeze its retained evidence
35
35
  agentlinkops citation inventory PANEL.json --out INVENTORY.json [--epoch ID] [--dir DIR]
@@ -52,8 +52,8 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
52
52
  agentlinkops citation alert PANEL.json [--webhook URL --report REPORT.html]
53
53
  check the latest epoch for declines;
54
54
  logs an alert line if any cell declined
55
- agentlinkops citation login ENGINE open a browser, log in once, save the session
56
- (the engine adapter uses it on every run)
55
+ agentlinkops citation login ENGINE optional legacy saved-session setup
56
+ (not needed for accountless measurement)
57
57
  agentlinkops citation logout ENGINE remove a saved session
58
58
  agentlinkops citation sessions list saved engine sessions
59
59
  agentlinkops citation sync [--dir DIR] [--evidence] push local epoch rows to the hosted
@@ -65,8 +65,11 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository
65
65
 
66
66
  Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
67
67
  ([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd.
68
- Live engines need their configured credentials or a usable browser session; the mock
69
- engine runs everything at zero cost. Every run states its estimated spend, and the
68
+ API engines need their configured credentials. Browser measurement defaults to
69
+ AGENTLINKOPS_BROWSER_AUTH=accountless and never loads saved sessions in that mode.
70
+ Optional legacy saved-session mode requires explicit AGENTLINKOPS_BROWSER_AUTH=saved-session.
71
+ Anonymous login walls or failed answers remain unknown; access is not guaranteed.
72
+ The mock engine runs everything at zero cost. Every run states its estimated spend, and the
70
73
  cap reserves estimated cost before each call. A supplier charge above that estimate
71
74
  is recorded as an overrun and stops subsequent calls. Browser measurement requires
72
75
  AGENTLINKOPS_PROXY_URL. Authorized direct diagnostics need
package/cli/client.js CHANGED
@@ -2,7 +2,7 @@ import {restResponseContract} from '../shared/response-contract.js';
2
2
  // A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
3
3
  // DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
4
4
  // The version follows the plugin manifests; bump it with them.
5
- export const CLI_VERSION = '0.6.6';
5
+ export const CLI_VERSION = '0.6.8';
6
6
  export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
7
7
  export class CloudError extends Error {
8
8
  constructor(code, status, details = null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "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.",
5
5
  "author": "AgentLinkOps (https://agentlinkops.com)",
6
6
  "license": "Apache-2.0",
@@ -12,7 +12,9 @@ This command defaults to the mock engine. Its suggested questions are writing pr
12
12
 
13
13
  Use `--competitors competitors.json` to add domains to check beside your own. The file is an array of objects with `domain`, `brand` and optional `aliases` fields. Each target gets separate results for each question. A target missing from one answer is only missing from that sample; it does not establish absence from the engine.
14
14
 
15
- Choose a live engine explicitly after checking access and budget. For an existing browser setup, use `--engine chatgpt:web-own-browser` or `--engine grok:web-own-browser`. `--engine google-aio` requires your configured supplier access. This workflow does not create accounts or buy services.
15
+ Choose a live engine explicitly after checking access and budget. For accountless browser measurement, use `--engine chatgpt:web-own-browser` or `--engine grok:web-own-browser`. `--engine google-aio` requires your configured supplier access. This workflow does not create accounts or buy services.
16
+
17
+ Starting with CLI 0.6.7, browser authentication defaults to `AGENTLINKOPS_BROWSER_AUTH=accountless`. This mode does not load saved browser sessions, even if session files already exist. A login wall, blocked page or failed answer remains unknown; anonymous access is not guaranteed. Use accountless measurement for this workflow. The optional legacy `AGENTLINKOPS_BROWSER_AUTH=saved-session` mode is separate and requires an explicit opt-in; logging in is not a prerequisite for the default workflow.
16
18
 
17
19
  Starting with CLI 0.6.5, browser measurement requires `AGENTLINKOPS_PROXY_URL`. Missing, failed or quarantined proxy admission never selects direct traffic. An explicitly authorized direct diagnostic requires `AGENTLINKOPS_BROWSER_EGRESS=direct-diagnostic` with no proxy configured. The standard URL proxy has no usage meter; estimated costs are not a provider invoice cap.
18
20
 
@@ -16,6 +16,6 @@ If the user authorizes outreach, use their existing email tool and honor their s
16
16
 
17
17
  When a placement is earned, save source and destination URLs and its relationship to the opportunity. Use connected monitoring tools when requested; local campaign work remains useful without cloud access. Do not promise rankings, response rates, exhaustive competitor coverage, or improvements unsupported by evidence.
18
18
 
19
- For citation panels, read [the panel authoring reference](../../references/citation-panels.md). Gather real audience questions with the user's available sources and record where each came from. `agentlinkops citation panel` drafts editable JSON with a mock engine by default. Suggested questions are not evidence of search demand. Choose a live engine only after checking the user's access and budget; keep citations, mentions, competitors and locale context separate in the results.
19
+ For citation panels, read [the panel authoring reference](../../references/citation-panels.md). Gather real audience questions with the user's available sources and record where each came from. `agentlinkops citation panel` drafts editable JSON with a mock engine by default. Suggested questions are not evidence of search demand. Choose a live engine only after checking the user's access and budget; keep citations, mentions, competitors and locale context separate in the results. Starting with CLI 0.6.7, browser citation measurement defaults to accountless and does not load saved sessions. Do not ask the user to log in as a prerequisite. Anonymous failures remain unknown. The panel reference keeps required proxy admission separate from an explicitly authorized direct diagnostic.
20
20
 
21
21
  Use the [campaign handoff recipe](../../references/recipes/qualified-campaign-handoff.md) when preparing records and reviewed drafts for the person's CRM or sender. It defines checkpoints and later readback before treating an uncertain write as complete.
@@ -9,7 +9,7 @@ records. This page is the complete agent reference: the same text prints from
9
9
  full once per session, then use `describe` for any command's exact schema.
10
10
 
11
11
  <!-- generated:skill-version begin (npm run generate:tools) -->
12
- Pack version 0.6.5. 125 commands in 13 toolsets.
12
+ Pack version 0.6.8. 125 commands in 13 toolsets.
13
13
  <!-- generated:skill-version end -->
14
14
 
15
15
  ## Install and connect (once per machine)
@@ -0,0 +1,215 @@
1
+ import { PROVIDER_MODEL_RESPONSE_SELECTORS } from './shims/utils.js';
2
+
3
+ // This function is serialized into the browser. Keep all lookups within the
4
+ // latest visible, explicitly identified assistant response.
5
+ export function readChatgptCitationState(input) {
6
+ const selectors = Array.isArray(input) ? input : input.selectors;
7
+ const visible = element => {
8
+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
9
+ const style = window.getComputedStyle(element);
10
+ if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
11
+ const rect = element.getBoundingClientRect();
12
+ return rect.width > 0 && rect.height > 0;
13
+ };
14
+ const latestResponse = () => {
15
+ const elements = Array.from(document.querySelectorAll(selectors.join(', '))).filter(visible);
16
+ return elements.filter(element => !elements.some(other => other !== element && other.contains(element))).at(-1);
17
+ };
18
+ const root = latestResponse();
19
+ if (!root || root.getAttribute('aria-busy') === 'true' ||
20
+ (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
21
+ (root.innerText || '').trim().length <= 50) return { found: false, groups: 0, unresolved: true, sources: [] };
22
+ if (input.prepareBinding) {
23
+ const text = root.innerText;
24
+ return {isCurrent:() => latestResponse() === root && visible(root) && root.innerText === text &&
25
+ root.getAttribute('aria-busy') !== 'true' && !(root.getAttribute('data-message-id') || '').startsWith('request-placeholder')};
26
+ }
27
+ const groups = Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"]'));
28
+ const concealed = element => {
29
+ for (let node = element; node; node = node.parentElement) {
30
+ const style = window.getComputedStyle(node);
31
+ if (node.hidden || node.getAttribute('aria-hidden') === 'true' || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return true;
32
+ if (node === root) break;
33
+ }
34
+ return !visible(element);
35
+ };
36
+ const sources = [];
37
+ let unresolved = false;
38
+ const anonymous = root.matches(selectors.at(-1));
39
+ let panelEligible = anonymous && groups.length > 0 && groups.length <= 50;
40
+ const publisherLabels = [];
41
+ if (anonymous) {
42
+ // A new anonymous layout with unrecognized citation controls is not proof
43
+ // of absence. Never use unrelated page-wide links to fill this gap.
44
+ const actions = root.querySelector('[role="group"][aria-label="Response actions"]');
45
+ const unsupported = Array.from(root.querySelectorAll('a[href], button, [role="button"], cite, sup, [data-citation], [data-citation-id]'));
46
+ if (unsupported.some(element => !actions?.contains(element) && !groups.some(group => group.contains(element)))) {
47
+ unresolved = true; panelEligible = false;
48
+ }
49
+ }
50
+ for (const group of groups) {
51
+ if (concealed(group)) { unresolved = true; panelEligible = false; continue; }
52
+ if (anonymous) {
53
+ const buttons = Array.from(group.querySelectorAll('button'));
54
+ if (!buttons.length || buttons.length > 50 || group.querySelector('a[href], [role="button"]:not(button)')) panelEligible = false;
55
+ for (const button of buttons) {
56
+ const label = (button.getAttribute('aria-label') || '').trim();
57
+ if (!label || label.length > 200 || concealed(button)) panelEligible = false;
58
+ else publisherLabels.push(label);
59
+ }
60
+ }
61
+ let resolved = 0;
62
+ for (const anchor of group.querySelectorAll('a[href]')) {
63
+ if (concealed(anchor)) { unresolved = true; continue; }
64
+ try {
65
+ const url = new URL(anchor.href);
66
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw Error();
67
+ sources.push({ url: url.href, title: (anchor.textContent || '').trim() });
68
+ resolved++;
69
+ } catch { unresolved = true; }
70
+ }
71
+ // Observed anonymous pills are buttons without URLs. Their labels are
72
+ // not URL evidence; a partial anchor list cannot resolve those controls.
73
+ if (!resolved || group.querySelector('button, [role="button"]')) unresolved = true;
74
+ }
75
+ const labels = [...new Set(publisherLabels)];
76
+ panelEligible = panelEligible && labels.length > 0 && labels.length <= 50;
77
+ const state = { found: true, anonymous, groups: groups.length, unresolved, sources, panelEligible };
78
+ if (!input.prepare) return state;
79
+ if (!panelEligible || !unresolved) return {state};
80
+ // Keep the exact assistant and control references in-page. No DOM identities,
81
+ // tokens or control IDs are persisted in observations.
82
+ const footer = root.querySelector(':scope > [role="group"][aria-label="Response actions"]');
83
+ const buttons = Array.from(footer?.querySelectorAll('button') || []).filter(button =>
84
+ !concealed(button) && !button.disabled && Array.from(button.children).some(child =>
85
+ !concealed(child) && child.children.length === 0 && (child.textContent || '').trim() === 'Sources'));
86
+ if (buttons.length !== 1) return {state};
87
+ const button = buttons[0], controlledId = button.getAttribute('aria-controls');
88
+ if (!controlledId || controlledId.length > 256) return {state};
89
+ const before = document.getElementById(controlledId);
90
+ // Only the observed absent/empty target is supported. A populated old panel
91
+ // must not be mistaken for freshly resolved citations from this answer.
92
+ if (before && (visible(before) || (before.textContent || '').trim() || before.querySelector('a'))) return {state};
93
+ const responseText = root.innerText;
94
+ const unavailable = () => ({status:'unavailable'});
95
+ const inspect = () => {
96
+ if (latestResponse() !== root || !root.isConnected || !root.matches(selectors.at(-1)) || concealed(root) ||
97
+ root.innerText !== responseText || root.getAttribute('aria-busy') === 'true' ||
98
+ (root.getAttribute('data-message-id') || '').startsWith('request-placeholder') ||
99
+ !button.isConnected || !footer.contains(button) || !root.contains(footer) || button.getAttribute('aria-controls') !== controlledId) return unavailable();
100
+ const currentState = readChatgptCitationState(selectors);
101
+ if (!currentState.panelEligible || currentState.groups !== state.groups) return unavailable();
102
+ const currentLabels = [...new Set(Array.from(root.querySelectorAll('[role="group"][aria-label="Sources"] button')).map(node => (node.getAttribute('aria-label') || '').trim()))];
103
+ if (currentLabels.length !== labels.length || currentLabels.some(label => !labels.includes(label))) return unavailable();
104
+ const target = document.getElementById(controlledId);
105
+ if (!target || !visible(target)) return {status:'pending'};
106
+ if (target === root || target.contains(root) || ['BODY','MAIN','HTML','NAV'].includes(target.tagName)) return unavailable();
107
+ const articles = (target.matches('article') ? [target] : Array.from(target.querySelectorAll('article'))).filter(visible);
108
+ if (articles.length !== 1) return unavailable();
109
+ const article = articles[0];
110
+ 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();
111
+ const headings = article.querySelectorAll('h1,h2,h3,[role="heading"]');
112
+ const header = article.querySelector(':scope > header > h2');
113
+ if (headings.length !== 1 || headings[0] !== header || concealed(header) || (header.textContent || '').trim() !== 'Sources') return unavailable();
114
+ const lists = article.querySelectorAll('ol');
115
+ if (lists.length !== 1 || article.querySelector('ul')) return unavailable();
116
+ const list = lists[0], cards = Array.from(list.children);
117
+ if (concealed(list) || !cards.length || cards.length > 50 || list.querySelectorAll('li').length !== cards.length || article.querySelectorAll('a').length !== cards.length) return unavailable();
118
+ const foundLabels = new Set(), results = [];
119
+ for (const card of cards) {
120
+ if (card.tagName !== 'LI' || concealed(card) || card.children.length !== 1 || card.firstElementChild.tagName !== 'A') return unavailable();
121
+ const anchor = card.firstElementChild, titles = anchor.querySelectorAll('strong');
122
+ if (concealed(anchor) || titles.length !== 1 || concealed(titles[0])) return unavailable();
123
+ const title = (titles[0].textContent || '').trim();
124
+ if (!title || title.length > 1000) return unavailable();
125
+ let url;
126
+ try {
127
+ const raw = anchor.getAttribute('href');
128
+ if (!raw || raw.length > 8192 || /[\u0000-\u0020\u007f]/.test(raw)) return unavailable();
129
+ url = new URL(raw);
130
+ if (!['http:','https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return unavailable();
131
+ } catch { return unavailable(); }
132
+ // Exact visible publisher labels, not guessed domains or URL derivation.
133
+ const matched = [...new Set(Array.from(anchor.querySelectorAll('span')).filter(node =>
134
+ node.children.length === 0 && !concealed(node)).map(node => (node.textContent || '').trim()).filter(label => labels.includes(label)))];
135
+ if (matched.length !== 1) return unavailable();
136
+ foundLabels.add(matched[0]);
137
+ results.push({url:url.href,title});
138
+ }
139
+ if (foundLabels.size !== labels.length) return unavailable();
140
+ return {status:'resolved',sources:results};
141
+ };
142
+ return {state,button,inspect};
143
+ }
144
+
145
+ const unavailable = () => Object.assign(new Error('ChatGPT citation controls could not be resolved.'), {code:'CITATION_EXTRACTION_UNAVAILABLE'});
146
+ const responseBindings = new WeakMap();
147
+ const bindingCurrent = binding => binding.evaluate(value => typeof value.isCurrent === 'function' && value.isCurrent());
148
+ export async function releaseChatgptResponseBinding(page) {
149
+ const binding = responseBindings.get(page);
150
+ responseBindings.delete(page);
151
+ await binding?.dispose().catch(() => {});
152
+ }
153
+ export async function extractBoundChatgptResponse(page, legacyExtract) {
154
+ const previous = responseBindings.get(page);
155
+ responseBindings.delete(page);
156
+ if (previous) await previous.dispose().catch(() => {});
157
+ let binding;
158
+ try {
159
+ binding = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepareBinding:true});
160
+ if (!await bindingCurrent(binding)) throw unavailable();
161
+ const answer = await legacyExtract(page);
162
+ if (!await bindingCurrent(binding)) throw unavailable();
163
+ responseBindings.set(page,binding);
164
+ return answer;
165
+ } catch {
166
+ await binding?.dispose().catch(() => {});
167
+ throw unavailable();
168
+ }
169
+ }
170
+ async function extractControlledPanel(page, binding) {
171
+ let handle, buttonHandle;
172
+ try {
173
+ handle = await page.evaluateHandle(readChatgptCitationState, {selectors:PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt,prepare:true});
174
+ buttonHandle = await handle.getProperty('button');
175
+ const button = buttonHandle.asElement();
176
+ if (!button) throw unavailable();
177
+ await button.click({timeout:3000});
178
+ // Bounded DOM render polling only; never another click/provider invocation.
179
+ let previous = null, stable = 0;
180
+ for (let attempt=0; attempt<12; attempt++) {
181
+ const result = await handle.evaluate((state,guard) => guard && !guard.isCurrent() ? {status:'unavailable'} : state.inspect(),binding || null);
182
+ if (result.status === 'resolved') {
183
+ const signature = JSON.stringify(result.sources);
184
+ stable = signature === previous ? stable + 1 : 1;
185
+ previous = signature;
186
+ // Publisher coverage alone is insufficient while more same-publisher
187
+ // cards may still be rendering. Require three identical valid reads.
188
+ if (stable >= 3) return [...new Map(result.sources.map(source => [source.url,source])).values()];
189
+ } else if (result.status === 'pending') { previous = null; stable = 0; }
190
+ else throw unavailable();
191
+ await page.waitForTimeout(250);
192
+ }
193
+ throw unavailable();
194
+ } catch { throw unavailable(); }
195
+ finally { await Promise.allSettled([buttonHandle?.dispose(),handle?.dispose()]); }
196
+ }
197
+
198
+ export async function extractChatgptSources(page, legacyExtract) {
199
+ const binding = responseBindings.get(page);
200
+ try {
201
+ if (binding && !await bindingCurrent(binding)) throw unavailable();
202
+ const state = await page.evaluate(readChatgptCitationState, PROVIDER_MODEL_RESPONSE_SELECTORS.chatgpt);
203
+ let sources;
204
+ if (!state.found || state.unresolved) {
205
+ if (!state.found || !state.panelEligible) throw unavailable();
206
+ sources = await extractControlledPanel(page,binding);
207
+ } else if (state.anonymous || state.groups) sources = [...new Map(state.sources.map(source => [source.url, source])).values()];
208
+ else sources = await legacyExtract(page);
209
+ if (binding && !await bindingCurrent(binding)) throw unavailable();
210
+ return sources;
211
+ } finally {
212
+ responseBindings.delete(page);
213
+ await binding?.dispose().catch(() => {});
214
+ }
215
+ }
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
 
16
16
  import { EngineError } from '../adapter.js';
17
17
  import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
18
+ import { extractBoundChatgptResponse, extractChatgptSources, releaseChatgptResponseBinding } from './chatgpt-citations.js';
18
19
 
19
20
  const MODULE_LOADERS = {
20
21
  'playwright-core': () => import('playwright-core'),
@@ -61,6 +62,18 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
61
62
  if (!BROWSER_PROVIDERS.includes(engineName)) {
62
63
  throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
63
64
  }
65
+ const authentication = env.AGENTLINKOPS_BROWSER_AUTH ?? 'accountless';
66
+ if (!['accountless', 'saved-session'].includes(authentication)) {
67
+ throw Object.assign(new EngineError('Unsupported browser authentication policy.'), { code: 'BROWSER_AUTH_POLICY_INVALID' });
68
+ }
69
+ // The vendored resolver reads process.env directly; arbitrary launch/profile
70
+ // and extension overrides must not reintroduce ambient identity.
71
+ const assertAccountlessLaunch = () => {
72
+ if (authentication === 'accountless' && ['CAMOUFOX_EXTRA_LAUNCH_JSON', 'CAMOUFOX_ENV_JSON', 'CAMOUFOX_ADDONS', 'CAMOUFOX_ARGS', 'CAMOUFOX_FIREFOX_USER_PREFS_JSON'].some(key => env[key] || process.env[key])) {
73
+ throw Object.assign(new EngineError('Accountless measurement does not accept custom browser launch or extension overrides.'), { code: 'BROWSER_AUTH_POLICY_INVALID' });
74
+ }
75
+ };
76
+ assertAccountlessLaunch();
64
77
  const identity = { engine: engineName, provider: 'web-own-browser', model: engineName };
65
78
  const sessionDir = runtime.sessionDir ?? join(homedir(), '.agentlinkops', 'citations', 'sessions');
66
79
  const load = runtime.loadModule ?? lazy;
@@ -89,6 +102,13 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
89
102
  const { OWN_PROVIDER_CONFIGS } = await load('./providers.js');
90
103
  providerConfig = OWN_PROVIDER_CONFIGS[engineName] ?? PROVIDER_CONFIGS[engineName];
91
104
  if (!providerConfig) throw new EngineError(`no provider config for ${engineName}`);
105
+ if (engineName === 'chatgpt') {
106
+ const legacyExtract = providerConfig.extractSources;
107
+ const legacyResponse = providerConfig.extractResponse;
108
+ providerConfig = { ...providerConfig,
109
+ extractResponse: page => extractBoundChatgptResponse(page, legacyResponse),
110
+ extractSources: page => extractChatgptSources(page, legacyExtract) };
111
+ }
92
112
  return providerConfig;
93
113
  }
94
114
 
@@ -110,6 +130,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
110
130
  const proxy = admission.proxy;
111
131
  await event(proxy?'egress: proxy-required':'egress: direct-diagnostic');
112
132
 
133
+ assertAccountlessLaunch();
113
134
  const options = await resolveCamoufoxLaunchOptions({
114
135
  provider: engineName,
115
136
  proxy: proxy ?? undefined,
@@ -123,6 +144,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
123
144
  const launchOpts = { ...options, executablePath: options.executablePath };
124
145
  if (proxy) launchOpts.proxy = proxy;
125
146
  admission.assertReady();
147
+ assertAccountlessLaunch();
126
148
  const browser = await fw.launch(launchOpts);
127
149
 
128
150
  browserHandle = {
@@ -139,6 +161,8 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
139
161
  }
140
162
 
141
163
  async function loadStorageState() {
164
+ // Accountless measurement never opens session files, even when they exist.
165
+ if (authentication === 'accountless') return undefined;
142
166
  try {
143
167
  const { readFile } = await load('node:fs/promises');
144
168
  return JSON.parse(await readFile(join(sessionDir, `${engineName}.json`), 'utf8'));
@@ -205,6 +229,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
205
229
  locale: 'en-US',
206
230
  timezoneId: 'America/New_York',
207
231
  });
232
+ let page;
208
233
  try {
209
234
  // Route-level asset stripping (opt out with AGENTLINKOPS_BROWSER_KEEP_ASSETS=1):
210
235
  // images and media are the bulk of the 3-8 MB measured per observation.
@@ -216,7 +241,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
216
241
  return route.continue();
217
242
  });
218
243
  }
219
- const page = await context.newPage();
244
+ page = await context.newPage();
220
245
  const { runPageDomOp } = await load('./gen/lib/browser/domOps.js');
221
246
  page.runDomOp = (operation, params = {}) => runPageDomOp(page, operation, params);
222
247
  let answer = '';
@@ -301,7 +326,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
301
326
  await config.beforeSubmitHook?.(page);
302
327
 
303
328
  // Submit: the surface's own send button when it exposes one; Enter fallback.
304
- // Submission is verified: the composer clears or a user turn appears.
329
+ // Allow the surface to settle; response extraction remains fail-closed.
305
330
  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();
306
331
  if (await send.isVisible().catch(() => false)) { await send.click(); }
307
332
  else { await editor.locator.press('Enter'); }
@@ -339,6 +364,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
339
364
  screenshotPng,
340
365
  };
341
366
  } catch (cause) {
367
+ if (cause?.code === 'CITATION_EXTRACTION_UNAVAILABLE') {
368
+ throw Object.assign(new EngineError('Citation response could not be bound.'), {code:'CITATION_EXTRACTION_UNAVAILABLE',unknown:true});
369
+ }
342
370
  if (cause instanceof EngineError) throw cause;
343
371
  const msg = String(cause?.message ?? cause);
344
372
  const retriable = /timeout|timed out|ERR_|net::|closed|Target closed/i.test(msg);
@@ -346,6 +374,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
346
374
  error.unknown = true;
347
375
  throw error;
348
376
  } finally {
377
+ if (engineName === 'chatgpt' && page) await closeQuietly(() => releaseChatgptResponseBinding(page));
349
378
  if(!await closeQuietly(() => context.close()))contextCleanupFailed=true;
350
379
  }
351
380
  },
@@ -362,10 +391,10 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
362
391
  },
363
392
  };
364
393
  const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine);
365
- const policyCodes=new Set(['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']);
394
+ 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']);
366
395
  const safeError=cause=>{
367
396
  const code=policyCodes.has(cause?.code)?cause.code:'BROWSER_RUN_FAILED';
368
- 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});
397
+ 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});
369
398
  if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
370
399
  return error;
371
400
  };
@@ -384,6 +413,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
384
413
  if(running)throw Object.assign(new EngineError('A browser sample is already running.'),{code:'BROWSER_RUN_IN_PROGRESS'});
385
414
  running=true;let result,error,reason=null,admitted=false;
386
415
  try{
416
+ assertAccountlessLaunch();
387
417
  if(admission.expired)await retire('lease_expiry');
388
418
  await admission.begin();admitted=true;
389
419
  result=await sample(input);
@@ -96,9 +96,11 @@ async function runPageDomOp(page, operation, params) {
96
96
  function findLatestResponseElement(selectors) {
97
97
  const selector = (selectors || []).join(", ");
98
98
  if (!selector.trim()) return null;
99
- const element = Array.from(document.querySelectorAll(selector)).filter(
100
- (el) => el instanceof HTMLElement && isVisible(el) && !isResponsePlaceholder(el) && el.innerText.trim().length > 50
101
- ).pop() ?? null;
99
+ const elements = Array.from(document.querySelectorAll(selector)).filter(
100
+ (el) => el instanceof HTMLElement && isVisible(el)
101
+ );
102
+ const element = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null;
103
+ if (!element || isResponsePlaceholder(element) || element.innerText.trim().length <= 50) return null;
102
104
  return element ? { selector, element } : null;
103
105
  }
104
106
  function getCachedRawSources(key) {
@@ -34,15 +34,16 @@ async function hasVisibleGenerationIndicator(page, provider) {
34
34
  async function getResponseStateSignature(page, provider) {
35
35
  return await page.evaluate((selectors) => {
36
36
  const visible = (element) => {
37
- if (!(element instanceof HTMLElement)) return false;
37
+ if (!(element instanceof HTMLElement) || !element.isConnected) return false;
38
38
  const style = window.getComputedStyle(element);
39
- return element.offsetParent !== null && style.visibility !== "hidden" && style.display !== "none";
39
+ if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
40
+ const rect = element.getBoundingClientRect();
41
+ return rect.width > 0 && rect.height > 0;
40
42
  };
41
- const elements = (selectors || []).flatMap((selector) => Array.from(document.querySelectorAll(selector))).filter(
42
- (el) => visible(el) && (el.innerText || "").trim().length > 0
43
- );
44
- const latest = elements.at(-1) ?? null;
45
- if (!latest) {
43
+ const selector = (selectors || []).join(", ");
44
+ const elements = selector.trim() ? Array.from(document.querySelectorAll(selector)).filter(visible) : [];
45
+ const latest = elements.filter((el) => !elements.some((other) => other !== el && other.contains(el))).at(-1) ?? null;
46
+ if (!latest || latest.getAttribute("aria-busy") === "true" || (latest.getAttribute("data-message-id") || "").startsWith("request-placeholder") || (latest.innerText || "").trim().length <= 50) {
46
47
  return { signature: "", textLength: 0 };
47
48
  }
48
49
  const text = (latest.innerText || "").replace(/\s+/g, " ").trim();
@@ -1,15 +1,23 @@
1
1
  // Shim for @oneglanse/utils runtime exports. The selector/timeout constants
2
- // are re-exported from the vendored agent-constants module unchanged; logger,
2
+ // are re-exported except the evidenced anonymous ChatGPT response boundary; logger,
3
3
  // getDomain and getFaviconUrls are minimal equivalents.
4
4
  export {
5
5
  PROVIDER_NO_OUTPUT_TIMEOUT_MS,
6
6
  PROVIDER_FORCE_EXIT_STABLE_MS,
7
7
  PROVIDER_EDITOR_SELECTORS,
8
8
  PROVIDER_SUBMIT_BTN_SELECTORS,
9
- PROVIDER_MODEL_RESPONSE_SELECTORS,
10
9
  PROVIDER_RESPONSE_GENERATION_SELECTORS,
11
10
  RETRYABLE_ERRORS,
12
11
  } from '../gen/utils/agent-constants.js';
12
+ import { PROVIDER_MODEL_RESPONSE_SELECTORS as vendoredResponseSelectors } from '../gen/utils/agent-constants.js';
13
+
14
+ // Anonymous surface observed 2026-09-20: conversation LI, distinct from its
15
+ // user sibling, with response actions. Never select the conversation itself.
16
+ 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"]))';
17
+ export const PROVIDER_MODEL_RESPONSE_SELECTORS = Object.freeze({
18
+ ...vendoredResponseSelectors,
19
+ chatgpt: Object.freeze([...vendoredResponseSelectors.chatgpt, CHATGPT_ANONYMOUS_RESPONSE_SELECTOR]),
20
+ });
13
21
 
14
22
  const envQuiet = () => Boolean(process.env.AGENTLINKOPS_BROWSER_QUIET);
15
23