diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6a66cf4..2631455 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.4", + "version": "0.6.5", "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 2b9584d..b3b35da 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.4", + "version": "0.6.5", "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 6b83622..f220fb1 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.5 (2026-09-20) + +- Browser measurement requires a configured proxy. Authorized direct diagnostics need an explicit setting; missing proxy configuration never selects direct traffic. +- Bound proxy acquisition, callback and cleanup waits. Failed cleanup quarantines the lease and blocks reuse; errors exclude provider details that could contain credentials. +- Keep supplied usage measurements separate from estimated costs. The standard URL proxy has no meter, and engine callback receipts are not persisted by the CLI runner. + ## 0.6.4 (2026-09-20) - Import and export immutable local IndexNow submission receipts, with hashes and replay checks. These commands send no submissions. diff --git a/README.md b/README.md index fc7076b..43654a5 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,16 @@ agentlinkops citation run panel.json --max-usd 1.00 The panel is JSON: `targets` (domain or url, brand, aliases), `prompts`, `engines` (`mock` or `perplexity`), optional `samples` and `maxUsd`. Every run writes immutable content-addressed answer snapshots plus append-only observation and epoch rows under -`.agentlinkops/citations/`. The USD cap is checked before each call, so it can be approached -but never exceeded, and a budget abort leaves a partial-epoch receipt and exit code 2. A cell +`.agentlinkops/citations/`. The runner reserves estimated cost before each call and retry. A reported supplier +overrun stops later calls; estimates do not enforce a provider invoice cap. A budget +abort leaves a partial-epoch receipt and exit code 2. A cell that declined with complete evidence exits 1, like an expected link observed absent. +Browser measurement requires `AGENTLINKOPS_PROXY_URL` and refuses direct fallback. +For an explicitly authorized direct diagnostic, set `AGENTLINKOPS_BROWSER_EGRESS=direct-diagnostic` +and leave the proxy unset. The standard URL proxy provides no actual usage meter; +its cost remains an estimate. Browser login and live account acceptance are separate. + ## Environment variables | Variable | Purpose | @@ -168,6 +174,8 @@ that declined with complete evidence exits 1, like an expected link observed abs | `AGENTLINKOPS_API_URL` | Hosted origin when it is not in `config.json`. | | `AGENTLINKOPS_WEBHOOK_SECRET` | Verifies signed deliveries in `agentlinkops receive`. | | `AGENTLINKOPS_GSC_TOKEN`, `AGENTLINKOPS_GA4_TOKEN` | First-party context commands under `agentlinkops context`. | +| `AGENTLINKOPS_PROXY_URL` | Proxy URL for browser measurement; credentials are never printed. | +| `AGENTLINKOPS_BROWSER_EGRESS` | Defaults to `proxy-required`; `direct-diagnostic` explicitly permits an authorized diagnostic without a proxy. | | `PERPLEXITY_API_KEY` | Live engine credential for `agentlinkops citation`. Presence is reported by `doctor`; the value is never printed. | No value is ever printed. The `LINKTRAIL_*` spellings, the `linktrail` command and a `.linktrail/` diff --git a/cli/citation.js b/cli/citation.js index 200c479..f0f9e51 100644 --- a/cli/citation.js +++ b/cli/citation.js @@ -68,7 +68,9 @@ Panel (JSON): targets (domain or url, brand, aliases), prompts, engines Live engines need their configured credentials or a usable browser session; the mock engine runs everything at zero cost. Every run states its estimated spend, and the cap reserves estimated cost before each call. A supplier charge above that estimate -is recorded as an overrun and stops subsequent calls. +is recorded as an overrun and stops subsequent calls. Browser measurement requires +AGENTLINKOPS_PROXY_URL. Authorized direct diagnostics need +AGENTLINKOPS_BROWSER_EGRESS=direct-diagnostic with no proxy configured. Output language: citation RATE with a Wilson interval and n, classified against the previous epoch as declined, grown or not_distinguishable — never "removed", never diff --git a/cli/client.js b/cli/client.js index a32fe03..2006dce 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.4'; +export const CLI_VERSION = '0.6.5'; export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`; export class CloudError extends Error { constructor(code, status, details = null) { diff --git a/cli/doctor.js b/cli/doctor.js index 978822d..44fcdbe 100644 --- a/cli/doctor.js +++ b/cli/doctor.js @@ -271,7 +271,7 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console execFile('camoufox', ['path'], { timeout: 8000 }, (error) => resolve(!error)); }); if (camoufoxOk) { - checks.push({ status: 'ok', name: 'browser', detail: 'playwright-core + camoufox present; browser engines available' }); + checks.push({ status: 'ok', name: 'browser', detail: 'playwright-core + camoufox present; browser dependencies present; proxy and account access remain unverified' }); } else { checks.push({ status: 'skip', name: 'browser', detail: 'playwright-core ok but camoufox missing', fix: 'python3 -m pip install cloverlabs-camoufox[geoip] && python3 -m camoufox fetch' }); } diff --git a/package-lock.json b/package-lock.json index 6c1c12d..2d0142c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentlinkops", - "version": "0.6.4", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentlinkops", - "version": "0.6.4", + "version": "0.6.5", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index 6b53234..1dc0d31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.4", + "version": "0.6.5", "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/references/citation-panels.md b/references/citation-panels.md index 960c53f..8ccf1be 100644 --- a/references/citation-panels.md +++ b/references/citation-panels.md @@ -14,6 +14,8 @@ Use `--competitors competitors.json` to add domains to check beside your own. Th 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. +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. + Set `--locale en-GB --country GB` for a British locale draft. The mock engine supports locale fixtures. The Google AIO supplier path supports US and GB location requests; other engine paths refuse non-default locale requests in this version. Legacy default browser observations do not prove US proxy egress. Reports disclose the requested context and its basis; they do not treat it as verified user geography. After reviewing the draft, run the [local CLI workflow](cli.md) and inspect the retained evidence: diff --git a/src/citations/browser/engine.js b/src/citations/browser/engine.js index e0612eb..e0fc778 100644 --- a/src/citations/browser/engine.js +++ b/src/citations/browser/engine.js @@ -14,6 +14,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { EngineError } from '../adapter.js'; +import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js'; const MODULE_LOADERS = { 'playwright-core': () => import('playwright-core'), @@ -56,20 +57,6 @@ const ENGINE_NOMINAL_COST = Object.freeze({ }); const DEFAULT_NOMINAL_COST = 0.03; -function parseProxyUrl(raw) { - if (!raw) return null; - try { - const url = new URL(raw); - return { - server: `${url.protocol}//${url.hostname}:${url.port}`, - username: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - }; - } catch { - throw new EngineError(`AGENTLINKOPS_PROXY_URL is not a valid proxy URL (length ${raw.length}, value never printed)`); - } -} - export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) { if (!BROWSER_PROVIDERS.includes(engineName)) { throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`); @@ -78,13 +65,21 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( const sessionDir = runtime.sessionDir ?? join(homedir(), '.agentlinkops', 'citations', 'sessions'); const load = runtime.loadModule ?? lazy; const platform = runtime.platform ?? process.platform; + const event=async text=>{ + let timer; + try{await Promise.race([Promise.resolve().then(()=>onEvent(text)),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error()),runtime.supplierTimeoutMs??5000);})]);} + catch{throw new EngineError('Browser event reporting failed.');} + finally{clearTimeout(timer);} + }; + const admission=createProxyAdmission({engine:engineName,env,supplier:runtime.proxySupplier,now:runtime.now,onReceipt:runtime.onEgressReceipt,timeoutMs:runtime.supplierTimeoutMs}); + let running=false,contextCleanupFailed=false; let browserHandle = null; // { browser, cleanup } for the whole epoch let displayHandle = null; let providerConfig = null; async function closeQuietly(action) { let timer; - try { await Promise.race([Promise.resolve().then(action).catch(() => null), new Promise(resolve => { timer = setTimeout(resolve, runtime.cleanupTimeoutMs ?? 5000); })]); } + try { return await Promise.race([Promise.resolve().then(action).then(() => true, () => false), new Promise(resolve => { timer = setTimeout(() => resolve(false), runtime.cleanupTimeoutMs ?? 5000); })]); } finally { clearTimeout(timer); } } @@ -109,11 +104,11 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( // engines that block headless render headfully inside a virtual display. if (platform === 'linux' && !display.detectDisplay()) { displayHandle = await display.ensureDisplay({ allowExistingDisplay: false }); - onEvent(`display: ${displayHandle.display}`); + await event(`display: ${displayHandle.display}`); } - const proxy = parseProxyUrl(env.AGENTLINKOPS_PROXY_URL); - if (proxy) onEvent(`proxy: ${proxy.server}`); + const proxy = admission.proxy; + await event(proxy?'egress: proxy-required':'egress: direct-diagnostic'); const options = await resolveCamoufoxLaunchOptions({ provider: engineName, @@ -127,14 +122,17 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( }); const launchOpts = { ...options, executablePath: options.executablePath }; if (proxy) launchOpts.proxy = proxy; + admission.assertReady(); const browser = await fw.launch(launchOpts); browserHandle = { browser, cleanup: async () => { - await closeQuietly(() => browser.close()); - await displayHandle?.cleanup?.().catch(() => null); + const browserClosed=await closeQuietly(() => browser.close()); + const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.()); displayHandle = null; + if(browserClosed)contextCleanupFailed=false; + return browserClosed&&displayClosed; }, }; return browserHandle; @@ -193,13 +191,14 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( return null; } - return { + const engine = { identity, estimateCostUsd: () => ENGINE_NOMINAL_COST[engineName] ?? DEFAULT_NOMINAL_COST, async run({ prompt }) { const config = await ensureProviderConfig(); const { browser } = await ensureBrowser(); + admission.assertReady(); const storageState = await loadStorageState(); const context = await browser.newContext({ storageState, @@ -318,9 +317,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( : `${engineName}: answer extracted empty`; throw new EngineError(reason, { retriable: true }); } let extractionFailed = config.citationExtraction === 'unsupported'; - const sources = await config.extractSources(page).catch(() => { + const sources = await config.extractSources(page).catch(async () => { extractionFailed = true; - onEvent('sources extraction failed; observation retained as unknown'); + await event('sources extraction failed; observation retained as unknown'); return []; }); const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null); @@ -347,16 +346,64 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( error.unknown = true; throw error; } finally { - await closeQuietly(() => context.close()); + if(!await closeQuietly(() => context.close()))contextCleanupFailed=true; } }, // Called by the runner at epoch end. async close() { - await browserHandle?.cleanup?.(); - await displayHandle?.cleanup?.().catch(() => null); - displayHandle = null; - browserHandle = null; + let confirmed=true; + try{if(browserHandle)confirmed=await browserHandle.cleanup();} + finally{ + const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.()); + confirmed=confirmed&&displayClosed;displayHandle=null;browserHandle=null; + } + return confirmed; }, }; + const sample=engine.run.bind(engine),closeBrowser=engine.close.bind(engine); + 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']); + 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}); + if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true; + return error; + }; + async function retire(phase,reason=null){ + const hadResources=Boolean(browserHandle||displayHandle||admission.proxy);let error,confirmed=false; + try{ + try{confirmed=await closeBrowser();}catch{confirmed=false;} + if(!confirmed)reason='cleanup_unconfirmed'; + try{if(hadResources)await admission.record({outcome:confirmed?'closed':'cleanup_unconfirmed',phase,reason});}catch(cause){error=cause;} + try{if(reason)await admission.quarantine(reason);}catch(cause){error??=cause;} + if(!confirmed)error??=Object.assign(new EngineError('Browser cleanup could not be confirmed.'),{code:'BROWSER_CLEANUP_UNCONFIRMED'}); + }finally{try{await admission.release();}catch(cause){error??=cause;}} + if(error)throw safeError(error); + } + engine.run=async input=>{ + if(running)throw Object.assign(new EngineError('A browser sample is already running.'),{code:'BROWSER_RUN_IN_PROGRESS'}); + running=true;let result,error,reason=null,admitted=false; + try{ + if(admission.expired)await retire('lease_expiry'); + await admission.begin();admitted=true; + result=await sample(input); + if(contextCleanupFailed)throw Object.assign(new EngineError('Browser cleanup could not be confirmed.'),{code:'BROWSER_CLEANUP_UNCONFIRMED'}); + }catch(cause){reason=proxyFailureReason(cause);error=safeError(cause);} + try{ + if(admitted){ + try{const receipt=await admission.record({outcome:error?'failed':result?.unknown?'unknown':'observed',reason});if(result)result.egressReceipt=receipt;} + catch(cause){error=safeError(cause);} + if(error){try{await retire('failure_cleanup',reason);}catch(cause){error=safeError(cause);}} + } + }finally{running=false;} + if(error)throw error; + return result; + }; + engine.close=async()=>{ + if(running)throw Object.assign(new EngineError('A browser sample is already running.'),{code:'BROWSER_RUN_IN_PROGRESS'}); + running=true; + try{await retire('cleanup');}finally{running=false;} + }; + engine.getEgressReceipts=admission.receipts; + return engine; } diff --git a/src/citations/browser/proxy-admission.js b/src/citations/browser/proxy-admission.js new file mode 100644 index 0000000..f782b9b --- /dev/null +++ b/src/citations/browser/proxy-admission.js @@ -0,0 +1,120 @@ +// Local proxy custody. Supplier callbacks perform no work until browser admission. +// Meter values are supplier-observed cumulative counters, never nominal estimates. +import {createHash,randomUUID} from 'node:crypto'; +import {EngineError} from '../adapter.js'; +const digest=value=>createHash('sha256').update(value).digest('hex'); +const quarantines=new Map(); +const safeInteger=value=>Number.isSafeInteger(value)&&value>=0; +const failure=(code,message)=>Object.assign(new EngineError(message),{code}); +export function parseProxyUrl(raw){ + try{ + if(typeof raw!=='string'||raw.length>8192)throw Error(); + const url=new URL(raw); + if(!['http:','https:','socks5:'].includes(url.protocol)||!url.hostname||url.search||url.hash||(url.pathname&&url.pathname!=='/')||(url.protocol==='socks5:'&&!url.port))throw Error(); + const username=decodeURIComponent(url.username),password=decodeURIComponent(url.password); + if(/[\r\n\0]/.test(username+password))throw Error(); + return {server:`${url.protocol}//${url.hostname}${url.port?':'+url.port:''}`,...(username?{username}:{}),...(password?{password}:{})}; + }catch{throw failure('PROXY_CONFIGURATION_INVALID','The browser proxy configuration is invalid.');} +} +export function createUrlProxySupplier({url,now=()=>Date.now()}={}){ + const key=typeof url==='string'?digest(url):null; + return { + async acquire({engine}){ + if(!url)throw failure('PROXY_REQUIRED','Configure a proxy before browser measurement, or explicitly select direct-diagnostic egress.'); + parseProxyUrl(url); + if((quarantines.get(engine+key)??0)>now())throw failure('PROXY_QUARANTINED','The browser proxy is quarantined.'); + return {id:randomUUID(),proxyUrl:url,expiresAt:new Date(now()+10*60*1000).toISOString()}; + }, + async quarantine({engine,reason}){quarantines.set(engine+key,reason==='cleanup_unconfirmed'?Infinity:now()+10*60*1000);}, + async release(){}, + }; +} +// Only explicit structured signals identify a block. Empty answers/selector errors +// do not establish a proxy problem, and arbitrary exception text is never retained. +export function proxyFailureReason(error){ + for(const value of [error,error?.cause]){ + if(value?.status===403||value?.failureType==='bot_detection')return 'blocked'; + if(value?.status===429||value?.failureType==='rate_limited')return 'rate_limited'; + if(['ERR_PROXY_CONNECTION_FAILED','ECONNREFUSED','ECONNRESET','ETIMEDOUT'].includes(value?.code)||value?.failureType==='connection_error'||/\bERR_PROXY_CONNECTION_FAILED\b/.test(String(value?.message??'')))return 'connection_error'; + } + return null; +} +export function createProxyAdmission({engine,env={},supplier,now=()=>Date.now(),onReceipt=()=>{},timeoutMs=5000}={}){ + const mode=env.AGENTLINKOPS_BROWSER_EGRESS??'proxy-required'; + if(!['proxy-required','direct-diagnostic'].includes(mode))throw failure('PROXY_POLICY_INVALID','Choose proxy-required or direct-diagnostic browser egress.'); + if(mode==='direct-diagnostic'&&(supplier||env.AGENTLINKOPS_PROXY_URL))throw failure('PROXY_POLICY_CONFLICT','Direct diagnostics cannot also configure a proxy.'); + const injected=Boolean(supplier); + supplier??=createUrlProxySupplier({url:env.AGENTLINKOPS_PROXY_URL,now}); + let lease=null,baseline=null,poisoned=false;const receipts=[]; + async function bounded(action,code){ + let timer; + try{return await Promise.race([Promise.resolve().then(action),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error()),timeoutMs);})]);} + catch{throw failure(code,'Browser proxy custody failed. No direct fallback was attempted.');} + finally{clearTimeout(timer);} + } + async function meter(){ + if(!lease||typeof supplier.readMeter!=='function')return null; + try{ + const value=await bounded(()=>supplier.readMeter({engine,leaseId:lease.id}),'PROXY_METER_UNAVAILABLE'); + if(!value||!safeInteger(value.bytes)||!(value.costMicrousd===null||value.costMicrousd===undefined||safeInteger(value.costMicrousd)))return null; + return {bytes:value.bytes,costMicrousd:value.costMicrousd??null}; + }catch{return null;} + } + async function release(){ + if(!lease)return; + const previous=lease;lease=null;baseline=null; + try{await bounded(()=>supplier.release({engine,leaseId:previous.id,disposition:poisoned?'quarantined':'clean'}),'PROXY_RELEASE_FAILED');} + catch(error){poisoned=true;throw error;} + } + return { + get proxy(){return lease?.proxy;}, + get expired(){return lease&&Date.parse(lease.expiresAt)<=now();}, + assertReady(){ + if(poisoned)throw failure('PROXY_CUSTODY_UNAVAILABLE','Browser proxy custody needs operator review.'); + if(mode==='proxy-required'&&(!lease||Date.parse(lease.expiresAt)<=now()))throw failure('PROXY_LEASE_EXPIRED','The browser proxy lease has expired.'); + }, + async begin(){ + if(poisoned)throw failure('PROXY_CUSTODY_UNAVAILABLE','Browser proxy custody needs operator review.'); + if(mode==='direct-diagnostic')return; + if(!injected&&!env.AGENTLINKOPS_PROXY_URL)throw failure('PROXY_REQUIRED','Configure a proxy before browser measurement, or explicitly select direct-diagnostic egress.'); + if(lease){if(Date.parse(lease.expiresAt)<=now())throw failure('PROXY_LEASE_EXPIRED','The browser proxy lease has expired.');return;} + if(!['acquire','release','quarantine'].every(name=>typeof supplier?.[name]==='function'))throw failure('PROXY_SUPPLIER_INVALID','The browser proxy supplier is incomplete.'); + let acquired; + const acquiring=Promise.resolve().then(()=>supplier.acquire({engine})); + try{acquired=await bounded(()=>acquiring,'PROXY_ACQUIRE_FAILED');} + catch(error){ + poisoned=true; + // A timed-out acquisition can resolve later; retire that lease without launching. + acquiring.then(value=>{if(typeof value?.id==='string')return bounded(()=>supplier.release({engine,leaseId:value.id}),'PROXY_RELEASE_FAILED');}).catch(()=>{}); + throw error; + } + try{ + if(!acquired||typeof acquired.id!=='string'||!/^[A-Za-z0-9_-]{1,128}$/.test(acquired.id)||typeof acquired.expiresAt!=='string'||!Number.isFinite(Date.parse(acquired.expiresAt))||Date.parse(acquired.expiresAt)<=now())throw Error(); + lease={id:acquired.id,expiresAt:acquired.expiresAt,proxy:parseProxyUrl(acquired.proxyUrl)}; + }catch{ + poisoned=true; + if(acquired&&typeof acquired.id==='string')await bounded(()=>supplier.release({engine,leaseId:acquired.id}),'PROXY_RELEASE_FAILED').catch(()=>{}); + throw failure('PROXY_LEASE_INVALID','The browser proxy lease is invalid.'); + } + baseline=await meter(); + if(Date.parse(lease.expiresAt)<=now()){poisoned=true;await release();throw failure('PROXY_LEASE_EXPIRED','The browser proxy lease has expired.');} + }, + async record({outcome,reason=null,phase='sample'}){ + const current=await meter(),valid=baseline&¤t&¤t.bytes>=baseline.bytes; + const bytes=valid?current.bytes-baseline.bytes:null; + const costMicrousd=valid&&baseline.costMicrousd!==null&¤t.costMicrousd!==null&¤t.costMicrousd>=baseline.costMicrousd?current.costMicrousd-baseline.costMicrousd:null; + const row=Object.freeze({version:1,sequence:receipts.length+1,engine,egress:mode,phase,outcome,reason,leaseHash:lease?digest(lease.id):null,observedAt:new Date(now()).toISOString(),meterState:outcome==='cleanup_unconfirmed'?'incomplete':mode==='direct-diagnostic'?'not_applicable':bytes===null?'unavailable':costMicrousd===null?'bytes_only':'measured',bytes,costMicrousd}); + baseline=current;receipts.push(row); + try{await bounded(()=>onReceipt({...row}),'PROXY_RECEIPT_FAILED');}catch(error){poisoned=true;throw error;} + return {...row}; + }, + async quarantine(reason){ + // Stop this engine instance even if an external supplier cannot quarantine. + poisoned=true; + if(!lease)return; + await bounded(()=>supplier.quarantine({engine,leaseId:lease.id,reason}),'PROXY_QUARANTINE_FAILED'); + }, + release, + receipts:()=>receipts.map(row=>({...row})), + }; +} diff --git a/test/citation-browser-provider.test.js b/test/citation-browser-provider.test.js index 63847f2..2767a7a 100644 --- a/test/citation-browser-provider.test.js +++ b/test/citation-browser-provider.test.js @@ -44,7 +44,7 @@ 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' }), /answer extracted empty/); + await assert.rejects(h.engine.run({ prompt: 'fixture' }), error=>error.code==='BROWSER_RUN_FAILED'); assert.equal(h.contexts[0].closed, true); await h.engine.close(); }); @@ -55,7 +55,7 @@ test('vendored response waiter has the required debug logger', async () => { test('stalled browser cleanup remains bounded and tears down its display', { timeout: 1000 }, async () => { const h = harness({ stalledCleanup: true }); - await h.engine.run({ prompt: 'fixture' }); + await assert.rejects(h.engine.run({ prompt: 'fixture' }),error=>error.code==='BROWSER_CLEANUP_UNCONFIRMED'); await h.engine.close(); assert.equal(h.displayClosed(), 1); }); diff --git a/test/citation-browser-proxy.test.js b/test/citation-browser-proxy.test.js new file mode 100644 index 0000000..8c60599 --- /dev/null +++ b/test/citation-browser-proxy.test.js @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {inspect} from 'node:util'; +import {createBrowserEngine} from '../src/citations/browser/engine.js'; +import {createProxyAdmission,createUrlProxySupplier,parseProxyUrl} from '../src/citations/browser/proxy-admission.js'; +const secret='fixture-private-proxy-password'; +const proxyUrl='http://fixture-user:'+secret+'@gateway.example:8080'; +function fixture({env={},supplier,runError,launchError,eventError,receiptError}={}){ + let now=Date.parse('2026-09-20T00:00:00Z'),meter=0; + 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 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:{}}; + if(path.endsWith('/domOps.js'))return {runPageDomOp:async()=>''}; + if(path.endsWith('/camoufox.js'))return {resolveCamoufoxLaunchOptions:async options=>{calls.resolve.push(options);if(runtime.prepare)await runtime.prepare();return {executablePath:'/fixture/firefox'};}}; + if(path.endsWith('/display.js'))return {detectDisplay:()=>false,ensureDisplay:async()=>({display:':992',cleanup:async()=>{calls.displayClose++;if(runtime.displayCleanup)await runtime.displayCleanup();}})}; + if(path==='node:fs/promises')return {readFile:async()=>{throw Error('no saved session');}}; + throw Error('Unexpected fixture module'); + }}; + const engine=createBrowserEngine({engineName:'chatgpt',env,runtime,onEvent:async value=>{calls.events.push(value);if(eventError==='hang')await new Promise(()=>{});if(eventError)throw Error(secret);}}); + return {engine,calls,supplier,browser,runtime,advance:ms=>{now+=ms;}}; +} +const code=(promise,expected)=>assert.rejects(promise,error=>error.code===expected&&!error.message.includes(secret)); + +test('missing proxy denies before any module, display, browser or supplier work; explicit direct diagnostic works',async()=>{ + let loads=0;const engine=createBrowserEngine({engineName:'chatgpt',env:{},runtime:{loadModule:async()=>{loads++;throw Error('unexpected');}}}); + await code(engine.run({prompt:'fixture'}),'PROXY_REQUIRED');assert.equal(loads,0); + const f=fixture({env:{AGENTLINKOPS_BROWSER_EGRESS:'direct-diagnostic'}}); + const result=await f.engine.run({prompt:'fixture'});assert.equal(f.calls.acquire,0);assert.equal(f.calls.launch[0].proxy,undefined);assert.equal(result.egressReceipt.egress,'direct-diagnostic');await f.engine.close(); + const direct=createProxyAdmission({engine:'chatgpt',env:{AGENTLINKOPS_BROWSER_EGRESS:'direct-diagnostic'}});await direct.begin();assert.equal(direct.proxy,undefined); + const receipt=await direct.record({outcome:'unknown'});assert.equal(receipt.egress,'direct-diagnostic');assert.equal(receipt.meterState,'not_applicable');assert.equal(receipt.bytes,null); + assert.throws(()=>createProxyAdmission({engine:'chatgpt',env:{AGENTLINKOPS_BROWSER_EGRESS:'direct-diagnostic',AGENTLINKOPS_PROXY_URL:proxyUrl}}),/cannot also/); +}); + +test('supplier lease is pinned into launch; epoch reuse, expiry relaunch and cumulative metering stay exact',async()=>{ + const f=fixture();const first=await f.engine.run({prompt:'fixture'});const second=await f.engine.run({prompt:'fixture'}); + assert.equal(f.calls.launch.length,1);assert.equal(f.calls.acquire,1);assert.equal(first.egressReceipt.bytes,100);assert.equal(second.egressReceipt.costMicrousd,200); + assert.deepEqual(f.calls.launch[0].proxy,{server:'http://gateway.example:8080',username:'fixture-user',password:secret}); + f.advance(1001);await f.engine.run({prompt:'fixture'});assert.equal(f.calls.acquire,2);assert.equal(f.calls.launch.length,2);assert.equal(f.calls.release.length,1); + await f.engine.close();assert.equal(f.calls.release.length,2);assert.equal(f.calls.browserClose,2);assert.equal(f.calls.displayClose,2); + assert.ok(f.engine.getEgressReceipts().every(r=>r.meterState==='measured')); + const safe=JSON.stringify(f.engine.getEgressReceipts())+f.calls.events.join('');assert.ok(!safe.includes(secret));assert.ok(!safe.includes('gateway.example'));assert.ok(!safe.includes('fixture-user')); + const copy=f.engine.getEgressReceipts();copy[0].bytes=999;assert.equal(f.engine.getEgressReceipts()[0].bytes,100); +}); + +test('blocked provider quarantines and releases once; admission never falls back to direct or retries it',async()=>{ + const f=fixture({runError:Object.assign(Error(secret),{status:403})});await assert.rejects(f.engine.run({prompt:'fixture'})); + assert.equal(f.calls.quarantine.length,1);assert.equal(f.calls.quarantine[0].reason,'blocked');assert.equal(f.calls.release.length,1);assert.equal(f.calls.browserClose,1); + await code(f.engine.run({prompt:'retry'}),'PROXY_CUSTODY_UNAVAILABLE');assert.equal(f.calls.acquire,1);assert.equal(f.calls.launch.length,1);await f.engine.close(); +}); + +test('generic extraction/setup failures are not invented blocks and close browser/display plus lease',async()=>{ + for(const options of [{runError:Error(secret)},{launchError:Error(secret)},{eventError:true}]){ + const f=fixture(options);await assert.rejects(f.engine.run({prompt:'fixture'}),error=>!error.message.includes(secret)); + assert.equal(f.calls.quarantine.length,0);assert.equal(f.calls.release.length,1);assert.equal(f.calls.displayClose,1);await f.engine.close(); + } +}); + +test('supplier, quarantine, release and receipt callback errors stay secret-safe and stop reuse',async()=>{ + const f=fixture();f.supplier.acquire=async()=>{throw Error(secret);};await code(f.engine.run({prompt:'fixture'}),'PROXY_ACQUIRE_FAILED');assert.equal(f.calls.launch.length,0);await code(f.engine.run({prompt:'retry'}),'PROXY_CUSTODY_UNAVAILABLE'); + const g=fixture({runError:Object.assign(Error('blocked'),{status:429})});g.supplier.quarantine=async()=>{throw Error(secret);};await code(g.engine.run({prompt:'fixture'}),'PROXY_QUARANTINE_FAILED');assert.equal(g.calls.release.length,1);assert.equal(g.calls.browserClose,1); + const h=fixture();await h.engine.run({prompt:'fixture'});h.supplier.release=async()=>{throw Error(secret);};await code(h.engine.close(),'PROXY_RELEASE_FAILED');await code(h.engine.run({prompt:'retry'}),'PROXY_CUSTODY_UNAVAILABLE'); + const j=fixture({receiptError:true});await code(j.engine.run({prompt:'fixture'}),'PROXY_RECEIPT_FAILED');assert.equal(j.calls.release.length,1);assert.equal(j.calls.browserClose,1);assert.equal(j.engine.getEgressReceipts().length,2); +}); + +test('invalid/expired leases are released without browser launch; timed-out acquisition is retired when it arrives',async()=>{ + for(const lease of [{id:'bad',proxyUrl:'file:///secret',expiresAt:'2099-01-01T00:00:00Z'},{id:'old',proxyUrl,expiresAt:'2000-01-01T00:00:00Z'}]){ + const f=fixture();f.supplier.acquire=async()=>lease;await code(f.engine.run({prompt:'fixture'}),'PROXY_LEASE_INVALID');assert.equal(f.calls.launch.length,0);assert.equal(f.calls.release.length,1); + } + const f=fixture();let resolve;f.supplier.acquire=()=>new Promise(done=>{resolve=done;});await code(f.engine.run({prompt:'fixture'}),'PROXY_ACQUIRE_FAILED'); + resolve({id:'late',proxyUrl,expiresAt:'2099-01-01T00:00:00Z'});await new Promise(done=>setTimeout(done,1));assert.equal(f.calls.release.length,1);assert.equal(f.calls.launch.length,0); +}); + +test('absent, malformed, reset or failed meters remain unavailable rather than nominal dollars or zero',async()=>{ + for(const readMeter of [undefined,async()=>{throw Error(secret);},async()=>({bytes:-1,costMicrousd:0}),async()=>({bytes:10,costMicrousd:'0.03'})]){ + const f=fixture();f.supplier.readMeter=readMeter;const result=await f.engine.run({prompt:'fixture'});assert.equal(result.egressReceipt.meterState,'unavailable');assert.equal(result.egressReceipt.bytes,null);assert.equal(result.egressReceipt.costMicrousd,null);assert.equal(result.costEstimateUsd,0.03);await f.engine.close(); + } + const f=fixture();let bytes=200;f.supplier.readMeter=async()=>({bytes:bytes-=100});const result=await f.engine.run({prompt:'fixture'});assert.equal(result.egressReceipt.meterState,'unavailable');await f.engine.close(); +}); + +test('generic URL supplier does not invent a meter and quarantines the endpoint within its process',async()=>{ + let now=0;const supplier=createUrlProxySupplier({url:proxyUrl,now:()=>now});const lease=await supplier.acquire({engine:'fixture'});assert.equal(supplier.readMeter,undefined);await supplier.quarantine({engine:'fixture',leaseId:lease.id});await code(supplier.acquire({engine:'fixture'}),'PROXY_QUARANTINED');now=600001;await supplier.acquire({engine:'fixture'}); + for(const raw of ['http://proxy/a','http://proxy/?password='+secret,'ftp://proxy:12','not a URL','socks5://proxy'])assert.throws(()=>parseProxyUrl(raw),error=>!error.message.includes(secret)); + assert.deepEqual(parseProxyUrl('http://proxy.example'),{server:'http://proxy.example'}); +}); + +test('overlapping samples and close cannot race lease ownership; a known proxy launch error quarantines',async()=>{ + const f=fixture(),first=f.engine.run({prompt:'fixture'}); + await code(f.engine.run({prompt:'overlap'}),'BROWSER_RUN_IN_PROGRESS');await code(f.engine.close(),'BROWSER_RUN_IN_PROGRESS'); + await first;assert.equal(f.calls.acquire,1);assert.equal(f.calls.launch.length,1);await f.engine.close();assert.equal(f.calls.release.length,1); + const g=fixture({launchError:Error('net::ERR_PROXY_CONNECTION_FAILED '+secret)});await assert.rejects(g.engine.run({prompt:'fixture'}),error=>!error.message.includes(secret));assert.equal(g.calls.quarantine.length,1);assert.equal(g.calls.quarantine[0].reason,'connection_error');assert.equal(g.calls.release.length,1); +}); + +test('close owns the lock through cleanup and supplier release',async()=>{ + const f=fixture();await f.engine.run({prompt:'fixture'});let finish; + f.runtime.browserCleanup=()=>new Promise(resolve=>{finish=resolve;}); + const closing=f.engine.close();await Promise.resolve();await Promise.resolve(); + await code(f.engine.run({prompt:'overlap'}),'BROWSER_RUN_IN_PROGRESS');await code(f.engine.close(),'BROWSER_RUN_IN_PROGRESS'); + finish();await closing;assert.equal(f.calls.release.length,1);assert.equal(f.calls.browserClose,1); +}); + +test('unconfirmed display/browser cleanup is bounded, quarantined, released with uncertainty and blocks reuse',{timeout:1000},async()=>{ + for(const component of ['displayCleanup','browserCleanup'])for(const hanging of [true,false]){ + const f=fixture();await f.engine.run({prompt:'fixture'}); + f.runtime[component]=()=>hanging?new Promise(()=>{}):Promise.reject(Error(secret)); + await code(f.engine.close(),'BROWSER_CLEANUP_UNCONFIRMED');assert.equal(f.calls.quarantine[0].reason,'cleanup_unconfirmed');assert.equal(f.calls.release[0].disposition,'quarantined'); + const receipt=f.engine.getEgressReceipts().at(-1);assert.equal(receipt.outcome,'cleanup_unconfirmed');assert.equal(receipt.meterState,'incomplete'); + await code(f.engine.run({prompt:'retry'}),'PROXY_CUSTODY_UNAVAILABLE');assert.equal(f.calls.launch.length,1); + } +}); + +test('hanging event reporting is bounded and inspect(error) never reveals provider or callback secrets',{timeout:1000},async()=>{ + for(const options of [{eventError:'hang'},{runError:Error(secret)},{launchError:Error(secret)},{receiptError:true}]){ + const f=fixture(options);await assert.rejects(f.engine.run({prompt:'fixture'}),error=>!inspect(error).includes(secret));assert.equal(f.calls.release.length,1); + } +}); + +test('meter or launch preparation cannot admit a lease that expired during awaits',async()=>{ + const f=fixture();f.supplier.readMeter=async()=>{f.advance(1001);return {bytes:0,costMicrousd:0};}; + await code(f.engine.run({prompt:'fixture'}),'PROXY_LEASE_EXPIRED');assert.equal(f.calls.launch.length,0);assert.equal(f.calls.release.length,1); + const g=fixture();g.runtime.prepare=async()=>{g.advance(1001);}; + await code(g.engine.run({prompt:'fixture'}),'PROXY_LEASE_EXPIRED');assert.equal(g.calls.launch.length,0);assert.equal(g.calls.release.length,1); +}); + +test('failure and lease-expiry retirement retain the final measured cleanup interval',async()=>{ + const f=fixture({runError:Error('fixture failure')});await assert.rejects(f.engine.run({prompt:'fixture'}));assert.equal(f.engine.getEgressReceipts().at(-1).phase,'failure_cleanup');assert.equal(f.engine.getEgressReceipts().at(-1).bytes,100); + const g=fixture();await g.engine.run({prompt:'fixture'});g.advance(1001);await g.engine.run({prompt:'fixture'});assert.ok(g.engine.getEgressReceipts().some(row=>row.phase==='lease_expiry'&&row.bytes===100));await g.engine.close(); +});