From dc11979668282e056316178afcd9c2f338980722 Mon Sep 17 00:00:00 2001 From: sunlover7 <328539788+sunlover7@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:44:37 -0400 Subject: [PATCH] Release 0.6.7 with accountless browser defaults --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 6 ++++ cli/citation.js | 15 +++++---- cli/client.js | 2 +- package-lock.json | 4 +-- package.json | 2 +- references/citation-panels.md | 4 ++- skills/agentlinkops-campaigns/SKILL.md | 2 +- src/citations/browser/engine.js | 19 ++++++++++- test/citation-browser-provider.test.js | 46 ++++++++++++++++++++++---- 11 files changed, 83 insertions(+), 21 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 65450ec..b8696d7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.6", + "version": "0.6.7", "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 05176de..d9881dd 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.6", + "version": "0.6.7", "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 7384bc3..9d7179a 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.7 (2026-09-20) + +- Default browser citation measurement to accountless mode. Saved session files are ignored unless `AGENTLINKOPS_BROWSER_AUTH=saved-session` is explicitly selected. +- Reject custom launch, environment, extension and Firefox-preference overrides in accountless mode. Proxy admission remains required; direct diagnostics still need explicit authorization and configuration. +- Keep anonymous login walls, blocked pages and failed answers unknown. Accountless mode does not guarantee that a provider will answer. + ## 0.6.6 (2026-09-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. diff --git a/cli/citation.js b/cli/citation.js index f0f9e51..c80ae66 100644 --- a/cli/citation.js +++ b/cli/citation.js @@ -28,8 +28,8 @@ const USAGE = `agentlinkops citation — AI citation watches in this repository tallies land under .agentlinkops/citations/ agentlinkops citation run PANEL.json --engine chatgpt:web-own-browser measure the consumer UI through Camoufox - (dedicated account, personal cadence; evidence - includes a screenshot of every answer) + (accountless by default; saved browser sessions + are not loaded) agentlinkops citation sweep PANEL.json --max-usd USD --out INVENTORY.json [--dir DIR] run a bounded epoch and freeze its retained evidence 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 agentlinkops citation alert PANEL.json [--webhook URL --report REPORT.html] check the latest epoch for declines; logs an alert line if any cell declined - agentlinkops citation login ENGINE open a browser, log in once, save the session - (the engine adapter uses it on every run) + agentlinkops citation login ENGINE optional legacy saved-session setup + (not needed for accountless measurement) agentlinkops citation logout ENGINE remove a saved session agentlinkops citation sessions list saved engine sessions 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 Panel (JSON): targets (domain or url, brand, aliases), prompts, engines ([{ "engine": "mock" | "perplexity", "model": "…" }]), optional samples and maxUsd. -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 +API engines need their configured credentials. Browser measurement defaults to +AGENTLINKOPS_BROWSER_AUTH=accountless and never loads saved sessions in that mode. +Optional legacy saved-session mode requires explicit AGENTLINKOPS_BROWSER_AUTH=saved-session. +Anonymous login walls or failed answers remain unknown; access is not guaranteed. +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. Browser measurement requires AGENTLINKOPS_PROXY_URL. Authorized direct diagnostics need diff --git a/cli/client.js b/cli/client.js index 006d4b3..46c83b3 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.6'; +export const CLI_VERSION = '0.6.7'; export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`; export class CloudError extends Error { constructor(code, status, details = null) { diff --git a/package-lock.json b/package-lock.json index a351df1..888035a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentlinkops", - "version": "0.6.6", + "version": "0.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentlinkops", - "version": "0.6.6", + "version": "0.6.7", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index b924802..7d5cc71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentlinkops", - "version": "0.6.6", + "version": "0.6.7", "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 8ccf1be..fd908fc 100644 --- a/references/citation-panels.md +++ b/references/citation-panels.md @@ -12,7 +12,9 @@ This command defaults to the mock engine. Its suggested questions are writing pr 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. -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. +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. + +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. 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. diff --git a/skills/agentlinkops-campaigns/SKILL.md b/skills/agentlinkops-campaigns/SKILL.md index 1908f63..00f2247 100644 --- a/skills/agentlinkops-campaigns/SKILL.md +++ b/skills/agentlinkops-campaigns/SKILL.md @@ -16,6 +16,6 @@ If the user authorizes outreach, use their existing email tool and honor their s 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. -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. +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. 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. diff --git a/src/citations/browser/engine.js b/src/citations/browser/engine.js index e0fc778..0d8930c 100644 --- a/src/citations/browser/engine.js +++ b/src/citations/browser/engine.js @@ -61,6 +61,18 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( if (!BROWSER_PROVIDERS.includes(engineName)) { throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`); } + const authentication = env.AGENTLINKOPS_BROWSER_AUTH ?? 'accountless'; + if (!['accountless', 'saved-session'].includes(authentication)) { + throw Object.assign(new EngineError('Unsupported browser authentication policy.'), { code: 'BROWSER_AUTH_POLICY_INVALID' }); + } + // The vendored resolver reads process.env directly; arbitrary launch/profile + // and extension overrides must not reintroduce ambient identity. + const assertAccountlessLaunch = () => { + 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])) { + throw Object.assign(new EngineError('Accountless measurement does not accept custom browser launch or extension overrides.'), { code: 'BROWSER_AUTH_POLICY_INVALID' }); + } + }; + assertAccountlessLaunch(); const identity = { engine: engineName, provider: 'web-own-browser', model: engineName }; const sessionDir = runtime.sessionDir ?? join(homedir(), '.agentlinkops', 'citations', 'sessions'); const load = runtime.loadModule ?? lazy; @@ -110,6 +122,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( const proxy = admission.proxy; await event(proxy?'egress: proxy-required':'egress: direct-diagnostic'); + assertAccountlessLaunch(); const options = await resolveCamoufoxLaunchOptions({ provider: engineName, proxy: proxy ?? undefined, @@ -123,6 +136,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( const launchOpts = { ...options, executablePath: options.executablePath }; if (proxy) launchOpts.proxy = proxy; admission.assertReady(); + assertAccountlessLaunch(); const browser = await fw.launch(launchOpts); browserHandle = { @@ -139,6 +153,8 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( } async function loadStorageState() { + // Accountless measurement never opens session files, even when they exist. + if (authentication === 'accountless') return undefined; try { const { readFile } = await load('node:fs/promises'); return JSON.parse(await readFile(join(sessionDir, `${engineName}.json`), 'utf8')); @@ -362,7 +378,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( }, }; 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 policyCodes=new Set(['BROWSER_AUTH_POLICY_INVALID','PROXY_REQUIRED','PROXY_POLICY_INVALID','PROXY_POLICY_CONFLICT','PROXY_CONFIGURATION_INVALID','PROXY_ACQUIRE_FAILED','PROXY_SUPPLIER_INVALID','PROXY_LEASE_INVALID','PROXY_LEASE_EXPIRED','PROXY_QUARANTINE_FAILED','PROXY_RELEASE_FAILED','PROXY_RECEIPT_FAILED','PROXY_CUSTODY_UNAVAILABLE','BROWSER_CLEANUP_UNCONFIRMED','BROWSER_RUN_IN_PROGRESS']); const 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}); @@ -384,6 +400,7 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = ( 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{ + assertAccountlessLaunch(); if(admission.expired)await retire('lease_expiry'); await admission.begin();admitted=true; result=await sample(input); diff --git a/test/citation-browser-provider.test.js b/test/citation-browser-provider.test.js index 2767a7a..22cde52 100644 --- a/test/citation-browser-provider.test.js +++ b/test/citation-browser-provider.test.js @@ -3,8 +3,9 @@ import assert from 'node:assert/strict'; import { createBrowserEngine, BROWSER_PROVIDERS } from '../src/citations/browser/engine.js'; import { OWN_PROVIDER_CONFIGS } from '../src/citations/browser/providers.js'; -function harness({ stalledCleanup = false, sourcesFail = false, unavailable = false, answer = 'A retained fixture answer long enough to be interpreted independently.' } = {}) { - const contexts = []; const launches = []; const resolves = []; const events = []; let closed = 0; let displayClosed = 0; +let fixtureSequence=0; +function harness({ authPolicy, stalledCleanup = false, sourcesFail = false, unavailable = false, answer = 'A retained fixture answer long enough to be interpreted independently.' } = {}) { + const contexts = []; const launches = []; const resolves = []; const events = []; let closed = 0; let displayClosed = 0; let stateReads = 0; const config = { navigateToPrompt: async () => {}, waitForResponse: async () => {}, extractResponse: async page => page.runDomOp('response-text', {}), ...(unavailable ? { citationExtraction: 'unsupported' } : {}), extractSources: async () => { if (sourcesFail) throw Error('secret-proxy-password'); return [{ url: 'https://example.com' }]; } }; @@ -20,15 +21,16 @@ function harness({ stalledCleanup = false, sourcesFail = false, unavailable = fa if (path.endsWith('/domOps.js')) return { runPageDomOp: async () => answer }; if (path.endsWith('/camoufox.js')) return { resolveCamoufoxLaunchOptions: async options => { resolves.push(options); return { executablePath: '/fixture/firefox' }; } }; if (path.endsWith('/display.js')) return { detectDisplay: () => false, ensureDisplay: async () => ({ display: ':991', cleanup: async () => { displayClosed++; } }) }; - if (path === 'node:fs/promises') return { readFile: async () => JSON.stringify({ cookies: [], origins: [] }) }; + if (path === 'node:fs/promises') return { readFile: async () => {stateReads++; return JSON.stringify({ cookies: [{ name: 'account-cookie', value: 'saved-state-secret', domain: '.chatgpt.com', path: '/' }], origins: [] });} }; throw Error('Unexpected import ' + path); } }; - const engine = createBrowserEngine({ engineName: 'chatgpt', runtime, env: { AGENTLINKOPS_PROXY_URL: 'http://fixture-user:fixture-secret@proxy.example.com:1234' }, onEvent: text => events.push(text) }); - return { engine, contexts, launches, resolves, events, closed: () => closed, displayClosed: () => displayClosed }; + const engine = createBrowserEngine({ engineName: 'chatgpt', runtime, env: { ...(authPolicy === undefined ? {} : {AGENTLINKOPS_BROWSER_AUTH: authPolicy}), AGENTLINKOPS_PROXY_URL: `http://fixture-user-${++fixtureSequence}:fixture-secret@proxy.example.com:1234` }, onEvent: text => events.push(text) }); + return { engine, contexts, launches, resolves, events, closed: () => closed, displayClosed: () => displayClosed, stateReads: () => stateReads }; } test('actual browser provider launches BrowserType, propagates display and isolates every sample', async () => { const h = harness(); - for (let i = 0; i < 2; i++) { const run = await h.engine.run({ prompt: 'fixture' }); assert.deepEqual(run.browserContext, { surface: 'web', authentication: 'saved-session', account_tier: 'unknown' }); assert.equal(run.citations.length, 1); assert.equal(run.citations[0].title, undefined); } + for (let i = 0; i < 2; i++) { const run = await h.engine.run({ prompt: 'fixture' }); assert.deepEqual(run.browserContext, { surface: 'web', authentication: 'anonymous', account_tier: 'unknown' }); assert.equal(run.citations.length, 1); assert.equal(run.citations[0].title, undefined); } + assert.equal(h.stateReads(),0); assert.ok(h.contexts.every(context=>context.options.storageState===undefined)); assert.equal(h.launches.length, 1); assert.equal(h.resolves[0].display, ':991'); assert.equal(h.contexts.length, 2); assert.ok(h.contexts.every(context => context.closed)); assert.notEqual(h.contexts[0], h.contexts[1]); assert.ok(!h.events.join('').includes('fixture-secret')); @@ -59,3 +61,35 @@ test('stalled browser cleanup remains bounded and tears down its display', { tim await h.engine.close(); assert.equal(h.displayClosed(), 1); }); + + +test('explicit accountless policy ignores saved state; legacy use requires explicit opt-in',async()=>{ + const anonymous=harness({authPolicy:'accountless'}); + const run=await anonymous.engine.run({prompt:'fixture'});assert.equal(run.browserContext.authentication,'anonymous');assert.equal(anonymous.stateReads(),0);assert.equal(anonymous.contexts[0].options.storageState,undefined);await anonymous.engine.close(); + const legacy=harness({authPolicy:'saved-session'});const saved=await legacy.engine.run({prompt:'fixture'});assert.equal(legacy.stateReads(),1);assert.equal(saved.browserContext.authentication,'saved-session');assert.equal(legacy.contexts[0].options.storageState.cookies[0].value,'saved-state-secret');assert.ok(!JSON.stringify(saved).includes('saved-state-secret'));await legacy.engine.close(); +}); + +test('invalid authentication policy fails before any module, saved-state read, lease or browser launch',()=>{ + for(const value of ['', 'automatic', '../sessions', 'accountless\n', 'secret-policy-value']){ + let touched=0;assert.throws(()=>createBrowserEngine({engineName:'chatgpt',env:{AGENTLINKOPS_BROWSER_AUTH:value},runtime:{loadModule:()=>{touched++;},firefox:{launch:()=>{touched++;}},proxySupplier:{acquire:()=>{touched++;}}}}),error=>error.code==='BROWSER_AUTH_POLICY_INVALID'&&(!value||!error.message.includes(value)));assert.equal(touched,0); + } +}); + + +test('accountless policy rejects ambient profile and extension overrides without exposing values',()=>{ + for(const key of ['CAMOUFOX_EXTRA_LAUNCH_JSON','CAMOUFOX_ENV_JSON','CAMOUFOX_ADDONS','CAMOUFOX_ARGS','CAMOUFOX_FIREFOX_USER_PREFS_JSON']){ + const secret='profile-secret-sentinel'; + assert.throws(()=>createBrowserEngine({engineName:'chatgpt',env:{[key]:secret}}),e=>e.code==='BROWSER_AUTH_POLICY_INVALID'&&!String(e).includes(secret)); + const previous=process.env[key];process.env[key]=secret; + try {assert.throws(()=>createBrowserEngine({engineName:'chatgpt',env:{}}),e=>e.code==='BROWSER_AUTH_POLICY_INVALID'&&!String(e).includes(secret));} + finally {if(previous===undefined)delete process.env[key];else process.env[key]=previous;} + } +}); + + +test('late ambient profile override is refused before launch or session access',async()=>{ + const h=harness(),key='CAMOUFOX_ARGS',previous=process.env[key]; + process.env[key]='["-profile","/fixture/secret-profile"]'; + try{await assert.rejects(h.engine.run({prompt:'fixture'}),e=>e.code==='BROWSER_AUTH_POLICY_INVALID'&&!String(e).includes('secret-profile'));assert.equal(h.launches.length,0);assert.equal(h.stateReads(),0);assert.equal(h.contexts.length,0);} + finally{if(previous===undefined)delete process.env[key];else process.env[key]=previous;await h.engine.close();} +});