Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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/`
Expand Down
4 changes: 3 additions & 1 deletion cli/citation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion cli/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions references/citation-panels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
103 changes: 75 additions & 28 deletions src/citations/browser/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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(', ')})`);
Expand All @@ -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); }
}

Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Loading
Loading