agentlinkops 0.6.3 → 0.6.5
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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +10 -2
- package/cli/citation.js +3 -1
- package/cli/client.js +1 -1
- package/cli/config.js +2 -1
- package/cli/doctor.js +1 -1
- package/cli/index-observations.js +15 -1
- package/cli/index-submissions.js +50 -0
- package/cli/main.js +2 -0
- package/package.json +1 -1
- package/references/citation-panels.md +2 -0
- package/src/citations/browser/engine.js +75 -28
- package/src/citations/browser/proxy-admission.js +120 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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.
|
|
3
|
+
"version": "0.6.5",
|
|
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.5 (2026-09-20)
|
|
7
|
+
|
|
8
|
+
- Browser measurement requires a configured proxy. Authorized direct diagnostics need an explicit setting; missing proxy configuration never selects direct traffic.
|
|
9
|
+
- Bound proxy acquisition, callback and cleanup waits. Failed cleanup quarantines the lease and blocks reuse; errors exclude provider details that could contain credentials.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
## 0.6.4 (2026-09-20)
|
|
13
|
+
|
|
14
|
+
- Import and export immutable local IndexNow submission receipts, with hashes and replay checks. These commands send no submissions.
|
|
15
|
+
- Join each submission attempt to later retained index observations for the exact URL and project. JSON and CSV preserve source, date, reason and unknown results.
|
|
16
|
+
- Submission responses remain received, validation pending, failed or unknown. Later Google observations do not verify indexing by an IndexNow participant or establish that submission caused indexing.
|
|
17
|
+
|
|
6
18
|
## 0.6.3 (2026-09-20)
|
|
7
19
|
|
|
8
20
|
- Agent installation preserves customer edits and supports previewed removal and interrupted-install recovery.
|
package/README.md
CHANGED
|
@@ -156,10 +156,16 @@ agentlinkops citation run panel.json --max-usd 1.00
|
|
|
156
156
|
The panel is JSON: `targets` (domain or url, brand, aliases), `prompts`, `engines`
|
|
157
157
|
(`mock` or `perplexity`), optional `samples` and `maxUsd`. Every run writes immutable
|
|
158
158
|
content-addressed answer snapshots plus append-only observation and epoch rows under
|
|
159
|
-
`.agentlinkops/citations/`. The
|
|
160
|
-
|
|
159
|
+
`.agentlinkops/citations/`. The runner reserves estimated cost before each call and retry. A reported supplier
|
|
160
|
+
overrun stops later calls; estimates do not enforce a provider invoice cap. A budget
|
|
161
|
+
abort leaves a partial-epoch receipt and exit code 2. A cell
|
|
161
162
|
that declined with complete evidence exits 1, like an expected link observed absent.
|
|
162
163
|
|
|
164
|
+
Browser measurement requires `AGENTLINKOPS_PROXY_URL` and refuses direct fallback.
|
|
165
|
+
For an explicitly authorized direct diagnostic, set `AGENTLINKOPS_BROWSER_EGRESS=direct-diagnostic`
|
|
166
|
+
and leave the proxy unset. The standard URL proxy provides no actual usage meter;
|
|
167
|
+
its cost remains an estimate. Browser login and live account acceptance are separate.
|
|
168
|
+
|
|
163
169
|
## Environment variables
|
|
164
170
|
|
|
165
171
|
| Variable | Purpose |
|
|
@@ -168,6 +174,8 @@ that declined with complete evidence exits 1, like an expected link observed abs
|
|
|
168
174
|
| `AGENTLINKOPS_API_URL` | Hosted origin when it is not in `config.json`. |
|
|
169
175
|
| `AGENTLINKOPS_WEBHOOK_SECRET` | Verifies signed deliveries in `agentlinkops receive`. |
|
|
170
176
|
| `AGENTLINKOPS_GSC_TOKEN`, `AGENTLINKOPS_GA4_TOKEN` | First-party context commands under `agentlinkops context`. |
|
|
177
|
+
| `AGENTLINKOPS_PROXY_URL` | Proxy URL for browser measurement; credentials are never printed. |
|
|
178
|
+
| `AGENTLINKOPS_BROWSER_EGRESS` | Defaults to `proxy-required`; `direct-diagnostic` explicitly permits an authorized diagnostic without a proxy. |
|
|
171
179
|
| `PERPLEXITY_API_KEY` | Live engine credential for `agentlinkops citation`. Presence is reported by `doctor`; the value is never printed. |
|
|
172
180
|
|
|
173
181
|
No value is ever printed. The `LINKTRAIL_*` spellings, the `linktrail` command and a `.linktrail/`
|
package/cli/citation.js
CHANGED
|
@@ -68,7 +68,9 @@ Panel (JSON): targets (domain or url, brand, aliases), prompts, engines
|
|
|
68
68
|
Live engines need their configured credentials or a usable browser session; the mock
|
|
69
69
|
engine runs everything at zero cost. Every run states its estimated spend, and the
|
|
70
70
|
cap reserves estimated cost before each call. A supplier charge above that estimate
|
|
71
|
-
is recorded as an overrun and stops subsequent calls.
|
|
71
|
+
is recorded as an overrun and stops subsequent calls. Browser measurement requires
|
|
72
|
+
AGENTLINKOPS_PROXY_URL. Authorized direct diagnostics need
|
|
73
|
+
AGENTLINKOPS_BROWSER_EGRESS=direct-diagnostic with no proxy configured.
|
|
72
74
|
|
|
73
75
|
Output language: citation RATE with a Wilson interval and n, classified against the
|
|
74
76
|
previous epoch as declined, grown or not_distinguishable — never "removed", never
|
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.
|
|
5
|
+
export const CLI_VERSION = '0.6.5';
|
|
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/cli/config.js
CHANGED
|
@@ -20,6 +20,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
20
20
|
receipts: 'receipts.jsonl',
|
|
21
21
|
observations: 'observations.jsonl',
|
|
22
22
|
indexObservations: 'index-observations.jsonl',
|
|
23
|
+
indexSubmissions: 'index-submissions.jsonl',
|
|
23
24
|
events: 'events.jsonl',
|
|
24
25
|
candidates: 'candidates.jsonl',
|
|
25
26
|
state: 'state.json',
|
|
@@ -99,7 +100,7 @@ export async function loadConfig({ cwd = process.cwd(), root = null } = {}) {
|
|
|
99
100
|
defaults: { ...DEFAULTS, ...file.defaults },
|
|
100
101
|
paths: {
|
|
101
102
|
ledger: at('ledger'), observations: at('observations'), events: at('events'),
|
|
102
|
-
candidates: at('candidates'), state: at('state'), receipts: at('receipts'), indexObservations: at('indexObservations'),
|
|
103
|
+
candidates: at('candidates'), state: at('state'), receipts: at('receipts'), indexObservations: at('indexObservations'), indexSubmissions: at('indexSubmissions'),
|
|
103
104
|
},
|
|
104
105
|
};
|
|
105
106
|
}
|
package/cli/doctor.js
CHANGED
|
@@ -271,7 +271,7 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
271
271
|
execFile('camoufox', ['path'], { timeout: 8000 }, (error) => resolve(!error));
|
|
272
272
|
});
|
|
273
273
|
if (camoufoxOk) {
|
|
274
|
-
checks.push({ status: 'ok', name: 'browser', detail: 'playwright-core + camoufox present; browser
|
|
274
|
+
checks.push({ status: 'ok', name: 'browser', detail: 'playwright-core + camoufox present; browser dependencies present; proxy and account access remain unverified' });
|
|
275
275
|
} else {
|
|
276
276
|
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' });
|
|
277
277
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {boundedSubmissionText,readSubmissions,saveSubmissions,reconcileSubmissions,submissionCsv} from './index-submissions.js';
|
|
1
2
|
import {readFile,writeFile,open,rename,unlink,mkdir,stat} from 'node:fs/promises';
|
|
2
3
|
import {dirname,resolve} from 'node:path';
|
|
3
4
|
import {createHash} from 'node:crypto';
|
|
@@ -9,7 +10,7 @@ import {indexEvidenceReference,describeEvidenceReference} from '../src/evidence-
|
|
|
9
10
|
import {indexReceiptResponse} from '../shared/index-observation-contract.js';
|
|
10
11
|
const sha=value=>createHash('sha256').update(value).digest('hex');
|
|
11
12
|
const fail=message=>{throw new ConfigError(message);};
|
|
12
|
-
export const INDEX_CLI_HELP='agentlinkops index pull --watch-id ID [--limit 50] [--before ID] | import RECEIPTS.json | export [--out FILE] | csv URLS.csv [--out FILE] | check-csv URLS.csv --connection-id ID --request-id ID --out FILE';
|
|
13
|
+
export const INDEX_CLI_HELP='agentlinkops index pull --watch-id ID [--limit 50] [--before ID] | import RECEIPTS.json | export [--out FILE] | csv URLS.csv [--out FILE] | check-csv URLS.csv --connection-id ID --request-id ID --out FILE | submissions-import FILE | submissions-export --project-id ID [--out FILE] | reconcile-submissions --project-id ID [--format json|csv] [--out FILE]';
|
|
13
14
|
function validate(row){
|
|
14
15
|
if(row?.v!==1||! /^[\w-]{1,128}$/.test(row.project_id??'')||! /^[\w-]{1,128}$/.test(row.watch_id??''))fail('Invalid local index receipt scope');
|
|
15
16
|
const receipt=indexReceiptResponse.parse(row.receipt);
|
|
@@ -61,6 +62,19 @@ function csvInput(text){
|
|
|
61
62
|
}
|
|
62
63
|
export async function indexMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
|
|
63
64
|
const command=args._[1],config=await loadConfig({cwd}),path=config.paths.indexObservations;
|
|
65
|
+
if(['submissions-import','submissions-export','reconcile-submissions'].includes(command)){
|
|
66
|
+
const allowed=new Set(['_','tag','out','project-id','format']);
|
|
67
|
+
if(args.tag?.length||Object.keys(args).some(key=>!allowed.has(key))||args._.length!==(command==='submissions-import'?3:2))fail(INDEX_CLI_HELP);
|
|
68
|
+
if(command==='submissions-import'){
|
|
69
|
+
if(args.out||args['project-id']||args.format)fail(INDEX_CLI_HELP);
|
|
70
|
+
out(JSON.stringify(await saveSubmissions(config.paths.indexSubmissions,JSON.parse(await boundedSubmissionText(resolve(cwd,args._[2]))))));return 0;
|
|
71
|
+
}
|
|
72
|
+
if(args.format&&!['json','csv'].includes(args.format))fail('Submission format must be json or csv');
|
|
73
|
+
if(command==='submissions-export'&&args.format)fail(INDEX_CLI_HELP);
|
|
74
|
+
const report=reconcileSubmissions(await readSubmissions(config.paths.indexSubmissions),command==='reconcile-submissions'?await readIndexReceipts(path):[],args['project-id']);
|
|
75
|
+
const text=command==='submissions-export'?JSON.stringify(report.submissions.map(({outcome,observations,verification,...row})=>row),null,2):args.format==='csv'?submissionCsv(report):JSON.stringify(report,null,2);
|
|
76
|
+
if(args.out){if(typeof args.out!=='string')fail('--out requires a file');await atomicOutput(resolve(cwd,args.out),text);}else out(text);return 0;
|
|
77
|
+
}
|
|
64
78
|
const allowed=new Set(['_','tag','out','watch-id','limit','before','connection-id','request-id']);if(args.tag?.length||Object.keys(args).some(key=>!allowed.has(key)))fail(INDEX_CLI_HELP);
|
|
65
79
|
if(command==='import'){
|
|
66
80
|
if(!args._[2])fail(INDEX_CLI_HELP);const parsed=JSON.parse(await boundedText(resolve(cwd,args._[2])));if(!Array.isArray(parsed)||parsed.length>100)fail('Import requires an array of at most 100 scoped index receipts');out(JSON.stringify(await saveIndexReceipts(path,parsed)));return 0;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {readFile,writeFile,open,rename,unlink,mkdir,stat} from 'node:fs/promises';
|
|
2
|
+
import {dirname} from 'node:path';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {ConfigError} from './config.js';
|
|
5
|
+
import {indexEvidenceReference} from '../src/evidence-reference.js';
|
|
6
|
+
const fail=message=>{throw new ConfigError(message);};
|
|
7
|
+
const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
|
+
const id=value=>typeof value==='string'&&/^[\w-]{1,128}$/.test(value);
|
|
9
|
+
const fields=['v','id','project_id','url','channel','endpoint','submitted_at','http_status','evidence_sha256'];
|
|
10
|
+
export const SUBMISSION_NOTE='Imported submission records describe reported HTTP responses. Hashes check local integrity, not provider authenticity. Later Google observations do not verify indexing by an IndexNow participant or show that submission caused indexing.';
|
|
11
|
+
export function validateSubmission(row){
|
|
12
|
+
if(!row||Object.keys(row).some(k=>!fields.includes(k))||fields.some(k=>!Object.hasOwn(row,k))||row.v!==1||!id(row.id)||!id(row.project_id)||row.channel!=='indexnow'||row.endpoint!=='https://api.indexnow.org/indexnow')fail('Invalid IndexNow submission receipt');
|
|
13
|
+
let url;try{url=new URL(row.url);}catch{fail('Invalid submission URL');}
|
|
14
|
+
if(typeof row.url!=='string'||url.href!==row.url||!['https:','http:'].includes(url.protocol)||url.username||url.password||url.hash)fail('Submission URL must be exact HTTP(S) without credentials or fragment');
|
|
15
|
+
if(typeof row.submitted_at!=='string'||!Number.isFinite(Date.parse(row.submitted_at))||new Date(row.submitted_at).toISOString()!==row.submitted_at)fail('Submission date must be an ISO UTC timestamp');
|
|
16
|
+
if(row.http_status!==null&&(!Number.isInteger(row.http_status)||row.http_status<100||row.http_status>599))fail('Invalid submission HTTP status');
|
|
17
|
+
const body=Object.fromEntries(fields.filter(k=>k!=='evidence_sha256').map(k=>[k,row[k]]));
|
|
18
|
+
if(row.evidence_sha256!==digest(body))fail('Submission receipt hash mismatch');
|
|
19
|
+
return {...body,evidence_sha256:row.evidence_sha256};
|
|
20
|
+
}
|
|
21
|
+
export async function boundedSubmissionText(path){const info=await stat(path);if(info.size>32*1024*1024)fail('Submission input exceeds 32 MiB');return readFile(path,'utf8');}
|
|
22
|
+
export async function readSubmissions(path){try{return (await boundedSubmissionText(path)).split('\n').filter(line=>line.trim()).map(line=>validateSubmission(JSON.parse(line)));}catch(error){if(error.code==='ENOENT')return [];throw error;}}
|
|
23
|
+
export async function saveSubmissions(path,rows){
|
|
24
|
+
if(!Array.isArray(rows)||rows.length>100)fail('Import requires at most 100 submission receipts');
|
|
25
|
+
const checked=rows.map(validateSubmission);await mkdir(dirname(path),{recursive:true});let lock;
|
|
26
|
+
try{lock=await open(`${path}.lock`,'wx');}catch(error){if(error.code==='EEXIST')fail('Submission receipt writer already locked');throw error;}
|
|
27
|
+
const tmp=`${path}.${crypto.randomUUID()}.tmp`;
|
|
28
|
+
try{
|
|
29
|
+
const current=await readSubmissions(path),byId=new Map(current.map(r=>[`${r.project_id}:${r.id}`,r]));
|
|
30
|
+
for(const row of checked){const key=`${row.project_id}:${row.id}`,old=byId.get(key);if(old&&JSON.stringify(old)!==JSON.stringify(row))fail('Submission receipt identity conflict');byId.set(key,row);}
|
|
31
|
+
const text=[...byId.values()].sort((a,b)=>a.project_id.localeCompare(b.project_id)||a.submitted_at.localeCompare(b.submitted_at)||a.id.localeCompare(b.id)).map(r=>JSON.stringify(r)).join('\n')+'\n';
|
|
32
|
+
if(Buffer.byteLength(text)>32*1024*1024)fail('Submission receipts exceed 32 MiB; archive before importing more');
|
|
33
|
+
await writeFile(tmp,text,{flag:'wx'});await rename(tmp,path);return {imported:checked.length,total:byId.size};
|
|
34
|
+
}finally{await unlink(tmp).catch(()=>{});await lock.close();await unlink(`${path}.lock`);}
|
|
35
|
+
}
|
|
36
|
+
export function reconcileSubmissions(submissions,observations,projectId){
|
|
37
|
+
if(!id(projectId))fail('A valid --project-id is required');
|
|
38
|
+
return {v:1,project_id:projectId,note:SUBMISSION_NOTE,submissions:submissions.filter(r=>r.project_id===projectId).map(validateSubmission).map(row=>{
|
|
39
|
+
const subsequent=observations.filter(r=>r.project_id===projectId&&r.receipt.observation.url===row.url&&Date.parse(r.receipt.observation.checked_at)>Date.parse(row.submitted_at)).map(r=>{
|
|
40
|
+
const o=r.receipt.observation;return {watch_id:r.watch_id,observation_id:o.id,checked_at:o.checked_at,backend:o.backend,source_key:o.source_key,tier:o.tier,reason:o.reason,confidence:o.confidence,evidence_sha256:o.evidenceSha256,evidence_available:r.receipt.evidenceAvailable,evidence_reference:indexEvidenceReference({receiptId:o.id}),relationship:'observation_after_submission',participant_indexing_verified:false,causation_established:false};
|
|
41
|
+
}).sort((a,b)=>a.checked_at.localeCompare(b.checked_at)||a.observation_id.localeCompare(b.observation_id));
|
|
42
|
+
return {...row,outcome:row.http_status===200?'received':row.http_status===202?'key_validation_pending':row.http_status===null?'unknown':'failed',observations:subsequent,verification:'not_established'};
|
|
43
|
+
})};
|
|
44
|
+
}
|
|
45
|
+
const cell=value=>{const raw=String(value??''),safe=/^[\s]*[=+\-@]|^[\t\r]/.test(raw)?`'${raw}`:raw;return `"${safe.replaceAll('"','""')}"`;};
|
|
46
|
+
export function submissionCsv(report){
|
|
47
|
+
const rows=[['Project','Submission ID','URL','Submitted At','HTTP Status','Submission Outcome','Submission Hash','Observation ID','Watch ID','Backend','Source','Check Date','Verdict','Reason','Confidence','Evidence Available','Observation Hash','Evidence Reference','Participant Indexing Verified','Causation Established','Reading Note']];
|
|
48
|
+
for(const s of report.submissions)for(const o of s.observations.length?s.observations:[null])rows.push([report.project_id,s.id,s.url,s.submitted_at,s.http_status,s.outcome,s.evidence_sha256,o?.observation_id,o?.watch_id,o?.backend,o?.source_key,o?.checked_at,o?.tier??'unknown',o?.reason??'no_later_observation',o?.confidence??'unknown',o?.evidence_available??false,o?.evidence_sha256,o?.evidence_reference,false,false,report.note]);
|
|
49
|
+
return rows.map(row=>row.map(cell).join(',')).join('\r\n')+'\r\n';
|
|
50
|
+
}
|
package/cli/main.js
CHANGED
|
@@ -56,6 +56,8 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
|
|
|
56
56
|
agentlinkops admission list|add|update|remove|evaluate|reevaluate|receipt --project ID
|
|
57
57
|
agentlinkops disavow list|history|propose|import|export|approve|reject|delete
|
|
58
58
|
agentlinkops index import FILE | export [--out FILE] | csv URLS.csv [--out FILE]
|
|
59
|
+
agentlinkops index submissions-import FILE | submissions-export --project-id ID [--out FILE]
|
|
60
|
+
agentlinkops index reconcile-submissions --project-id ID [--format json|csv] [--out FILE]
|
|
59
61
|
agentlinkops index check-csv URLS.csv --connection-id ID --request-id ID --out FILE
|
|
60
62
|
agentlinkops import FILE --target DOMAIN [--from SUPPLIER] [--map source=COL,target=COL]
|
|
61
63
|
[--exact-url] [--no-subdomains] [--generated-at ISO] [--json]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlinkops",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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",
|
|
@@ -14,6 +14,8 @@ Use `--competitors competitors.json` to add domains to check beside your own. Th
|
|
|
14
14
|
|
|
15
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.
|
|
16
16
|
|
|
17
|
+
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
|
+
|
|
17
19
|
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.
|
|
18
20
|
|
|
19
21
|
After reviewing the draft, run the [local CLI workflow](cli.md) and inspect the retained evidence:
|
|
@@ -14,6 +14,7 @@ import { homedir } from 'node:os';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
16
|
import { EngineError } from '../adapter.js';
|
|
17
|
+
import {createProxyAdmission,proxyFailureReason} from './proxy-admission.js';
|
|
17
18
|
|
|
18
19
|
const MODULE_LOADERS = {
|
|
19
20
|
'playwright-core': () => import('playwright-core'),
|
|
@@ -56,20 +57,6 @@ const ENGINE_NOMINAL_COST = Object.freeze({
|
|
|
56
57
|
});
|
|
57
58
|
const DEFAULT_NOMINAL_COST = 0.03;
|
|
58
59
|
|
|
59
|
-
function parseProxyUrl(raw) {
|
|
60
|
-
if (!raw) return null;
|
|
61
|
-
try {
|
|
62
|
-
const url = new URL(raw);
|
|
63
|
-
return {
|
|
64
|
-
server: `${url.protocol}//${url.hostname}:${url.port}`,
|
|
65
|
-
username: decodeURIComponent(url.username),
|
|
66
|
-
password: decodeURIComponent(url.password),
|
|
67
|
-
};
|
|
68
|
-
} catch {
|
|
69
|
-
throw new EngineError(`AGENTLINKOPS_PROXY_URL is not a valid proxy URL (length ${raw.length}, value never printed)`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
60
|
export function createBrowserEngine({ engineName, env = process.env, onEvent = () => {}, runtime = {} } = {}) {
|
|
74
61
|
if (!BROWSER_PROVIDERS.includes(engineName)) {
|
|
75
62
|
throw new EngineError(`unknown browser engine: ${engineName} (known: ${BROWSER_PROVIDERS.join(', ')})`);
|
|
@@ -78,13 +65,21 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
78
65
|
const sessionDir = runtime.sessionDir ?? join(homedir(), '.agentlinkops', 'citations', 'sessions');
|
|
79
66
|
const load = runtime.loadModule ?? lazy;
|
|
80
67
|
const platform = runtime.platform ?? process.platform;
|
|
68
|
+
const event=async text=>{
|
|
69
|
+
let timer;
|
|
70
|
+
try{await Promise.race([Promise.resolve().then(()=>onEvent(text)),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error()),runtime.supplierTimeoutMs??5000);})]);}
|
|
71
|
+
catch{throw new EngineError('Browser event reporting failed.');}
|
|
72
|
+
finally{clearTimeout(timer);}
|
|
73
|
+
};
|
|
81
74
|
|
|
75
|
+
const admission=createProxyAdmission({engine:engineName,env,supplier:runtime.proxySupplier,now:runtime.now,onReceipt:runtime.onEgressReceipt,timeoutMs:runtime.supplierTimeoutMs});
|
|
76
|
+
let running=false,contextCleanupFailed=false;
|
|
82
77
|
let browserHandle = null; // { browser, cleanup } for the whole epoch
|
|
83
78
|
let displayHandle = null;
|
|
84
79
|
let providerConfig = null;
|
|
85
80
|
async function closeQuietly(action) {
|
|
86
81
|
let timer;
|
|
87
|
-
try { await Promise.race([Promise.resolve().then(action).
|
|
82
|
+
try { return await Promise.race([Promise.resolve().then(action).then(() => true, () => false), new Promise(resolve => { timer = setTimeout(() => resolve(false), runtime.cleanupTimeoutMs ?? 5000); })]); }
|
|
88
83
|
finally { clearTimeout(timer); }
|
|
89
84
|
}
|
|
90
85
|
|
|
@@ -109,11 +104,11 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
109
104
|
// engines that block headless render headfully inside a virtual display.
|
|
110
105
|
if (platform === 'linux' && !display.detectDisplay()) {
|
|
111
106
|
displayHandle = await display.ensureDisplay({ allowExistingDisplay: false });
|
|
112
|
-
|
|
107
|
+
await event(`display: ${displayHandle.display}`);
|
|
113
108
|
}
|
|
114
109
|
|
|
115
|
-
const proxy =
|
|
116
|
-
|
|
110
|
+
const proxy = admission.proxy;
|
|
111
|
+
await event(proxy?'egress: proxy-required':'egress: direct-diagnostic');
|
|
117
112
|
|
|
118
113
|
const options = await resolveCamoufoxLaunchOptions({
|
|
119
114
|
provider: engineName,
|
|
@@ -127,14 +122,17 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
127
122
|
});
|
|
128
123
|
const launchOpts = { ...options, executablePath: options.executablePath };
|
|
129
124
|
if (proxy) launchOpts.proxy = proxy;
|
|
125
|
+
admission.assertReady();
|
|
130
126
|
const browser = await fw.launch(launchOpts);
|
|
131
127
|
|
|
132
128
|
browserHandle = {
|
|
133
129
|
browser,
|
|
134
130
|
cleanup: async () => {
|
|
135
|
-
await closeQuietly(() => browser.close());
|
|
136
|
-
await displayHandle?.cleanup?.()
|
|
131
|
+
const browserClosed=await closeQuietly(() => browser.close());
|
|
132
|
+
const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
|
|
137
133
|
displayHandle = null;
|
|
134
|
+
if(browserClosed)contextCleanupFailed=false;
|
|
135
|
+
return browserClosed&&displayClosed;
|
|
138
136
|
},
|
|
139
137
|
};
|
|
140
138
|
return browserHandle;
|
|
@@ -193,13 +191,14 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
193
191
|
return null;
|
|
194
192
|
}
|
|
195
193
|
|
|
196
|
-
|
|
194
|
+
const engine = {
|
|
197
195
|
identity,
|
|
198
196
|
estimateCostUsd: () => ENGINE_NOMINAL_COST[engineName] ?? DEFAULT_NOMINAL_COST,
|
|
199
197
|
|
|
200
198
|
async run({ prompt }) {
|
|
201
199
|
const config = await ensureProviderConfig();
|
|
202
200
|
const { browser } = await ensureBrowser();
|
|
201
|
+
admission.assertReady();
|
|
203
202
|
const storageState = await loadStorageState();
|
|
204
203
|
const context = await browser.newContext({
|
|
205
204
|
storageState,
|
|
@@ -318,9 +317,9 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
318
317
|
: `${engineName}: answer extracted empty`;
|
|
319
318
|
throw new EngineError(reason, { retriable: true }); }
|
|
320
319
|
let extractionFailed = config.citationExtraction === 'unsupported';
|
|
321
|
-
const sources = await config.extractSources(page).catch(() => {
|
|
320
|
+
const sources = await config.extractSources(page).catch(async () => {
|
|
322
321
|
extractionFailed = true;
|
|
323
|
-
|
|
322
|
+
await event('sources extraction failed; observation retained as unknown');
|
|
324
323
|
return [];
|
|
325
324
|
});
|
|
326
325
|
const screenshotPng = await page.screenshot({ fullPage: false }).catch(() => null);
|
|
@@ -347,16 +346,64 @@ export function createBrowserEngine({ engineName, env = process.env, onEvent = (
|
|
|
347
346
|
error.unknown = true;
|
|
348
347
|
throw error;
|
|
349
348
|
} finally {
|
|
350
|
-
await closeQuietly(() => context.close());
|
|
349
|
+
if(!await closeQuietly(() => context.close()))contextCleanupFailed=true;
|
|
351
350
|
}
|
|
352
351
|
},
|
|
353
352
|
|
|
354
353
|
// Called by the runner at epoch end.
|
|
355
354
|
async close() {
|
|
356
|
-
|
|
357
|
-
await
|
|
358
|
-
|
|
359
|
-
|
|
355
|
+
let confirmed=true;
|
|
356
|
+
try{if(browserHandle)confirmed=await browserHandle.cleanup();}
|
|
357
|
+
finally{
|
|
358
|
+
const displayClosed=await closeQuietly(() => displayHandle?.cleanup?.());
|
|
359
|
+
confirmed=confirmed&&displayClosed;displayHandle=null;browserHandle=null;
|
|
360
|
+
}
|
|
361
|
+
return confirmed;
|
|
360
362
|
},
|
|
361
363
|
};
|
|
364
|
+
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']);
|
|
366
|
+
const safeError=cause=>{
|
|
367
|
+
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});
|
|
369
|
+
if(code==='BROWSER_RUN_FAILED'||cause?.unknown===true)error.unknown=true;
|
|
370
|
+
return error;
|
|
371
|
+
};
|
|
372
|
+
async function retire(phase,reason=null){
|
|
373
|
+
const hadResources=Boolean(browserHandle||displayHandle||admission.proxy);let error,confirmed=false;
|
|
374
|
+
try{
|
|
375
|
+
try{confirmed=await closeBrowser();}catch{confirmed=false;}
|
|
376
|
+
if(!confirmed)reason='cleanup_unconfirmed';
|
|
377
|
+
try{if(hadResources)await admission.record({outcome:confirmed?'closed':'cleanup_unconfirmed',phase,reason});}catch(cause){error=cause;}
|
|
378
|
+
try{if(reason)await admission.quarantine(reason);}catch(cause){error??=cause;}
|
|
379
|
+
if(!confirmed)error??=Object.assign(new EngineError('Browser cleanup could not be confirmed.'),{code:'BROWSER_CLEANUP_UNCONFIRMED'});
|
|
380
|
+
}finally{try{await admission.release();}catch(cause){error??=cause;}}
|
|
381
|
+
if(error)throw safeError(error);
|
|
382
|
+
}
|
|
383
|
+
engine.run=async input=>{
|
|
384
|
+
if(running)throw Object.assign(new EngineError('A browser sample is already running.'),{code:'BROWSER_RUN_IN_PROGRESS'});
|
|
385
|
+
running=true;let result,error,reason=null,admitted=false;
|
|
386
|
+
try{
|
|
387
|
+
if(admission.expired)await retire('lease_expiry');
|
|
388
|
+
await admission.begin();admitted=true;
|
|
389
|
+
result=await sample(input);
|
|
390
|
+
if(contextCleanupFailed)throw Object.assign(new EngineError('Browser cleanup could not be confirmed.'),{code:'BROWSER_CLEANUP_UNCONFIRMED'});
|
|
391
|
+
}catch(cause){reason=proxyFailureReason(cause);error=safeError(cause);}
|
|
392
|
+
try{
|
|
393
|
+
if(admitted){
|
|
394
|
+
try{const receipt=await admission.record({outcome:error?'failed':result?.unknown?'unknown':'observed',reason});if(result)result.egressReceipt=receipt;}
|
|
395
|
+
catch(cause){error=safeError(cause);}
|
|
396
|
+
if(error){try{await retire('failure_cleanup',reason);}catch(cause){error=safeError(cause);}}
|
|
397
|
+
}
|
|
398
|
+
}finally{running=false;}
|
|
399
|
+
if(error)throw error;
|
|
400
|
+
return result;
|
|
401
|
+
};
|
|
402
|
+
engine.close=async()=>{
|
|
403
|
+
if(running)throw Object.assign(new EngineError('A browser sample is already running.'),{code:'BROWSER_RUN_IN_PROGRESS'});
|
|
404
|
+
running=true;
|
|
405
|
+
try{await retire('cleanup');}finally{running=false;}
|
|
406
|
+
};
|
|
407
|
+
engine.getEgressReceipts=admission.receipts;
|
|
408
|
+
return engine;
|
|
362
409
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Local proxy custody. Supplier callbacks perform no work until browser admission.
|
|
2
|
+
// Meter values are supplier-observed cumulative counters, never nominal estimates.
|
|
3
|
+
import {createHash,randomUUID} from 'node:crypto';
|
|
4
|
+
import {EngineError} from '../adapter.js';
|
|
5
|
+
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
6
|
+
const quarantines=new Map();
|
|
7
|
+
const safeInteger=value=>Number.isSafeInteger(value)&&value>=0;
|
|
8
|
+
const failure=(code,message)=>Object.assign(new EngineError(message),{code});
|
|
9
|
+
export function parseProxyUrl(raw){
|
|
10
|
+
try{
|
|
11
|
+
if(typeof raw!=='string'||raw.length>8192)throw Error();
|
|
12
|
+
const url=new URL(raw);
|
|
13
|
+
if(!['http:','https:','socks5:'].includes(url.protocol)||!url.hostname||url.search||url.hash||(url.pathname&&url.pathname!=='/')||(url.protocol==='socks5:'&&!url.port))throw Error();
|
|
14
|
+
const username=decodeURIComponent(url.username),password=decodeURIComponent(url.password);
|
|
15
|
+
if(/[\r\n\0]/.test(username+password))throw Error();
|
|
16
|
+
return {server:`${url.protocol}//${url.hostname}${url.port?':'+url.port:''}`,...(username?{username}:{}),...(password?{password}:{})};
|
|
17
|
+
}catch{throw failure('PROXY_CONFIGURATION_INVALID','The browser proxy configuration is invalid.');}
|
|
18
|
+
}
|
|
19
|
+
export function createUrlProxySupplier({url,now=()=>Date.now()}={}){
|
|
20
|
+
const key=typeof url==='string'?digest(url):null;
|
|
21
|
+
return {
|
|
22
|
+
async acquire({engine}){
|
|
23
|
+
if(!url)throw failure('PROXY_REQUIRED','Configure a proxy before browser measurement, or explicitly select direct-diagnostic egress.');
|
|
24
|
+
parseProxyUrl(url);
|
|
25
|
+
if((quarantines.get(engine+key)??0)>now())throw failure('PROXY_QUARANTINED','The browser proxy is quarantined.');
|
|
26
|
+
return {id:randomUUID(),proxyUrl:url,expiresAt:new Date(now()+10*60*1000).toISOString()};
|
|
27
|
+
},
|
|
28
|
+
async quarantine({engine,reason}){quarantines.set(engine+key,reason==='cleanup_unconfirmed'?Infinity:now()+10*60*1000);},
|
|
29
|
+
async release(){},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Only explicit structured signals identify a block. Empty answers/selector errors
|
|
33
|
+
// do not establish a proxy problem, and arbitrary exception text is never retained.
|
|
34
|
+
export function proxyFailureReason(error){
|
|
35
|
+
for(const value of [error,error?.cause]){
|
|
36
|
+
if(value?.status===403||value?.failureType==='bot_detection')return 'blocked';
|
|
37
|
+
if(value?.status===429||value?.failureType==='rate_limited')return 'rate_limited';
|
|
38
|
+
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';
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export function createProxyAdmission({engine,env={},supplier,now=()=>Date.now(),onReceipt=()=>{},timeoutMs=5000}={}){
|
|
43
|
+
const mode=env.AGENTLINKOPS_BROWSER_EGRESS??'proxy-required';
|
|
44
|
+
if(!['proxy-required','direct-diagnostic'].includes(mode))throw failure('PROXY_POLICY_INVALID','Choose proxy-required or direct-diagnostic browser egress.');
|
|
45
|
+
if(mode==='direct-diagnostic'&&(supplier||env.AGENTLINKOPS_PROXY_URL))throw failure('PROXY_POLICY_CONFLICT','Direct diagnostics cannot also configure a proxy.');
|
|
46
|
+
const injected=Boolean(supplier);
|
|
47
|
+
supplier??=createUrlProxySupplier({url:env.AGENTLINKOPS_PROXY_URL,now});
|
|
48
|
+
let lease=null,baseline=null,poisoned=false;const receipts=[];
|
|
49
|
+
async function bounded(action,code){
|
|
50
|
+
let timer;
|
|
51
|
+
try{return await Promise.race([Promise.resolve().then(action),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error()),timeoutMs);})]);}
|
|
52
|
+
catch{throw failure(code,'Browser proxy custody failed. No direct fallback was attempted.');}
|
|
53
|
+
finally{clearTimeout(timer);}
|
|
54
|
+
}
|
|
55
|
+
async function meter(){
|
|
56
|
+
if(!lease||typeof supplier.readMeter!=='function')return null;
|
|
57
|
+
try{
|
|
58
|
+
const value=await bounded(()=>supplier.readMeter({engine,leaseId:lease.id}),'PROXY_METER_UNAVAILABLE');
|
|
59
|
+
if(!value||!safeInteger(value.bytes)||!(value.costMicrousd===null||value.costMicrousd===undefined||safeInteger(value.costMicrousd)))return null;
|
|
60
|
+
return {bytes:value.bytes,costMicrousd:value.costMicrousd??null};
|
|
61
|
+
}catch{return null;}
|
|
62
|
+
}
|
|
63
|
+
async function release(){
|
|
64
|
+
if(!lease)return;
|
|
65
|
+
const previous=lease;lease=null;baseline=null;
|
|
66
|
+
try{await bounded(()=>supplier.release({engine,leaseId:previous.id,disposition:poisoned?'quarantined':'clean'}),'PROXY_RELEASE_FAILED');}
|
|
67
|
+
catch(error){poisoned=true;throw error;}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
get proxy(){return lease?.proxy;},
|
|
71
|
+
get expired(){return lease&&Date.parse(lease.expiresAt)<=now();},
|
|
72
|
+
assertReady(){
|
|
73
|
+
if(poisoned)throw failure('PROXY_CUSTODY_UNAVAILABLE','Browser proxy custody needs operator review.');
|
|
74
|
+
if(mode==='proxy-required'&&(!lease||Date.parse(lease.expiresAt)<=now()))throw failure('PROXY_LEASE_EXPIRED','The browser proxy lease has expired.');
|
|
75
|
+
},
|
|
76
|
+
async begin(){
|
|
77
|
+
if(poisoned)throw failure('PROXY_CUSTODY_UNAVAILABLE','Browser proxy custody needs operator review.');
|
|
78
|
+
if(mode==='direct-diagnostic')return;
|
|
79
|
+
if(!injected&&!env.AGENTLINKOPS_PROXY_URL)throw failure('PROXY_REQUIRED','Configure a proxy before browser measurement, or explicitly select direct-diagnostic egress.');
|
|
80
|
+
if(lease){if(Date.parse(lease.expiresAt)<=now())throw failure('PROXY_LEASE_EXPIRED','The browser proxy lease has expired.');return;}
|
|
81
|
+
if(!['acquire','release','quarantine'].every(name=>typeof supplier?.[name]==='function'))throw failure('PROXY_SUPPLIER_INVALID','The browser proxy supplier is incomplete.');
|
|
82
|
+
let acquired;
|
|
83
|
+
const acquiring=Promise.resolve().then(()=>supplier.acquire({engine}));
|
|
84
|
+
try{acquired=await bounded(()=>acquiring,'PROXY_ACQUIRE_FAILED');}
|
|
85
|
+
catch(error){
|
|
86
|
+
poisoned=true;
|
|
87
|
+
// A timed-out acquisition can resolve later; retire that lease without launching.
|
|
88
|
+
acquiring.then(value=>{if(typeof value?.id==='string')return bounded(()=>supplier.release({engine,leaseId:value.id}),'PROXY_RELEASE_FAILED');}).catch(()=>{});
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
try{
|
|
92
|
+
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();
|
|
93
|
+
lease={id:acquired.id,expiresAt:acquired.expiresAt,proxy:parseProxyUrl(acquired.proxyUrl)};
|
|
94
|
+
}catch{
|
|
95
|
+
poisoned=true;
|
|
96
|
+
if(acquired&&typeof acquired.id==='string')await bounded(()=>supplier.release({engine,leaseId:acquired.id}),'PROXY_RELEASE_FAILED').catch(()=>{});
|
|
97
|
+
throw failure('PROXY_LEASE_INVALID','The browser proxy lease is invalid.');
|
|
98
|
+
}
|
|
99
|
+
baseline=await meter();
|
|
100
|
+
if(Date.parse(lease.expiresAt)<=now()){poisoned=true;await release();throw failure('PROXY_LEASE_EXPIRED','The browser proxy lease has expired.');}
|
|
101
|
+
},
|
|
102
|
+
async record({outcome,reason=null,phase='sample'}){
|
|
103
|
+
const current=await meter(),valid=baseline&¤t&¤t.bytes>=baseline.bytes;
|
|
104
|
+
const bytes=valid?current.bytes-baseline.bytes:null;
|
|
105
|
+
const costMicrousd=valid&&baseline.costMicrousd!==null&¤t.costMicrousd!==null&¤t.costMicrousd>=baseline.costMicrousd?current.costMicrousd-baseline.costMicrousd:null;
|
|
106
|
+
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});
|
|
107
|
+
baseline=current;receipts.push(row);
|
|
108
|
+
try{await bounded(()=>onReceipt({...row}),'PROXY_RECEIPT_FAILED');}catch(error){poisoned=true;throw error;}
|
|
109
|
+
return {...row};
|
|
110
|
+
},
|
|
111
|
+
async quarantine(reason){
|
|
112
|
+
// Stop this engine instance even if an external supplier cannot quarantine.
|
|
113
|
+
poisoned=true;
|
|
114
|
+
if(!lease)return;
|
|
115
|
+
await bounded(()=>supplier.quarantine({engine,leaseId:lease.id,reason}),'PROXY_QUARANTINE_FAILED');
|
|
116
|
+
},
|
|
117
|
+
release,
|
|
118
|
+
receipts:()=>receipts.map(row=>({...row})),
|
|
119
|
+
};
|
|
120
|
+
}
|