@getopencrawl/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,313 +0,0 @@
1
- /** Private local browser observations. No browser state is sent to OpenCrawl. */
2
- import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
- import crypto from 'node:crypto';
5
- import { createInterface } from 'node:readline/promises';
6
- import { configDir } from './client.mjs';
7
- import { isReadOnlyRequest } from '../browser.mjs';
8
- import { displayLine } from './display.mjs';
9
- import { allowedOptions } from './jobs.mjs';
10
- import {LETTER_ADAPTER,isLetterPolicy,LetterReadAttempt} from './product-letter-read.mjs';
11
- import {LETTER_CAPABILITIES_ADAPTER,isLetterCapabilitiesPolicy,letterPickerReady,extractLetterCapabilities} from './product-letter-capabilities.mjs';
12
-
13
- const API='/api/v1/product-monitors';
14
- const UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
- const HASH=/^[0-9a-f]{64}$/;
16
- const sha=value=>crypto.createHash('sha256').update(value).digest('hex');
17
- const same=(a,b)=>canonical(a)===canonical(b);
18
- export function canonical(value) {return JSON.stringify(value,(_,v)=>v&&typeof v==='object'&&!Array.isArray(v)?Object.fromEntries(Object.keys(v).sort().map(k=>[k,v[k]])):v);}
19
- export class MonitorFailure extends Error {constructor(code){super(code);this.code=code;}}
20
- const fail=code=>{throw new MonitorFailure(code);};
21
- const id=(value,prefix)=>{if(typeof value!=='string'||!new RegExp(`^${prefix}_[a-f0-9]{32}$`).test(value))fail('invalid_id');return value;};
22
- const monitorId=value=>{if(typeof value!=='string'||!/^src_[a-f0-9]{32}$/.test(value))fail('invalid_id');return value;};
23
- const uuid=value=>{if(typeof value!=='string'||!UUID.test(value))fail('invalid_capture_id');return value;};
24
- const count=(value,min,max)=>Number.isSafeInteger(value)&&value>=min&&value<=max;
25
- export function exactUrl(value) {
26
- if(typeof value!=='string'||value.length>2048||/[\x00-\x20\x7f\\]/.test(value)||/%0[0-9a-f]|%1[0-9a-f]|%7f/i.test(value))fail('invalid_url');
27
- let u;try{u=new URL(value);}catch{fail('invalid_url');}
28
- if(u.protocol!=='https:'||u.username||u.password||u.href!==value)fail('invalid_url');
29
- let pathname;try{pathname=decodeURIComponent(u.pathname);}catch{fail('invalid_url');}
30
- if(/(?:^|\/)(?:logout|log-out|signout|sign-out|delete|remove|destroy|unsubscribe|deactivate|terminate)(?:\/|$)/i.test(pathname)||[...u.searchParams.keys()].some(k=>/^(action|delete|remove|logout|unsubscribe)$/i.test(k)))fail('action_url');
31
- return u;
32
- }
33
- export function readMonitor(m,expected) {
34
- if(!m||m.version!==1||monitorId(m.id)!==m.id||expected&&m.id!==expected||m.visibility!=='private'||!count(m.generation,1,Number.MAX_SAFE_INTEGER)||!['active','paused','revoked'].includes(m.status)||!Array.isArray(m.pages)||!count(m.pages.length,1,5)||!HASH.test(m.policy_sha256)||typeof m.account_label!=='string')fail('invalid_monitor');
35
- const p=m.policy,v=p?.viewport;
36
- if(sha(canonical(p))!==m.policy_sha256)fail('invalid_policy');
37
- if(!(p?.version===1&&!Object.hasOwn(p,'request_adapter')||p?.version===2&&p.request_adapter===LETTER_ADAPTER||p?.version===3&&p.request_adapter===LETTER_CAPABILITIES_ADAPTER)||p.extractor!=='product-screen-v1'||p.screenshot_mode!=='viewport'||!count(v?.width,320,1920)||!count(v?.height,240,1080)||v.device_scale_factor!==1||!['light','dark'].includes(p.color_scheme)||typeof p.locale!=='string'||typeof p.timezone_id!=='string'||![0,60,360,1440].includes(m.cadence_minutes))fail('invalid_policy');
38
- const selector=s=>typeof s==='string'&&s.length>0&&s.length<=400&&!/[\x00-\x1f\x7f]/.test(s);
39
- for(const rows of [p.masks,p.noise_regions])if(!Array.isArray(rows)||rows.length>16||rows.some(r=>!selector(r?.selector)||typeof r.label!=='string'))fail('invalid_policy');
40
- const seen=new Set();for(const page of m.pages){id(page.id,'ppg');if(seen.has(page.id))fail('invalid_monitor');seen.add(page.id);if(exactUrl(page.url).origin!==m.origin||!selector(page.ready_selector)||!selector(page.scope_selector)||page.login_selector!==null&&!selector(page.login_selector)||page.account_marker!==null&&(!selector(page.account_marker?.selector)||page.account_marker.required!==true))fail('invalid_monitor');}
41
- if(p.version>=2&&(m.pages.length!==1||!(isLetterPolicy(p,m.pages[0])||isLetterCapabilitiesPolicy(p,m.pages[0]))))fail('invalid_policy');
42
- return m;
43
- }
44
- async function request(client,route,options={}) {return client.request(route,{timeout:30000,maxResponseBytes:1024*1024,...options});}
45
- async function identity(client,signal) {
46
- const me=await request(client,'/api/auth/me',{signal});
47
- if(!/^usr_[0-9a-f]{32}$/.test(me?.user?.id)||!/^ws_[0-9a-f]{32}$/.test(me?.workspace?.id))fail('identity_unavailable');
48
- if(client.profile&&(client.profile.userId!==me.user.id||client.profile.workspaceId!==me.workspace.id))fail('identity_changed');
49
- return {server:client.baseUrl,user_id:me.user.id,workspace_id:me.workspace.id};
50
- }
51
- async function current(client,monitor,scope,signal) {
52
- const who=await identity(client,signal);if(scope&&!same(who,scope))fail('identity_changed');
53
- const m=readMonitor(await request(client,`${API}/${monitor.id}`,{signal}),monitor.id);
54
- if(m.generation!==monitor.generation||m.policy_sha256!==monitor.policy_sha256||m.status!=='active')fail('monitor_changed');return m;
55
- }
56
- async function privateDir(directory){await fs.mkdir(directory,{recursive:true,mode:0o700});if((await fs.lstat(directory)).isSymbolicLink())fail('unsafe_local_path');await fs.chmod(directory,0o700);}
57
- async function write(file,value,beforeCommit=()=>{}){const temp=file+'.'+crypto.randomUUID()+'.tmp';try{const h=await fs.open(temp,'wx',0o600);try{await h.writeFile(JSON.stringify(value)+'\n');await h.sync();}finally{await h.close();}beforeCommit();await fs.rename(temp,file);if(process.platform!=='win32'){const d=await fs.open(path.dirname(file),'r');try{await d.sync();}finally{await d.close();}}}finally{await fs.unlink(temp).catch(e=>{if(e.code!=='ENOENT')throw e;});}}
58
- async function read(file,max=4*1024*1024){try{const h=await fs.open(file,'r');try{const s=await h.stat();if(!s.isFile()||s.size>max)fail('invalid_local_state');return JSON.parse(await h.readFile('utf8'));}finally{await h.close();}}catch(e){if(e.code==='ENOENT')return null;throw e;}}
59
- async function lock(directory,fn){await privateDir(directory);let h;try{h=await fs.open(path.join(directory,'lock'),'wx',0o600);}catch(e){if(e.code==='EEXIST')fail('profile_locked');throw e;}try{await h.writeFile(String(process.pid));return await fn();}finally{await h.close();await fs.unlink(path.join(directory,'lock'));}}
60
- function profilePath(name){if(typeof name!=='string'||!/^[A-Za-z0-9_-]{1,64}$/.test(name))fail('invalid_profile');return path.join(configDir(),'product-monitor-profiles',name);}
61
- function operationPath(scope,monitor){return path.join(configDir(),'product-monitor-operations',sha(canonical({...scope,monitor})));}
62
- function checkUpload(u,m) {
63
- if(u.outcome==='captured'){
64
- const png=Buffer.from(u.screenshot_png_base64,'base64');
65
- if(Buffer.byteLength(u.text)>131072||png.length>2097152||png.length<24||!png.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))||png.readUInt32BE(16)!==m.policy.viewport.width||png.readUInt32BE(20)!==m.policy.viewport.height)fail('capture_too_large_or_invalid');
66
- if(Array.from(u.title).length>256||Buffer.byteLength(u.title)>1024)fail('title_too_large');
67
- }
68
- if(Buffer.byteLength(JSON.stringify(u))>3145728)fail('capture_too_large');return u;
69
- }
70
- export function receipt(value,u,monitor) {
71
- if(value?.version!==1||id(value.id,'pcap')!==value.id||value.client_capture_id!==u.client_capture_id||value.monitor_id!==monitor||value.page_id!==u.page_id||value.generation!==u.expected_generation||value.outcome!==u.outcome||value.observed_at!==u.observed_at||value.manifest_sha256!==sha(canonical(u)))fail('receipt_mismatch');
72
- if(u.outcome==='captured'){
73
- const s=value.snapshot;id(s?.id,'psnap');
74
- if(!HASH.test(s.version)||s.text_sha256!==sha(u.text)||s.text_bytes!==Buffer.byteLength(u.text)||s.screenshot_sha256!==sha(Buffer.from(u.screenshot_png_base64,'base64'))||s.screenshot_bytes!==Buffer.from(u.screenshot_png_base64,'base64').length||s.policy_sha256!==u.policy_sha256)fail('receipt_mismatch');
75
- }else if(value.snapshot!==null||value.comparison!==null)fail('receipt_mismatch');
76
- return value;
77
- }
78
- export async function sendCapture(client,monitor,u,directory,signal){
79
- const file=path.join(directory,u.client_capture_id+'.json');
80
- if(await read(file))fail('capture_already_attempted');
81
- await unresolved(directory,Buffer.byteLength(JSON.stringify({version:1,state:'intended',monitor,upload:u}))+1);
82
- await write(file,{version:1,state:'intended',monitor,upload:u});
83
- let result;
84
- try{result=await request(client,`${API}/${monitor}/captures`,{method:'POST',body:u,signal});}
85
- catch{try{result=await request(client,`${API}/${monitor}/captures/by-client-id/${u.client_capture_id}`,{signal});}catch{fail('upload_unconfirmed');}}
86
- result=receipt(result,u,monitor);await write(file,{version:1,state:'confirmed',monitor,manifest_sha256:result.manifest_sha256,receipt:result});return result;
87
- }
88
- async function unresolved(directory,additionalBytes=0){const names=await fs.readdir(directory);if(names.length+(additionalBytes?2:0)>256)fail('local_history_full');let bytes=additionalBytes*2;for(const name of names){bytes+=(await fs.lstat(path.join(directory,name))).size;if(bytes>64*1024*1024)fail('local_history_full');if(!UUID.test(name.slice(0,-5))||!name.endsWith('.json'))continue;const record=await read(path.join(directory,name));if(record?.state!=='confirmed')fail('upload_unconfirmed');}}
89
- export async function loginPrompt(message,{signal,input=process.stdin,output=process.stderr}={}){if(!input.isTTY)fail('interactive_login_required');const r=createInterface({input,output});try{await r.question(message,{signal});}catch(error){if(error?.name==='AbortError')fail('cancelled');throw error;}finally{r.close();}}
90
- async function browser(overrides){try{return overrides.playwright||await import('playwright');}catch{fail('browser_setup_required');}}
91
- async function launch(pw,directory,m,headed=false,timeout){
92
- const context=await pw.chromium.launchPersistentContext(path.join(directory,'chromium'),{headless:!headed,acceptDownloads:false,permissions:[],serviceWorkers:'block',viewport:{width:m.policy.viewport.width,height:m.policy.viewport.height},deviceScaleFactor:1,locale:m.policy.locale,timezoneId:m.policy.timezone_id,colorScheme:m.policy.color_scheme,reducedMotion:'reduce',...(timeout===undefined?{}:{timeout})});
93
- if(!headed){try{await context.route('**/*',route=>route.abort('blockedbyclient'));if(typeof context.routeWebSocket!=='function')fail('browser_version_unsupported');await context.routeWebSocket('**/*',socket=>socket.close());for(const page of context.pages())await page.close();}catch(error){await context.close();throw error;}}
94
- return context;
95
- }
96
-
97
- /** A Boolean readiness hint only; headless capture remains authoritative. */
98
- export function loginScreenReady({screen,expected,capabilities=false}) {
99
- if(location.href!==screen.url||!screen.account_marker||typeof expected!=='string'||!expected)return false;
100
- const visible=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'&&r.width>0&&r.height>0;};
101
- const all=s=>Array.from(document.querySelectorAll(s)).filter(visible);
102
- const one=s=>{const a=all(s);return a.length===1?a[0]:null;};
103
- if(screen.login_selector&&all(screen.login_selector).length)return false;
104
- return Boolean(one(screen.ready_selector)&&(capabilities||one(screen.scope_selector))&&one(screen.account_marker.selector)?.innerText?.trim()===expected);
105
- }
106
- function loginActive(signal){if(signal.aborted)throw signal.reason instanceof MonitorFailure?signal.reason:new MonitorFailure('cancelled');}
107
- export async function waitForLogin(page,screen,markers,signal,manual=loginPrompt,capabilities=false){
108
- const waiting=new AbortController();const abort=()=>waiting.abort(signal.reason);signal.addEventListener('abort',abort,{once:true});
109
- try{
110
- loginActive(signal);
111
- const automatic=(async()=>{while(true){
112
- loginActive(waiting.signal);if(page.isClosed?.())fail('login_window_closed');
113
- if(screen.account_marker&&page.url()===screen.url){
114
- try{if(await page.evaluate(loginScreenReady,{screen,expected:markers[screen.id],...(capabilities?{capabilities:true}:{})})===true)return;}
115
- catch{loginActive(waiting.signal);if(page.isClosed?.())fail('login_window_closed');} // Normal authentication navigation can replace the DOM.
116
- }
117
- await sleep(1000,waiting.signal);
118
- }})();
119
- await Promise.race([automatic,Promise.resolve().then(()=>manual('Sign in in the browser; we\u2019ll continue when ready. If needed, return here and press Enter. (10-minute limit; Ctrl+C cancels.) ',{signal:waiting.signal}))]);
120
- loginActive(signal);
121
- }finally{waiting.abort();signal.removeEventListener('abort',abort);}
122
- }
123
-
124
- /** Runs synchronously in the page. Neither hidden text nor excluded text is returned. */
125
- export function extractScreen({screen,policy,expected,freeze=false}) {
126
- const visible=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'&&r.width>0&&r.height>0;};
127
- const all=s=>Array.from(document.querySelectorAll(s)).filter(visible);
128
- const one=s=>{const a=all(s);return a.length===1?a[0]:null;};
129
- const login=screen.login_selector&&all(screen.login_selector).length>0;
130
- if(login)return {error:'session_expired',login:true};
131
- if(!one(screen.ready_selector))return {error:'not_ready'};
132
- const scope=one(screen.scope_selector);if(!scope)return {error:'scope_mismatch'};
133
- let marker=null,markerNode=null;
134
- if(screen.account_marker){const node=one(screen.account_marker.selector);markerNode=node;marker=node?.innerText?.trim();if(!marker||typeof expected!=='string'||marker!==expected)return {error:'context_mismatch'};}
135
- const selectors=[...policy.masks,...policy.noise_regions].map(r=>r.selector);
136
- const masks=[];for(const s of selectors){const a=all(s);if(!a.length||a.length>100)return {error:'mask_unresolved'};masks.push(...a);}
137
- masks.push(...document.querySelectorAll('input,textarea,select,[contenteditable="true"]'));
138
- if(markerNode)masks.push(markerNode); // Only the marker digest is eligible for upload.
139
- const excluded=node=>masks.some(m=>m===node||m.contains(node));
140
- let nodes=0;
141
- const walk=node=>{if(++nodes>20000)throw Error('bounds');if(node.nodeType===Node.TEXT_NODE)return node.textContent;if(node.nodeType!==Node.ELEMENT_NODE)return '';if(excluded(node)||!visible(node)||['SCRIPT','STYLE','NOSCRIPT','TEMPLATE'].includes(node.tagName))return '';return Array.from(node.childNodes).map(walk).join('')+(/^(P|DIV|SECTION|ARTICLE|H[1-6]|LI|TR|BR)$/.test(node.tagName)?'\n':' ');};
142
- const text=walk(scope).replace(/[ \t]+/g,' ').replace(/ *\n */g,'\n').trim();
143
- if(!text)return {error:'not_ready'};
144
- if(freeze){
145
- // Hide the entire composited subtree; descendants cannot override parent opacity.
146
- // This happens before observation, and no unmasked screenshot is ever taken.
147
- for(const node of masks)node.style.setProperty('opacity','0','important');
148
- window.__opencrawlMasks=masks;window.__opencrawlChanged=false;
149
- window.__opencrawlObserver?.disconnect();
150
- window.__opencrawlObserver=new MutationObserver(rows=>{if(rows.some(r=>!excluded(r.target)||r.type==='attributes'&&masks.includes(r.target)))window.__opencrawlChanged=true;});
151
- window.__opencrawlObserver.observe(document.documentElement,{attributes:true,childList:true,characterData:true,subtree:true});
152
- }
153
- return {text,title:screen.label,marker,maskCount:masks.length};
154
- }
155
- export async function captureScreen(context,m,screen,binding,{signal,onDiagnostics}={}) {
156
- if(m.policy.version!==1&&!isLetterPolicy(m.policy,screen)&&!isLetterCapabilitiesPolicy(m.policy,screen))fail('invalid_policy');
157
- const page=await context.newPage();page.on('popup',popup=>popup.close().catch(()=>{}));let blocked=0,captureGuard='not_reached';const outerBlocks={navigation_scope:0,approved_read_redirect:0,non_read:0};const outerBlock=kind=>{outerBlocks[kind]=Math.min(64,outerBlocks[kind]+1);};
158
- const base={version:1,client_capture_id:crypto.randomUUID(),page_id:screen.id,expected_generation:m.generation,policy_sha256:m.policy_sha256,expected_previous_snapshot_id:screen.latest_snapshot?.id||null,session_epoch:binding.session_epoch,observed_at:new Date().toISOString(),requested_url:screen.url,final_url:null,outcome:'capture_failed',account:{label:m.account_label,assurance:'declared_only',marker_sha256:null},ready_matched:false,login_marker_matched:false,title:null,text:null,screenshot_png_base64:null};
159
- const capabilities=isLetterCapabilitiesPolicy(m.policy,screen),deadline=performance.now()+60000;
160
- const adapter=isLetterPolicy(m.policy,screen)||capabilities?new LetterReadAttempt(page,deadline,capabilities):null;
161
- const abort=()=>{if(capabilities)adapter.close();return page.close().catch(()=>{});};signal?.addEventListener('abort',abort,{once:true});const timer=setTimeout(abort,60000);
162
- try{
163
- await page.route('**/*',async route=>{const req=route.request();const navigation=req.isNavigationRequest()&&req.frame()===page.mainFrame();const requested=req.url();const wanted=new URL(screen.url);wanted.hash='';if(adapter&&req.redirectedFrom()&&adapter.requests.has(req.redirectedFrom())){adapter.reject(req,'redirect','approved_read_redirect');blocked++;outerBlock('approved_read_redirect');await route.abort('blockedbyclient');}
164
- else if(navigation&&requested!==wanted.href&&requested!==screen.url){blocked++;outerBlock('navigation_scope');adapter?.recordDenial(req,'selected_page','navigation_scope');await route.abort('blockedbyclient');}
165
- else if(adapter?!['GET','HEAD','OPTIONS'].includes(req.method().toUpperCase()):!isReadOnlyRequest(req.method(),req.postData())){const decision=adapter?.request(req);if(decision==='allow')await adapter.forward(route);else{if(decision!=='block_known'){blocked++;outerBlock('non_read');}await route.abort('blockedbyclient');}}
166
- else await route.continue();});
167
- if(typeof page.routeWebSocket!=='function')fail('browser_version_unsupported');await page.routeWebSocket('**/*',socket=>socket.close());
168
- if(signal?.aborted)fail('cancelled');
169
- const response=await page.goto(screen.url,{waitUntil:'domcontentloaded',timeout:30000});
170
- if(signal?.aborted)fail('cancelled');
171
- if(page.url()!==screen.url){base.outcome='scope_mismatch';return base;}
172
- if(response&&response.status()>=400){base.outcome='capture_failed';return base;}
173
- await page.locator(screen.ready_selector).waitFor({state:'visible',timeout:5000}).catch(()=>{});
174
- if(adapter&&screen.login_selector&&await page.evaluate(selector=>Array.from(document.querySelectorAll(selector)).some(node=>{const style=getComputedStyle(node),rect=node.getBoundingClientRect();return style.display!=='none'&&style.visibility!=='hidden'&&style.opacity!=='0'&&rect.width>0&&rect.height>0;}),screen.login_selector)){base.outcome='session_expired';base.login_marker_matched=true;return base;}
175
- if(adapter&&!await adapter.ready(signal)){if(signal?.aborted)fail('cancelled');captureGuard='readiness_refused';base.outcome='request_blocked';return base;}
176
- if(capabilities){
177
- const ready=await page.evaluate(letterPickerReady,{screen,expected:binding.markers[screen.id]});
178
- if(ready.error){base.outcome=ready.error;base.login_marker_matched=ready.login===true;return base;}
179
- const remaining=()=>{const ms=Math.floor(deadline-performance.now());if(ms<=0)fail('timeout');return ms;};
180
- // The only interaction this adapter permits: open the observed provider picker once.
181
- await page.locator('[data-testid="connect-integration-open"]').click({timeout:remaining()});
182
- await page.locator('[data-testid="connect-integration"]').waitFor({state:'visible',timeout:remaining()});
183
- // Playwright visibility permits opacity zero during Letter's entrance animation.
184
- // Wait within the original capture deadline before freezing the unchanged mask set.
185
- await page.waitForFunction(()=>{
186
- const nodes=document.querySelectorAll('[data-testid="connect-integration"]');if(nodes.length!==1)return false;
187
- const node=nodes[0],style=getComputedStyle(node),rect=node.getBoundingClientRect();
188
- return node.getAttribute('role')==='dialog'&&style.display!=='none'&&style.visibility!=='hidden'&&style.opacity==='1'&&rect.width>0&&rect.height>0&&node.getAnimations().every(animation=>!animation.pending&&['finished','idle'].includes(animation.playState));
189
- },null,{timeout:remaining()});
190
- if(!await adapter.ready(signal)){captureGuard='readiness_refused';base.outcome='request_blocked';return base;}
191
- }
192
- await page.evaluate(()=>{window.scrollTo(0,0);});
193
- const data=await page.evaluate(capabilities?extractLetterCapabilities:extractScreen,{screen,policy:m.policy,expected:binding.markers[screen.id],freeze:true});
194
- if(data.error){base.outcome=data.error;base.login_marker_matched=data.login===true;return base;}
195
- const png=await page.screenshot({type:'png',fullPage:false,animations:'disabled',caret:'initial',timeout:10000});
196
- const changed=await page.evaluate(()=>window.__opencrawlChanged===true||window.__opencrawlMasks?.some(node=>!node.isConnected||getComputedStyle(node).opacity!=='0'));
197
- if(changed||page.url()!==screen.url){base.outcome='not_ready';return base;}
198
- if(blocked||adapter&&(adapter.invalid||adapter.inFlight>0||adapter.required!=='verified')){captureGuard='final_request_refused';base.outcome='request_blocked';return base;}
199
- captureGuard='passed';
200
- return checkUpload({...base,outcome:'captured',observed_at:new Date().toISOString(),final_url:screen.url,ready_matched:true,title:data.title,text:data.text,screenshot_png_base64:png.toString('base64'),account:{label:m.account_label,assurance:data.marker?'dom_marker_matched':'declared_only',marker_sha256:data.marker?sha(data.marker):null}},m);
201
- }catch(e){if(signal?.aborted)fail('cancelled');base.outcome=e?.name==='TimeoutError'?'timeout':'capture_failed';return base;}
202
- finally{clearTimeout(timer);signal?.removeEventListener('abort',abort);adapter?.close();await page.close().catch(()=>{});if(adapter)await onDiagnostics?.(base.client_capture_id,{...adapter.report(),capture_guard:captureGuard,outer_blocks:outerBlocks});}
203
- }
204
- async function sleep(ms,signal){if(signal?.aborted)fail('cancelled');await new Promise((resolve,reject)=>{const stop=()=>{clearTimeout(t);signal?.removeEventListener('abort',stop);reject(new MonitorFailure('cancelled'));};const t=setTimeout(()=>{signal?.removeEventListener('abort',stop);resolve();},ms);signal?.addEventListener('abort',stop,{once:true});});}
205
- export async function monitorRunner(action,m,client,options={},overrides={}){
206
- const scope=await identity(client,overrides.signal);const directory=profilePath(options.profile);
207
- return lock(directory,async()=>{
208
- const file=path.join(directory,'binding.json');let binding=await read(file);
209
- const expected={...scope,monitor_id:m.id,generation:m.generation,policy_sha256:m.policy_sha256,origin:m.origin};
210
- if(binding&&!same(binding.scope,expected))fail('profile_binding_changed');
211
- const operations=operationPath(scope,m.id);await privateDir(operations);
212
- return lock(operations,async()=>{
213
- await unresolved(operations);
214
- const onDiagnostics=async(captureId,report)=>{const bytes=Buffer.byteLength(JSON.stringify(report)+'\n');if(bytes>16384)fail('diagnostics_too_large');await unresolved(operations,bytes);await write(path.join(operations,'diagnostics-'+captureId+'.json'),report);};
215
- const pw=await browser(overrides);
216
- if(action==='login'){
217
- let markers=options.markers?await read(path.resolve(options.markers),16384):binding?.markers||{};
218
- if(!markers||Array.isArray(markers)||typeof markers!=='object'||Object.entries(markers).some(([k,v])=>!m.pages.some(p=>p.id===k)||typeof v!=='string'||!v.trim()||v.length>500))fail('invalid_markers');
219
- if(m.pages.some(p=>p.account_marker&&!markers[p.id]))fail('markers_required');
220
- if(binding&&!same(binding.markers,markers))fail('profile_binding_changed');
221
- if(!overrides.waitForLogin&&!process.stdin.isTTY)fail('interactive_login_required');
222
- const duration=overrides.loginTimeoutMs??600000;if(!count(duration,1,600000))fail('invalid_login_timeout');
223
- const controller=new AbortController(),signal=controller.signal,deadline=performance.now()+duration;
224
- const stop=()=>controller.abort(new MonitorFailure('cancelled'));
225
- const active=()=>{if(performance.now()>=deadline)controller.abort(new MonitorFailure('login_timeout'));loginActive(signal);};
226
- const remaining=()=>{active();return Math.max(1,Math.min(30000,Math.ceil(deadline-performance.now())));};
227
- let context,closing;const close=()=>{if(context&&!closing)closing=Promise.resolve().then(()=>context.close());return closing||Promise.resolve();};
228
- const onAbort=()=>{void close().catch(()=>{});};signal.addEventListener('abort',onAbort,{once:true});
229
- overrides.signal?.addEventListener('abort',stop,{once:true});if(overrides.signal?.aborted)stop();
230
- process.on('SIGINT',stop);process.on('SIGTERM',stop);
231
- const timer=setTimeout(()=>controller.abort(new MonitorFailure('login_timeout')),duration);
232
- try{
233
- context=await launch(pw,directory,m,true,remaining());active();
234
- const page=context.pages()[0]||await context.newPage();active();await page.goto(m.pages[0].url,{waitUntil:'domcontentloaded',timeout:remaining()});active();
235
- await waitForLogin(page,m.pages[0],markers,signal,overrides.waitForLogin||loginPrompt,isLetterCapabilitiesPolicy(m.policy,m.pages[0]));active();await current(client,m,scope,signal);active();
236
- binding={version:1,scope:expected,markers,session_epoch:crypto.randomUUID()};
237
- await close();context=null;closing=null;active();
238
- context=await launch(pw,directory,m,false,remaining());active();
239
- for(const screen of m.pages){active();const observed=await captureScreen(context,m,screen,binding,{signal,onDiagnostics});active();if(observed.outcome!=='captured')fail(observed.outcome);}
240
- await current(client,m,scope,signal);active();await write(file,binding,active);
241
- }catch(error){active();throw error;}
242
- finally{try{await close();}finally{clearTimeout(timer);signal.removeEventListener('abort',onAbort);overrides.signal?.removeEventListener('abort',stop);process.off('SIGINT',stop);process.off('SIGTERM',stop);}}
243
- return {state:'session_saved_locally',monitor_id:m.id,profile:options.profile};
244
- }
245
- if(!binding)fail('local_login_required');
246
- if(Number(options.generation)!==m.generation)fail('generation_required');
247
- if(action==='watch'&&m.cadence_minutes===0)fail('cadence_required');
248
- const controller=new AbortController();const signal=overrides.signal||controller.signal;const stop=()=>controller.abort();if(!overrides.signal){process.on('SIGINT',stop);process.on('SIGTERM',stop);}
249
- let context;try{context=await launch(pw,directory,m,false);}catch(e){if(!overrides.signal){process.off('SIGINT',stop);process.off('SIGTERM',stop);}throw e;}
250
- const records=[];
251
- try{do{
252
- await unresolved(operations);const fresh=await current(client,m,scope,signal);
253
- for(const screen of fresh.pages){
254
- if(signal.aborted)fail('cancelled');await current(client,m,scope,signal);
255
- const upload=await captureScreen(context,m,screen,binding,{signal,onDiagnostics});await current(client,m,scope,signal);
256
- if(action==='preview'){
257
- if((await fs.readdir(directory)).filter(n=>n.startsWith('preview-')).length>=20)fail('local_preview_full');
258
- const preview=path.join(directory,'preview-'+upload.client_capture_id);await privateDir(preview);await write(path.join(preview,'observation.json'),upload);if(upload.outcome==='captured')await fs.writeFile(path.join(preview,'masked.png'),Buffer.from(upload.screenshot_png_base64,'base64'),{mode:0o600});records.push({state:upload.outcome,preview});
259
- }else {const r=await sendCapture(client,m.id,upload,operations,signal);records.push(r);overrides.onReceipt?.(r);}
260
- if(upload.outcome!=='captured')fail(upload.outcome);
261
- }
262
- if(action!=='watch')break;
263
- await (overrides.sleep||sleep)(m.cadence_minutes*60000,signal);
264
- records.splice(0); // A long-running watch never accumulates unbounded output in memory.
265
- }while(!signal.aborted);
266
- return {state:'complete',captures:records};
267
- }finally{await context.close();if(!overrides.signal){process.off('SIGINT',stop);process.off('SIGTERM',stop);}}
268
- });
269
- });
270
- }
271
- export const PRODUCT_MONITORS_HELP=`Local private product monitoring\n\n opencrawl browser monitor create --spec FILE\n opencrawl browser monitor list|show [MONITOR_ID]\n opencrawl browser monitor login MONITOR_ID --profile NAME [--markers FILE]\n opencrawl browser monitor preview|check|watch MONITOR_ID --profile NAME --generation N\n opencrawl browser monitor recover MONITOR_ID --capture UUID\n opencrawl browser monitor changes MONITOR_ID\n opencrawl browser monitor compare COMPARISON_ID --revision HASH\n opencrawl browser monitor image SNAPSHOT_ID --revision HASH --output NEW.png\n opencrawl browser monitor context COMPARISON_ID --revision HASH [--budget 4096]\n\nUse --account PROFILE and --server ORIGIN for the intended OpenCrawl account.\nmarkers FILE is private JSON {page_id: expected visible account/workspace text}.\nBrowser sessions stay here. Watch runs in the foreground; this computer must stay on.\nUnknown uploads stop without resend. Failures retain the last good snapshot.\n`;
272
- async function command(rest,options,client,overrides={}){
273
- const [action,value,...extra]=rest;const out=overrides.stdout||((s)=>process.stdout.write(s));
274
- const allowed={create:['spec'],list:[],show:[],login:['profile','markers'],preview:['profile','generation'],check:['profile','generation'],watch:['profile','generation'],recover:['capture'],changes:[],compare:['revision'],image:['revision','output'],context:['revision','budget']};
275
- if(!Object.hasOwn(allowed,action)||extra.length)fail('invalid_command');allowedOptions(options,allowed[action],'browser monitor');
276
- let result;
277
- if(action==='list'){result=await request(client,API);if(!Array.isArray(result?.monitors)||result.monitors.length>10)fail('invalid_monitor_list');result.monitors.forEach(m=>readMonitor(m));}
278
- else if(action==='create'){
279
- if(value||typeof options.spec!=='string')fail('spec_required');const input=await read(path.resolve(options.spec),65536);if(!input)fail('spec_required');uuid(input.client_create_id);const scope=await identity(client);const dir=operationPath(scope,'create');await privateDir(dir);
280
- result=await lock(dir,async()=>{const file=path.join(dir,input.client_create_id+'.json');const prior=await read(file);if(prior&&!same(prior.input,input))fail('creation_conflict');let value;
281
- if(prior){try{value=await request(client,`${API}/by-client-id/${input.client_create_id}`);}catch{fail('creation_unconfirmed');}}
282
- else {await write(file,{state:'intended',input});try{value=await request(client,API,{method:'POST',body:input});}catch{try{value=await request(client,`${API}/by-client-id/${input.client_create_id}`);}catch{fail('creation_unconfirmed');}}}
283
- readMonitor(value);const pages=input.pages.map(p=>({login_selector:null,account_marker:null,...p}));const policy={version:1,color_scheme:'light',screenshot_mode:'viewport',extractor:'product-screen-v1',masks:[],noise_regions:[],...input.policy,viewport:{device_scale_factor:1,...input.policy?.viewport}};if(value.name!==input.name||value.account_label!==input.account_label||value.cadence_minutes!==(input.cadence_minutes??0)||!same(value.policy,policy)||!same(value.pages.map(({id,last_attempt,latest_snapshot,...p})=>p),pages))fail('receipt_mismatch');await write(file,{state:'confirmed',input,monitor:value});return value;});
284
- }else if(action==='image'){
285
- id(value,'psnap');if(!HASH.test(options.revision)||typeof options.output!=='string')fail('image_output_required');
286
- const snapshot=await request(client,`${API}/snapshots/${value}`);if(snapshot?.id!==value||snapshot.version!==options.revision)fail('snapshot_changed');
287
- const bytes=await request(client,`${API}/snapshots/${value}/image`,{responseType:'png'});
288
- if(bytes.length!==snapshot.screenshot_bytes||sha(bytes)!==snapshot.screenshot_sha256)fail('image_mismatch');
289
- const output=path.resolve(options.output),handle=await fs.open(output,'wx',0o600);try{await handle.writeFile(bytes);await handle.sync();}finally{await handle.close();}result={snapshot_id:value,version:options.revision,output};
290
- }else if(action==='context'){
291
- id(value,'pcmp');if(!HASH.test(options.revision))fail('version_required');const budget=options.budget===undefined?4096:Number(options.budget);if(!count(budget,256,16384))fail('invalid_budget');
292
- result=await request(client,'/api/v1/product-monitor-context',{method:'POST',body:{selections:[{comparison_id:value,version:options.revision}],budget}});
293
- if(typeof result?.context!=='string'||Buffer.byteLength(result.context)!==result.context_bytes||result.context_bytes>budget||result.budget!==budget||!Array.isArray(result.included)||!Array.isArray(result.omitted)||result.included.some(r=>r.comparison_id!==value||r.version!==options.revision||!['before','after'].includes(r.side))||result.omitted.some(r=>r.comparison_id!==value||!['before','after'].includes(r.side)||!['budget','no_text_region'].includes(r.reason))||result.included.length+result.omitted.length!==2||new Set([...result.included,...result.omitted].map(r=>r.side)).size!==2||result.image_content_included!==false)fail('context_mismatch');
294
- }else if(action==='compare'){
295
- id(value,'pcmp');if(!HASH.test(options.revision))fail('version_required');result=await request(client,`${API}/comparisons/${value}`);if(result?.id!==value||result.version!==options.revision)fail('comparison_changed');
296
- }else{
297
- monitorId(value);
298
- if(action==='recover'){
299
- uuid(options.capture);const scope=await identity(client);const dir=operationPath(scope,value);result=await lock(dir,async()=>{const file=path.join(dir,options.capture+'.json');const saved=await read(file);if(!saved||saved.monitor!==value)fail('capture_unknown');const raw=await request(client,`${API}/${value}/captures/by-client-id/${options.capture}`);let got;if(saved.state==='confirmed'){if(raw?.client_capture_id!==options.capture||raw.monitor_id!==value||raw.manifest_sha256!==saved.manifest_sha256||!same(raw,saved.receipt))fail('receipt_mismatch');got=raw;}else got=receipt(raw,saved.upload,value);await write(file,{version:1,monitor:value,state:'confirmed',manifest_sha256:got.manifest_sha256,receipt:got});return got;});
300
- }else if(action==='changes'){result=await request(client,`${API}/${value}/comparisons`);if(!Array.isArray(result?.comparisons))fail('invalid_comparisons');}
301
- else{const m=readMonitor(await request(client,`${API}/${value}`),value);if(action==='show')result=m;else{if(m.status!=='active')fail('monitor_not_active');result=await monitorRunner(action,m,client,options,{...overrides,onReceipt:action==='watch'?r=>out(JSON.stringify(r)+'\n'):undefined});}}
302
- }
303
- if(options.json)out(JSON.stringify(result,null,2)+'\n');else if(result?.comparisons)out(result.comparisons.map(x=>`${displayLine(x.id)} ${displayLine(x.version)} ${displayLine(x.observed_before)} → ${displayLine(x.observed_after)}\n`).join('')||'No retained comparisons.\n');else out(JSON.stringify(result,null,2)+'\n');return 0;
304
- }
305
-
306
- export async function productMonitorsCommand(rest,options,client,overrides={}) {
307
- try {return await command(rest,options,client,overrides);}
308
- catch(error) {
309
- if(error instanceof MonitorFailure)throw error;
310
- const code=Number.isInteger(error?.status)&&error.status>=400&&error.status<=599?`http_${error.status}`:error?.code==='EEXIST'?'output_exists':'request_unconfirmed';
311
- throw new MonitorFailure(code);
312
- }
313
- }
@@ -1,164 +0,0 @@
1
- import {readResearchCompletion,readResearchPresentation} from './research-completion.mjs';
2
- import {readResearchAnswerContract} from './research-answer-contract.mjs';
3
- import {readResearchAnswerView} from './research-answer-view.mjs';
4
- import fs from 'node:fs/promises';
5
- import {createHash} from 'node:crypto';
6
- import {allowedOptions,decimal,identifier,withInterrupt} from './jobs.mjs';
7
- import {displayLine,displayText} from './display.mjs';
8
-
9
- const ROOT='/api/research/agents',ACTIVE=new Set(['queued','running']);
10
- const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
11
- const text=(v,max,min=0)=>typeof v==='string'&&Array.from(v).length>=min&&Array.from(v).length<=max&&!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(v);
12
- const integer=(v,min=0,max=Number.MAX_SAFE_INTEGER)=>Number.isSafeInteger(v)&&v>=min&&v<=max;
13
- const stamp=v=>typeof v==='string'&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+00:00)$/.test(v)&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString().slice(0,19)===v.slice(0,19);
14
- const fields=(v,keys)=>Object.keys(v).every(key=>keys.includes(key));
15
- function canonicalWebsite(value){if(/[\\\s\u0000-\u001f\u007f]/.test(value)||/%(?![0-9a-f]{2})/i.test(value))throw new Error('Use a valid public website URL.');const u=new URL(value);u.hash='';return new URL(u.href.replace(/%([0-9a-f]{2})/gi,(_all,hex)=>{const ch=String.fromCharCode(parseInt(hex,16));return /^[A-Za-z0-9._~-]$/.test(ch)?ch:'%'+hex.toUpperCase()})).href;}
16
- function publicUrl(v){if(!text(v,2048,1))return false;try{const u=new URL(v);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password&&!!u.hostname}catch{return false}}
17
- function pageEvidence(cite){
18
- if(cite.source_kind!=='web_page')return !Object.hasOwn(cite,'page');
19
- const p=cite.page;
20
- return object(p)&&fields(p,['requested_url','final_url','body_sha256','start','end','total_chars','extractor'])&&publicUrl(p.requested_url)&&publicUrl(p.final_url)&&p.final_url===cite.url&&typeof p.body_sha256==='string'&&/^[0-9a-f]{64}$/.test(p.body_sha256)&&integer(p.start,0,131071)&&integer(p.end,1,131072)&&integer(p.total_chars,1,131072)&&p.start<p.end&&p.end<=p.total_chars&&p.end-p.start<=16000&&typeof cite.end==='number'&&cite.end<=p.end-p.start&&p.extractor==='public-static-v3';
21
- }
22
- const invalid=()=>{throw new Error('Server returned invalid or out-of-scope saved research findings. No result was emitted.');};
23
- const feedbackRatings=['useful','irrelevant','incorrect','outdated'];
24
- const feedbackNote=value=>value===null||text(value,1000)&&!value.includes('\u0000')&&Buffer.byteLength(value)<=4096;
25
- function confirmedReadFailure(error,request){
26
- if((request.method??'GET')!=='GET'||!Number.isInteger(error?.status))return null;
27
- const message={
28
- 401:'Saved research could not be read. Check the selected CLI account, server and workspace and its existing credential.',
29
- 403:'This account cannot read the requested research. Check the selected account and workspace and ask its administrator to confirm access.',
30
- 404:request.command==='receipt'?'The original admission receipt is unavailable to this account. Check the selected workspace and exact operation. A missing receipt does not authorize a new admission.':'The saved research is unavailable to this account. Check the selected workspace and exact agent, run or source identifiers. Do not substitute a current webpage for retained evidence.',
31
- 409:'The saved research could not be read in its current state. Read the same agent, schedule or feedback and reconcile its current revision before any write.'
32
- }[error.status];
33
- return message?{code:`http_${error.status}`,message}:null;
34
- }
35
- export function validateAgentFeedback(value,agentId,runId,findingId){
36
- if(!object(value)||!fields(value,['agent_id','run_id','finding_id','feedback'])||value.agent_id!==agentId||value.run_id!==runId||value.finding_id!==findingId||!object(value.feedback))invalid();
37
- const f=value.feedback;if(!fields(f,['version','revision','state','rating','note','updated_at'])||f.version!==1||!integer(f.revision)||!feedbackNote(f.note)||Buffer.byteLength(JSON.stringify(value))>8192)invalid();
38
- if(f.state==='absent'){if(f.revision!==0||f.rating!==null||f.note!==null||f.updated_at!==null)invalid()}
39
- else if(f.state==='deleted'){if(f.revision<1||f.rating!==null||f.note!==null||!stamp(f.updated_at))invalid()}
40
- else if(f.state==='active'){if(f.revision<1||!feedbackRatings.includes(f.rating)||!stamp(f.updated_at))invalid()}
41
- else invalid();return value;
42
- }
43
- export function validateAgentSource(value,agentId,runId,sourceId){
44
- if(!object(value)||!fields(value,['agent_id','run_id','source'])||value.agent_id!==agentId||value.run_id!==runId||!object(value.source))invalid();
45
- const source=value.source;
46
- if(!fields(source,['id','url','title','text','body_sha256','observed_at','published_at','source_kind','collection_id','document_id','version_id','page'])||source.id!==sourceId||!text(source.id,80,1)||!publicUrl(source.url)||!text(source.title,2048,1)||!text(source.text,16000,1)||!source.text.trim()||source.text.includes('\u0000')||!stamp(source.observed_at)||source.published_at!==null&&!text(source.published_at,100,1)||!['indexed','web_page'].includes(source.source_kind)||!pageEvidence({...source,end:Array.from(source.text).length})||typeof source.body_sha256!=='string'||!/^[0-9a-f]{64}$/.test(source.body_sha256))invalid();
47
- for(const key of ['collection_id','document_id','version_id'])if(source.source_kind==='indexed'?!text(source[key],200,1):source[key]!==null)invalid();
48
- if(source.source_kind==='web_page'&&source.page.end-source.page.start!==Array.from(source.text).length)invalid();
49
- if(Buffer.byteLength(JSON.stringify(value))>131072||createHash('sha256').update(source.text,'utf8').digest('hex')!==source.body_sha256)invalid();
50
- return value;
51
- }
52
- export function validateAgentDescriptor(value){
53
- if(!object(value)||!fields(value,['customer','goal','collection_ids','schedule_minutes','research_policy'])||!object(value.customer)||!fields(value.customer,['name','website','context'])||!text(value.customer.name,200,1)||!value.customer.name.trim()||!publicUrl(value.customer.website)||!text(value.customer.context??'',4000)||!text(value.goal,4000,1)||!value.goal.trim()||value.schedule_minutes!==undefined&&value.schedule_minutes!==0||!Array.isArray(value.collection_ids??[])||(value.collection_ids??[]).length>8||!(value.collection_ids??[]).every(v=>text(v,200,1))||new Set(value.collection_ids??[]).size!==(value.collection_ids??[]).length)throw new Error('Agent JSON must contain a customer name and public website, a goal, optional context and up to eight unique collection_ids; schedule_minutes must be 0.');
54
- if(Object.hasOwn(value,'research_policy')&&!['completion_v1','completion_v2','completion_v3'].includes(value.research_policy))throw new Error('research_policy must be completion_v1, completion_v2 or completion_v3 when explicitly supplied.');
55
- const result={customer:{name:value.customer.name.trim(),website:canonicalWebsite(value.customer.website),context:value.customer.context??''},goal:value.goal.trim(),collection_ids:value.collection_ids??[],schedule_minutes:0,...(Object.hasOwn(value,'research_policy')?{research_policy:value.research_policy}:{})};if(Buffer.byteLength(JSON.stringify(result))>32768)throw new Error('The research brief exceeds 32 KiB.');return result;
56
- }
57
- export function validateAgentRun(value,agentId=value?.agent_id,runId=value?.id){
58
- if(!object(value)||!fields(value,['id','agent_id','agent_revision','status','phase','created_at','started_at','finished_at','outcome','findings','gaps','usage','error'])||!text(value.id,200,1)||value.id!==runId||!text(value.agent_id,200,1)||value.agent_id!==agentId||!integer(value.agent_revision,1)||!['queued','running','completed','partial','needs_input','failed','unknown','cancelled'].includes(value.status)||!['queued','researching','synthesizing','presenting','finished'].includes(value.phase)||!stamp(value.created_at)||value.started_at!==null&&!stamp(value.started_at)||value.finished_at!==null&&!stamp(value.finished_at)||!['findings','no_supported_findings','needs_input',null].includes(value.outcome)||!Array.isArray(value.findings)||value.findings.length>5||!Array.isArray(value.gaps)||value.gaps.length>5||!value.gaps.every(v=>text(v,500,1))||!object(value.usage)||Buffer.byteLength(JSON.stringify(value.usage))>16384||value.error!==null&&(!object(value.error)||!text(value.error.code,100,1)||!text(value.error.message,1000,1)))invalid();
59
- const seen=new Set();for(const finding of value.findings){if(!object(finding)||!fields(finding,['id','claim','relevance','qualifiers','citations'])||!text(finding.id,200,1)||seen.has(finding.id)||!text(finding.claim,1500,1)||!text(finding.relevance,1500,1)||!Array.isArray(finding.qualifiers)||finding.qualifiers.length>5||!finding.qualifiers.every(v=>text(v,300,1))||!Array.isArray(finding.citations)||finding.citations.length<1||finding.citations.length>3)invalid();seen.add(finding.id);
60
- for(const cite of finding.citations){if(!object(cite)||!fields(cite,['id','url','title','quote','start','end','body_sha256','observed_at','published_at','source_kind','collection_id','document_id','version_id','page'])||!text(cite.id,80,1)||!publicUrl(cite.url)||!text(cite.title,2048)||!text(cite.quote,1200,1)||!integer(cite.start,0,16000)||!integer(cite.end,1,16000)||cite.end-cite.start!==Array.from(cite.quote).length||typeof cite.body_sha256!=='string'||!/^[0-9a-f]{64}$/.test(cite.body_sha256)||!stamp(cite.observed_at)||cite.published_at!==null&&!text(cite.published_at,100,1)||!['web_search','indexed','web_page'].includes(cite.source_kind)||!pageEvidence(cite))invalid();for(const key of ['collection_id','document_id','version_id'])if(cite.source_kind==='indexed'?!text(cite[key],200,1):cite[key]!==null)invalid();}
61
- }
62
- if((value.outcome==='findings')!==Boolean(value.findings.length)||ACTIVE.has(value.status)&&(value.phase==='finished'||value.outcome!==null||value.findings.length||value.finished_at!==null)||!ACTIVE.has(value.status)&&value.phase!=='finished'||Buffer.byteLength(JSON.stringify({findings:value.findings,gaps:value.gaps}))>32768)invalid();return value;
63
- }
64
- export function validateResearchAgent(value,id=value?.id){if(!object(value)||!fields(value,['id','customer','goal','collection_ids','revision','status','schedule_minutes','created_at','updated_at','latest_run'])||!text(value.id,200,1)||value.id!==id||!integer(value.revision,1)||!['active','paused'].includes(value.status)||![0,1440].includes(value.schedule_minutes)||!stamp(value.created_at)||!stamp(value.updated_at)||!text(value.customer?.context,4000))invalid();try{validateAgentDescriptor({customer:value.customer,goal:value.goal,collection_ids:value.collection_ids,schedule_minutes:0})}catch{invalid()}if(value.latest_run!==null)validateAgentRun(value.latest_run,value.id);return value;}
65
- export function validateAgentSchedule(value,agentId){
66
- if(!object(value)||!fields(value,['agent_id','revision','enabled','schedule_minutes','next_scheduled_at','blocked_reason','blocked_run_id','deferred_reason'])||value.agent_id!==agentId||!integer(value.revision)||typeof value.enabled!=='boolean'||value.schedule_minutes!==(value.enabled?1440:0)||!['unknown','failed','needs_input','cancelled','access_changed','agent_paused',null].includes(value.blocked_reason)||!['capacity','daily_budget','storage','run_active','history_capacity',null].includes(value.deferred_reason)||value.blocked_run_id!==null&&!text(value.blocked_run_id,200,1)||value.next_scheduled_at!==null&&!stamp(value.next_scheduled_at))invalid();
67
- if((!value.enabled||value.blocked_reason!==null)&&value.deferred_reason!==null||!value.enabled&&(value.next_scheduled_at!==null||value.blocked_reason!==null||value.blocked_run_id!==null)||value.enabled&&(value.revision===0||(value.blocked_reason===null)!==(value.next_scheduled_at!==null))||value.blocked_reason===null&&value.blocked_run_id!==null)invalid();
68
- return value;
69
- }
70
- export function validateAgentRepeats(value,agentId,runId){
71
- if(!object(value)||!fields(value,['agent_id','run_id','status','items'])||value.agent_id!==agentId||value.run_id!==runId||!['available','not_assessed'].includes(value.status)||!Array.isArray(value.items)||value.items.length>5||value.status==='not_assessed'&&value.items.length)invalid();
72
- const seen=new Set();for(const item of value.items){
73
- if(!object(item)||!fields(item,['finding_id','classification','first_seen_run_id','first_seen_finding_id'])||!text(item.finding_id,200,1)||seen.has(item.finding_id)||!text(item.first_seen_run_id,200,1)||!text(item.first_seen_finding_id,200,1)||!['first_seen','exact_repeat'].includes(item.classification))invalid();
74
- const same=item.first_seen_run_id===runId&&item.first_seen_finding_id===item.finding_id;if((item.classification==='first_seen')!==same||item.classification==='exact_repeat'&&item.first_seen_run_id===runId)invalid();seen.add(item.finding_id);
75
- }
76
- return value;
77
- }
78
- export function validateAgentReceipt(value,id){if(!object(value)||!fields(value,['agent','run','replayed'])||typeof value.replayed!=='boolean')invalid();const agent=validateResearchAgent(value.agent,id??value.agent?.id),run=validateAgentRun(value.run,agent.id);if(agent.revision!==run.agent_revision||agent.latest_run?.id!==run.id)invalid();return value;}
79
- function key(value){if(typeof value!=='string'||!/^[A-Za-z0-9_-]{16,128}$/.test(value))throw new Error('Retain a random 16–128 character --request-key for this exact research request.');return value;}
80
- export function agentRequest(rest,options){
81
- if(rest[0]==='agent'&&rest[1]==='feedback'){
82
- const [, ,id,runId,findingId,action='show',...extra]=rest;
83
- identifier(id,'agent');identifier(runId,'run');identifier(findingId,'finding');
84
- if(extra.length||!['show','set','delete'].includes(action))throw new Error('Use research agent feedback AGENT_ID RUN_ID FINDING_ID [show|set|delete].');
85
- allowedOptions(options,action==='show'?[]:action==='set'?['revision','rating','note']:['revision'],'research agent feedback');
86
- const route=`${ROOT}/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}/findings/${encodeURIComponent(findingId)}/feedback`,base={command:'feedback',id,runId,findingId,action,route};
87
- if(action==='show')return base;
88
- const revision=decimal(options.revision,'revision',0,Number.MAX_SAFE_INTEGER);
89
- if(action==='delete')return {...base,revision,method:'DELETE',route:route+`?expected_revision=${revision}`};
90
- if(!feedbackRatings.includes(options.rating)||!feedbackNote(options.note??null))throw new Error('Use --rating useful|irrelevant|incorrect|outdated and an optional note of at most 1000 Unicode characters.');
91
- return {...base,revision,method:'PUT',body:{expected_revision:revision,rating:options.rating,note:options.note??null}};
92
- }
93
- if(rest[0]==='agent'&&rest[1]==='source'){
94
- allowedOptions(options,[],'research agent source');if(rest.length!==5)throw new Error('Use research agent source AGENT_ID RUN_ID SOURCE_ID.');
95
- const id=identifier(rest[2],'agent'),runId=identifier(rest[3],'run'),sourceId=identifier(rest[4],'source');return {command:'source',id,runId,sourceId,route:`${ROOT}/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}/sources/${encodeURIComponent(sourceId)}`};
96
- }
97
- if(rest[0]==='agent'&&rest[1]==='runs'){
98
- allowedOptions(options,['limit','cursor'],'research agent runs');if(rest.length!==3)throw new Error('Use research agent runs AGENT_ID [--limit N] [--cursor CURSOR].');
99
- const id=identifier(rest[2],'agent'),limit=decimal(options.limit,'limit',1,100,25),query=new URLSearchParams({limit});
100
- if(options.cursor!==undefined){if(typeof options.cursor!=='string'||!/^[\x21-\x7e]{1,512}$/.test(options.cursor))throw new Error('Use the returned opaque research history cursor.');query.set('cursor',options.cursor)}
101
- return {command:'runs',id,limit,cursor:options.cursor,route:`${ROOT}/${encodeURIComponent(id)}/runs?${query}`};
102
- }
103
- if(rest[0]==='agent'&&rest[1]==='schedule'){
104
- const [, ,id,action='show',...extra]=rest;identifier(id,'agent');if(extra.length||!['show','daily','pause','resume'].includes(action))throw new Error('Use research agent schedule AGENT_ID [show|daily|pause|resume].');allowedOptions(options,action==='show'?[]:['revision'],'research agent schedule');
105
- return {command:'schedule',id,route:`${ROOT}/${encodeURIComponent(id)}/schedule`,...(action==='show'?{}:{method:'PATCH',body:{expected_revision:decimal(options.revision,'revision',0,Number.MAX_SAFE_INTEGER),enabled:action!=='pause'}})};
106
- }
107
- if(rest[0]==='agent'&&rest[1]==='repeats'){
108
- allowedOptions(options,[],'research agent repeats');if(rest.length!==4)throw new Error('Use research agent repeats AGENT_ID RUN_ID.');const id=identifier(rest[2],'agent'),runId=identifier(rest[3],'run');return {command:'repeats',id,runId,route:`${ROOT}/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}/repeats`};
109
- }
110
- if(rest[0]==='agent-run'){
111
- allowedOptions(options,['wait','timeout'],'research agent-run');if(rest.length!==3)throw new Error('Use research agent-run AGENT_ID RUN_ID.');
112
- const id=identifier(rest[1],'agent'),runId=identifier(rest[2],'run');if(options.timeout!==undefined&&!options.wait)throw new Error('--timeout requires --wait.');
113
- return {command:'agent-run',id,runId,route:`${ROOT}/${encodeURIComponent(id)}/runs/${encodeURIComponent(runId)}`,wait:!!options.wait,timeout:decimal(options.timeout,'timeout',1,3600,300)};
114
- }
115
- const [,command,id,...extra]=rest,allowed={create:['file','request-key','wait','timeout'],list:['limit','cursor'],show:[],run:['revision','request-key','wait','timeout','research-policy'],pause:['revision'],resume:['revision'],receipt:['operation','request-key']};
116
- if(!Object.hasOwn(allowed,command))throw new Error('Choose research agent create, list, show, run, pause, resume or receipt.');allowedOptions(options,allowed[command],'research agent');
117
- if(extra.length||['create','list','receipt'].includes(command)&&id!==undefined)throw new Error('Unexpected research agent argument.');
118
- if(options.timeout!==undefined&&!options.wait)throw new Error('--timeout requires --wait.');
119
- const base={command,wait:!!options.wait,timeout:decimal(options.timeout,'timeout',1,3600,300)};
120
- if(command==='create'){if(!text(options.file,4096,1))throw new Error('Provide --file with the research brief JSON.');return {...base,route:ROOT,method:'POST',file:options.file,key:key(options['request-key'])};}
121
- if(command==='list'){const limit=decimal(options.limit,'limit',1,100,25),q=new URLSearchParams({limit});if(options.cursor!==undefined){if(!text(options.cursor,512,1))throw new Error('Use the returned bounded cursor.');q.set('cursor',options.cursor)}return {...base,limit,route:ROOT+'?'+q};}
122
- if(command==='receipt'){if(options.operation!=='create'&&!/^run:ragent_[0-9a-f]{32}$/.test(options.operation??''))throw new Error('--operation must be create or run:AGENT_ID.');return {...base,route:ROOT+'/receipts/by-key?operation='+encodeURIComponent(options.operation),key:key(options['request-key']),id:options.operation==='create'?undefined:options.operation.slice(4)};}
123
- identifier(id,'agent');const route=ROOT+'/'+encodeURIComponent(id);
124
- if(command==='show')return {...base,id,route};
125
- const revision=decimal(options.revision,'revision',1,Number.MAX_SAFE_INTEGER);
126
- if(command==='run'){if(Object.hasOwn(options,'research-policy')&&!['completion_v1','completion_v2','completion_v3'].includes(options['research-policy']))throw new Error('--research-policy must be completion_v1, completion_v2 or completion_v3 when explicitly supplied.');return {...base,id,route:route+'/runs',method:'POST',body:{expected_revision:revision,...(Object.hasOwn(options,'research-policy')?{research_policy:options['research-policy']}:{})},key:key(options['request-key'])};}
127
- return {...base,id,route,method:'PATCH',body:{expected_revision:revision,status:command==='pause'?'paused':'active'}};
128
- }
129
- async function readDescriptor(file){const handle=await fs.open(file,'r');try{const size=(await handle.stat()).size;if(!integer(size,1,65536))throw new Error('Research brief JSON must be between 1 byte and 64 KiB.');const bytes=Buffer.alloc(size+1),read=await handle.read(bytes,0,size+1,0);if(read.bytesRead!==size)throw new Error('Research brief changed while reading.');let value;try{value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes.subarray(0,size)))}catch{throw new Error('Invalid research brief JSON. Supply valid UTF-8 JSON without extra fields.');}return validateAgentDescriptor(value)}finally{await handle.close()}}
130
- export async function waitAgentRun(client,initial,{timeout,signal,now=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}){let run=validateAgentRun(initial);const started=now();let deadline=started+(timeout??([2,3].includes(readResearchCompletion(run).completion?.version??0)?540:300))*1000;while(ACTIVE.has(run.status)){if(signal?.aborted)return {run,wait:{outcome:'interrupted'}};if(now()>=deadline)return {run,wait:{outcome:'timeout'}};try{run=validateAgentRun(await client.request(`${ROOT}/${encodeURIComponent(run.agent_id)}/runs/${encodeURIComponent(run.id)}`,{signal,timeout:Math.max(1,Math.min(30000,Math.ceil(deadline-now()))),maxResponseBytes:262144}),run.agent_id,run.id)}catch(error){const outcome=signal?.aborted?'interrupted':now()>=deadline?'timeout':'unavailable',failure=outcome==='unavailable'?confirmedReadFailure(error,{command:'agent-run'}):null;return {run,wait:{outcome,...(failure?{error:failure}:{})}}}if(timeout===undefined&&[2,3].includes(readResearchCompletion(run).completion?.version??0))deadline=started+540000;if(ACTIVE.has(run.status)){const next=Math.min(deadline,now()+2000);while(!signal?.aborted&&now()<next)await sleep(Math.min(100,next-now()));}}return {run,wait:{outcome:run.status}};}
131
- function renderRun(run,stdout){stdout(`Run: ${displayLine(run.id)}\nAgent: ${displayLine(run.agent_id)}\nStatus: ${displayLine(run.status)}\n`);const answer=readResearchAnswerView(run);if(answer.state==='valid'&&answer.sections.some(s=>s.assertions.length)){stdout('\nConcise answer\n');for(const section of answer.sections){stdout('\n'+displayText(section.component.text)+'\n');for(const assertion of section.assertions)stdout(displayText(assertion.text)+'\n');if(section.notice)stdout(displayText(section.notice)+'\n');const evidenceIds=section.assertions.length?[...new Set(section.assertions.flatMap(a=>a.fact_refs.flatMap(r=>run.findings.find(f=>f.id===r.finding_id)?.citations.map(c=>c.id)??[])))]:section.component.evidence_ids;if(evidenceIds.length)stdout('Evidence: '+evidenceIds.map(displayLine).join(', ')+'\n');}stdout('\nExact findings and qualifications\n');}for(const finding of run.findings){stdout(`\n${displayLine(finding.id)}\nObserved statement: ${displayText(finding.claim)}\n`);if(finding.relevance!=='Selected for this research brief.')stdout(`Relevance (inference): ${displayText(finding.relevance)}\n`);for(const qualifier of finding.qualifiers)stdout('Qualification: '+displayText(qualifier)+'\n');for(const cite of finding.citations)stdout(`[${displayLine(cite.id)}] ${displayLine(cite.title)}\n${displayLine(cite.url)}\nObserved: ${displayLine(cite.observed_at)}; source positions ${cite.start}–${cite.end}\n${displayText(cite.quote)}\n`);}for(const gap of [...run.gaps,...readResearchAnswerContract(run).notices.map(notice=>notice.text),...readResearchCompletion(run).notices.map(notice=>notice.text)])stdout('Gap: '+displayText(gap)+'\n');const presentation=readResearchPresentation(run);if(presentation&&!['reviewed','skipped_no_eligible'].includes(presentation.status))stdout('Summary: A concise presentation was not completed for every part. The saved findings and gaps remain available.\n');if(run.error)stdout('Run error: '+displayLine(run.error.message)+'\n');}
132
- export async function agentCommand(rest,options,client,{stdout=value=>process.stdout.write(value),stderr=value=>process.stderr.write(value)}={}){
133
- const request=agentRequest(rest,options);if(request.file)request.body=await readDescriptor(request.file);
134
- let value;try{value=await client.request(request.route,{method:request.method,body:request.body,idempotencyKey:request.key,maxResponseBytes:request.command==='source'?131072:request.command==='runs'?1048576:4*1024*1024});
135
- if(request.command==='runs'){
136
- if(!object(value)||!fields(value,['items','next_cursor','has_more'])||!Array.isArray(value.items)||value.items.length>request.limit||typeof value.next_cursor!=='string'||!/^[\x21-\x7e]{1,512}$/.test(value.next_cursor)||typeof value.has_more!=='boolean'||value.has_more&&!value.items.length||request.cursor!==undefined&&(value.items.length?value.next_cursor===request.cursor:value.next_cursor!==request.cursor)||Buffer.byteLength(JSON.stringify(value))>1048576)invalid();
137
- const seen=new Set();for(const item of value.items){const run=validateAgentRun(item,request.id);if(seen.has(run.id))invalid();seen.add(run.id)}
138
- }
139
- else if(request.command==='list'){if(!object(value)||!Array.isArray(value.items)||value.items.length>request.limit||value.next_cursor!==null&&!text(value.next_cursor,512,1))invalid();value.items.forEach(item=>validateResearchAgent(item));}
140
- else if(['create','run','receipt'].includes(request.command)){validateAgentReceipt(value,request.id);if(request.command==='create'&&(value.agent.revision!==1||value.agent.schedule_minutes!==0||value.agent.goal!==request.body.goal||['name','website','context'].some(k=>value.agent.customer[k]!==request.body.customer[k])||JSON.stringify(value.agent.collection_ids)!==JSON.stringify(request.body.collection_ids)))invalid();if(request.command==='run'&&value.run.agent_revision!==request.body.expected_revision)invalid();}
141
- else if(request.command==='schedule'){validateAgentSchedule(value,request.id);if(request.body&&(value.enabled!==request.body.enabled||![request.body.expected_revision,request.body.expected_revision+1].includes(value.revision)))invalid();}
142
- else if(request.command==='feedback'){
143
- validateAgentFeedback(value,request.id,request.runId,request.findingId);const f=value.feedback;
144
- if(request.action!=='show'&&![request.revision,request.revision+1].includes(f.revision))invalid();
145
- if(request.action==='set'&&(f.state!=='active'||f.rating!==request.body.rating||f.note!==request.body.note)||request.action==='delete'&&f.state==='active')invalid();
146
- }
147
- else if(request.command==='source')validateAgentSource(value,request.id,request.runId,request.sourceId);
148
- else if(request.command==='repeats')validateAgentRepeats(value,request.id,request.runId);
149
- else if(request.command==='agent-run')validateAgentRun(value,request.id,request.runId);
150
- else {validateResearchAgent(value,request.id);if(['pause','resume'].includes(request.command)&&(value.status!==request.body.status||![request.body.expected_revision,request.body.expected_revision+1].includes(value.revision)))invalid();}
151
- }catch(error){const status=Number.isInteger(error?.status)?error.status:undefined,message=confirmedReadFailure(error,request)?.message??(request.command==='feedback'?'Feedback was not confirmed. Read the same finding feedback and reconcile its revision before another write.':request.command==='source'?'Saved source context could not be read. Do not substitute a current webpage for this saved observation.':request.command==='schedule'?'Schedule request was not confirmed. Read the same schedule and reconcile its setting and revision before another write.':'Research request was not confirmed. Read the same agent/run or recover the original admission key; never start a duplicate blindly.');const result={error:{code:status?`http_${status}`:'request_unconfirmed',message,...(request.key?{request_key:request.key}:{})}};if(options.json)stdout(JSON.stringify(result,null,2)+'\n');else stderr(message+(request.key?'\nOriginal request key: '+request.key:'')+'\n');return 1;}
152
- if(request.command==='feedback'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else stdout(`Feedback: ${displayLine(value.feedback.state)}\nRevision: ${value.feedback.revision}\nRating: ${displayLine(value.feedback.rating??'none')}\n${value.feedback.note===null?'':displayText(value.feedback.note)+'\n'}Customer feedback is not independent factual proof.\n`);return 0;}
153
- if(request.command==='source'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else stdout(`Saved source context: ${displayLine(value.source.id)}\n${displayLine(value.source.title)}\n${displayLine(value.source.url)}\nObserved: ${displayLine(value.source.observed_at)}\nThis is the admitted evidence window, which may be part of a page.\n\n${displayText(value.source.text)}\n`);return 0;}
154
- if(request.command==='runs'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{for(const run of value.items)stdout(`${displayLine(run.id)}\t${displayLine(run.status)}\t${displayLine(run.created_at)}\n`);stderr(`Next cursor: ${displayLine(value.next_cursor)}\nMore admitted runs: ${value.has_more?'yes':'no'}\nRetain pending run IDs and read each exact run until terminal.\n`);}return 0;}
155
- if(request.command==='schedule'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else stdout(`Agent: ${displayLine(value.agent_id)}\nSchedule revision: ${value.revision}\nDaily research: ${value.enabled?'enabled':'disabled'}\nNext scheduled: ${displayLine(value.next_scheduled_at??'none')}\nBlocked: ${displayLine(value.blocked_reason??'no')}\nDeferred: ${displayLine(value.deferred_reason??'no')}\n`);return 0;}
156
- if(request.command==='repeats'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{stdout(`Run: ${displayLine(value.run_id)}\nExact-repeat assessment: ${displayLine(value.status)}\n`);for(const item of value.items)stdout(`${displayLine(item.finding_id)}\t${displayLine(item.classification)}\tfirst seen ${displayLine(item.first_seen_run_id)} / ${displayLine(item.first_seen_finding_id)}\n`);stdout('Exact repetition is not a judgment of business novelty; all saved findings remain available.\n');}return 0;}
157
- if(request.command==='list'){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{for(const agent of value.items)stdout(`${displayLine(agent.id)}\tr${agent.revision}\t${displayLine(agent.status)}\t${displayLine(agent.customer.name)}\n`);if(value.next_cursor)stderr('Next cursor: '+displayLine(value.next_cursor)+'\n');}return 0;}
158
- if(['show','pause','resume'].includes(request.command)){if(options.json)stdout(JSON.stringify(value,null,2)+'\n');else{stdout(`Agent: ${displayLine(value.id)}\nCustomer: ${displayLine(value.customer.name)}\nGoal: ${displayText(value.goal)}\nRevision: ${value.revision}\nStatus: ${displayLine(value.status)}\n`);if(value.latest_run)renderRun(value.latest_run,stdout);}return 0;}
159
- let run=request.command==='agent-run'?value:value.run,output=value;
160
- if(request.wait){const waited=await withInterrupt(signal=>waitAgentRun(client,run,{...(options.timeout===undefined?{}:{timeout:request.timeout}),signal}));run=waited.run;output=request.command==='agent-run'?waited:{...value,...waited};}
161
- if(options.json)stdout(JSON.stringify(output,null,2)+'\n');else{renderRun(run,stdout);if(value.replayed)stderr('Recovered the original admission receipt; read its run for current findings.\n');if(request.key)stderr('Original request key: '+request.key+'\n');}
162
- if(output.wait&&!['completed','partial'].includes(output.wait.outcome)){if(output.wait.error&&!options.json)stderr(output.wait.error.message+'\n');stderr('Follow the same agent and run to continue. Waiting does not cancel server work.\n');return 1;}
163
- return request.wait||request.command==='agent-run'?run.status==='completed'?0:run.status==='partial'?2:ACTIVE.has(run.status)&&!request.wait?0:1:0;
164
- }
@@ -1,3 +0,0 @@
1
- import {createHash} from 'node:crypto';
2
- import {readAnswerContract} from './answer-contract.mjs';
3
- export const readResearchAnswerContract=run=>readAnswerContract(run,text=>createHash('sha256').update(text,'utf8').digest('hex'));
@@ -1,7 +0,0 @@
1
- import {createHash} from 'node:crypto';
2
- import {readResearchAnswerContract} from './research-answer-contract.mjs';
3
- import {readAnswerView} from './answer-view.mjs';
4
- export function readResearchAnswerView(run){
5
- if(!Object.hasOwn(run.usage,'answer_view'))return {state:'absent',view:null,sections:[]};
6
- return readAnswerView(run,readResearchAnswerContract(run),text=>createHash('sha256').update(text,'utf8').digest('hex'));
7
- }