@getopencrawl/cli 0.0.0-stage → 0.1.1

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.
Files changed (57) hide show
  1. package/README.md +322 -2
  2. package/bin/opencrawl.mjs +553 -0
  3. package/browser.mjs +166 -0
  4. package/package.json +25 -4
  5. package/src/accounts.test.mjs +223 -0
  6. package/src/agent.mjs +95 -0
  7. package/src/agent.test.mjs +58 -0
  8. package/src/answer-contract.mjs +112 -0
  9. package/src/answer-view.mjs +118 -0
  10. package/src/browser.test.mjs +72 -0
  11. package/src/capture.mjs +273 -0
  12. package/src/capture.test.mjs +171 -0
  13. package/src/client.mjs +183 -0
  14. package/src/client.test.mjs +34 -0
  15. package/src/connect.mjs +230 -0
  16. package/src/connect.test.mjs +131 -0
  17. package/src/context.mjs +34 -0
  18. package/src/context.test.mjs +32 -0
  19. package/src/display.mjs +4 -0
  20. package/src/fixtures/letter-connections.json +59 -0
  21. package/src/fixtures/product-monitor-contract.json +312 -0
  22. package/src/fixtures/research-completion-hosted.json +502 -0
  23. package/src/fixtures/research-completion-v1.json +58 -0
  24. package/src/fixtures/research-completion-v2.json +33 -0
  25. package/src/fixtures/research-presentation-hosted.json +720 -0
  26. package/src/jobs.mjs +117 -0
  27. package/src/jobs.test.mjs +67 -0
  28. package/src/mcp-conversations.test.mjs +80 -0
  29. package/src/monitors.mjs +146 -0
  30. package/src/monitors.test.mjs +62 -0
  31. package/src/product-letter-capabilities.mjs +71 -0
  32. package/src/product-letter-read.mjs +178 -0
  33. package/src/product-monitor-login.test.mjs +140 -0
  34. package/src/product-monitors.mjs +321 -0
  35. package/src/product-monitors.test.mjs +69 -0
  36. package/src/research-agent-errors.test.mjs +110 -0
  37. package/src/research-agent-feedback.test.mjs +28 -0
  38. package/src/research-agent.mjs +164 -0
  39. package/src/research-agent.test.mjs +231 -0
  40. package/src/research-answer-contract.mjs +3 -0
  41. package/src/research-answer-contract.test.mjs +14 -0
  42. package/src/research-answer-view.mjs +7 -0
  43. package/src/research-answer-view.test.mjs +27 -0
  44. package/src/research-completion.mjs +94 -0
  45. package/src/research-completion.test.mjs +110 -0
  46. package/src/research-event.mjs +39 -0
  47. package/src/research-event.test.mjs +57 -0
  48. package/src/research-presentation.test.mjs +56 -0
  49. package/src/research.mjs +141 -0
  50. package/src/research.test.mjs +97 -0
  51. package/src/sessions.mjs +165 -0
  52. package/src/sessions.test.mjs +151 -0
  53. package/src/tasks.mjs +115 -0
  54. package/src/tasks.test.mjs +83 -0
  55. package/src/upload-commands.test.mjs +140 -0
  56. package/src/uploads.mjs +141 -0
  57. package/src/uploads.test.mjs +64 -0
@@ -0,0 +1,171 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import { spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { validateCaptureReceipt } from './capture.mjs';
10
+
11
+ const SCRIPT=fileURLToPath(new URL('../bin/opencrawl.mjs',import.meta.url));
12
+ const identity={user:{id:'usr_'+'a'.repeat(32)},workspace:{id:'ws_'+'b'.repeat(32)}};
13
+ const doc='doc_'+'c'.repeat(32),key='oc_synthetic_capture';
14
+ function run(args,env,cwd,input='') {
15
+ return new Promise((resolve,reject)=>{
16
+ const child=spawn(process.execPath,[SCRIPT,...args],{env,cwd,stdio:['pipe','pipe','pipe']});let stdout='',stderr='';
17
+ const timer=setTimeout(()=>child.kill('SIGKILL'),15000);
18
+ child.stdout.on('data',chunk=>stdout+=chunk);child.stderr.on('data',chunk=>stderr+=chunk);
19
+ child.on('error',reject);child.on('close',code=>{clearTimeout(timer);resolve({code,stdout,stderr});});child.stdin.end(input);
20
+ });
21
+ }
22
+ async function fixture(t,{provider='codex',capture=true,environment=false}={}) {
23
+ const dir=await fs.mkdtemp(path.join(os.tmpdir(),'oc-capture-')),config=path.join(dir,'config'),project=path.join(dir,'project');await fs.mkdir(project);
24
+ const env={...process.env};for(const name of Object.keys(env))if(name.startsWith('OPENCRAWL_'))delete env[name];env.OPENCRAWL_CONFIG_DIR=config;
25
+ const calls=[],sessions=new Map();let behavior='ok',who=identity;
26
+ const server=http.createServer(async(req,res)=>{
27
+ let raw='';for await(const chunk of req)raw+=chunk;const body=raw?JSON.parse(raw):null;calls.push({route:req.url,body});
28
+ const reply=(status,value)=>{res.writeHead(status,{'Content-Type':'application/json'});res.end(JSON.stringify(value));};
29
+ if(req.headers.authorization!=='Bearer '+key)return reply(401,{detail:'PRIVATE_PROVIDER_ERROR'});
30
+ if(req.url==='/api/auth/me')return reply(200,who);
31
+ if(req.url==='/api/v1/context')return reply(200,{context:'[S1] Earlier synthetic evidence'});
32
+ if(req.url==='/mcp')return reply(200,{jsonrpc:'2.0',id:body.id,result:{tools:[]}});
33
+ if(req.url==='/api/v1/sessions/events') {
34
+ if(['401','403','409','410','413','422','429','500'].includes(behavior))return reply(Number(behavior),{detail:{code:'synthetic',message:'PRIVATE_PROVIDER_ERROR'}});
35
+ let rows=sessions.get(body.session_id)||new Map(),accepted=0,replayed=0;
36
+ for(const event of body.events){const prior=rows.get(event.id);if(prior){if(JSON.stringify(prior)!==JSON.stringify(event))return reply(409,{detail:'changed'});replayed++;}else{rows.set(event.id,event);accepted++;}}
37
+ sessions.set(body.session_id,rows);
38
+ if(behavior==='lost'){req.socket.destroy();return;}
39
+ return reply(200,{document_id:doc,lexical_ready:true,semantic_ready:false,semantic:{status:'pending',indexed_chunks:0,total_chunks:1},event_count:rows.size,accepted_events:accepted,replayed_events:replayed});
40
+ }
41
+ if(req.url.startsWith('/api/v1/sessions/events/status?')) {
42
+ if(behavior==='slow_status')await new Promise(resolve=>setTimeout(resolve,1000));
43
+ if(behavior==='500')return reply(500,{detail:'PRIVATE_PROVIDER_ERROR'});
44
+ if(behavior==='410')return reply(410,{detail:{code:'session_deleted',message:'Deleted'}});
45
+ const rows=sessions.get(new URL(req.url,'http://localhost').searchParams.get('session_id'));
46
+ return rows?reply(200,{document_id:doc,lexical_ready:true,semantic_ready:true,semantic:{status:'ready',indexed_chunks:1,total_chunks:1},event_count:rows.size}):reply(404,{detail:'Unknown'});
47
+ }
48
+ reply(404,{});
49
+ });await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const url='http://127.0.0.1:'+server.address().port;
50
+ t.after(async()=>{await new Promise(resolve=>server.close(resolve));await fs.rm(dir,{recursive:true,force:true});});
51
+ let bound;
52
+ if(environment){env.OPENCRAWL_TEST_CAPTURE_KEY=key;bound=['--credential-env','OPENCRAWL_TEST_CAPTURE_KEY','--server',url];}
53
+ else {assert.equal((await run(['login','--server',url],env,project,key+'\n')).code,0);bound=[];}
54
+ const installed=await run(['install',provider,...(capture?['--capture']:[]),...bound],env,project);assert.equal(installed.code,0,installed.stderr);
55
+ const settingsFile=path.join(project,provider==='codex'?'.codex/hooks.json':'.claude/settings.local.json');
56
+ let hooks={};try{hooks=JSON.parse(await fs.readFile(settingsFile,'utf8')).hooks||{};}catch(error){if(error.code!=='ENOENT')throw error;}
57
+ const entry=hooks.UserPromptSubmit?.flatMap(group=>group.hooks).find(hook=>hook.command.includes('opencrawl-managed-capture'));
58
+ const configValue=environment?null:JSON.parse(await fs.readFile(path.join(config,'config.json')));
59
+ const args=environment?[...bound,'--expect-user',identity.user.id,'--expect-workspace',identity.workspace.id]:['--account',configValue.activeProfile,'--server',url];
60
+ const command=(name,...rest)=>run([name,...rest,...args],env,project);
61
+ const hook=(event,extra={},overrides={})=>run(['capture-hook','--provider',provider,'--project',project,...args],{...env,...overrides},project,JSON.stringify({session_id:'synthetic-session',turn_id:'turn-1',cwd:project,hook_event_name:event,...extra}));
62
+ const readState=async()=>{const file=(await fs.readdir(path.join(config,'capture'))).find(value=>value.endsWith('.json'));const location=path.join(config,'capture',file);return{file:location,value:JSON.parse(await fs.readFile(location))};};
63
+ return{dir,config,project,env,url,args,calls,sessions,hooks,entry,installed,command,hook,readState,setBehavior:value=>behavior=value,setIdentity:value=>who=value};
64
+ }
65
+
66
+ test('retrieval-only installs do not create capture hooks or spool',async t=>{
67
+ const f=await fixture(t,{capture:false});assert.equal(f.entry,undefined);await assert.rejects(fs.stat(path.join(f.config,'capture')),{code:'ENOENT'});
68
+ });
69
+ for(const provider of ['claude-code','codex'])test(`${provider} capture install is private, pinned, idempotent and preserves other hooks`,async t=>{
70
+ const f=await fixture(t,{provider});assert.ok(f.entry);assert.equal(f.entry.timeout,10);assert.ok(f.hooks.Stop);
71
+ assert.ok(f.entry.command.includes('--account')&&f.entry.command.includes('--project')&&!f.entry.command.includes(key));
72
+ const file=path.join(f.project,provider==='codex'?'.codex/hooks.json':'.claude/settings.local.json');const settings=JSON.parse(await fs.readFile(file));settings.hooks.Stop.unshift({hooks:[{type:'command',command:'echo unrelated'}]});await fs.writeFile(file,JSON.stringify(settings));
73
+ assert.equal((await run(['install',provider,'--capture'],f.env,f.project)).code,0);
74
+ assert.equal((await run(['install',provider,'--capture'],f.env,f.project)).code,0);
75
+ const current=JSON.parse(await fs.readFile(file));assert.equal(current.hooks.Stop.flatMap(group=>group.hooks).length,2);assert.equal(current.hooks.Stop[0].hooks[0].command,'echo unrelated');
76
+ const state=await f.readState();assert.equal((await fs.stat(state.file)).mode&0o777,0o600);assert.equal(state.value.sessions.length,0);
77
+ });
78
+ test('prompt retrieves old evidence first then captures; Stop is quiet and deduplicated',async t=>{
79
+ const f=await fixture(t);f.calls.length=0;
80
+ const prompt=await f.hook('UserPromptSubmit',{prompt:'The synthetic user asks a question.'});assert.equal(prompt.code,0);assert.match(JSON.parse(prompt.stdout).hookSpecificOutput.additionalContext,/Earlier synthetic/);
81
+ assert.ok(f.calls.findIndex(call=>call.route==='/api/v1/context')<f.calls.findIndex(call=>call.route==='/api/v1/sessions/events'));
82
+ assert.deepEqual(JSON.parse((await f.hook('Stop',{last_assistant_message:'Synthetic final answer'})).stdout),{});
83
+ await f.hook('Stop',{last_assistant_message:'Synthetic final answer'});await f.hook('UserPromptSubmit',{prompt:'The synthetic user asks a question.'});
84
+ assert.equal([...f.sessions.values()][0].size,2);const state=(await f.readState()).value;
85
+ assert.equal(state.sessions[0].events.length,2);assert.ok(state.sessions[0].events.every(event=>event.content===undefined));
86
+ assert.equal(state.sessions[0].receipt.semantic_ready,false);
87
+ const result=await f.command('capture','status','--provider','codex','--json');const observed=JSON.parse(result.stdout);assert.equal(observed.sessions[0].last_acknowledgement.semantic_ready,true);assert.ok(!result.stdout.includes('Synthetic final answer'));
88
+ });
89
+ test('new Codex turns append stable distinct events even when prompt text repeats',async t=>{
90
+ const f=await fixture(t);for(const id of ['turn-1','turn-2']){await f.hook('UserPromptSubmit',{turn_id:id,prompt:'Same exact question'});await f.hook('Stop',{turn_id:id,last_assistant_message:'Same answer'});}
91
+ const rows=[...f.sessions.values()][0];assert.equal(rows.size,4);assert.deepEqual([...rows.values()].map(event=>event.sequence),[0,1,2,3]);
92
+ });
93
+ test('Claude pairs new prompts with Stop without reading transcript paths',async t=>{
94
+ const f=await fixture(t,{provider:'claude-code'});for(let index=0;index<2;index++){await f.hook('UserPromptSubmit',{turn_id:undefined,prompt:'Repeat prompt',transcript_path:'/does/not/exist'});await f.hook('Stop',{turn_id:undefined,last_assistant_message:'Repeat answer',transcript_path:'/does/not/exist'});await f.hook('Stop',{turn_id:undefined,last_assistant_message:'Repeat answer'});}
95
+ assert.equal([...f.sessions.values()][0].size,4);
96
+ });
97
+ test('different project cwd is rejected before retrieval or event publication',async t=>{
98
+ const f=await fixture(t);const other=path.join(f.dir,'other');await fs.mkdir(other);f.calls.length=0;
99
+ const result=await f.hook('UserPromptSubmit',{cwd:other,prompt:'PRIVATE_OTHER_PROJECT'});assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),{});assert.equal(f.calls.length,0);assert.ok(!JSON.stringify((await f.readState()).value).includes('PRIVATE_OTHER_PROJECT'));
100
+ });
101
+ test('lost acknowledgement remains queued and explicit flush replays exact events once',async t=>{
102
+ const f=await fixture(t);f.setBehavior('lost');await f.hook('UserPromptSubmit',{prompt:'Durable synthetic event'});let state=(await f.readState()).value;
103
+ assert.equal(state.sessions[0].events[0].state,'queued');assert.equal(state.sessions[0].receipt,undefined);assert.equal([...f.sessions.values()][0].size,1);
104
+ f.setBehavior('ok');const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,0,result.stderr);assert.equal([...f.sessions.values()][0].size,1);state=(await f.readState()).value;assert.equal(state.sessions[0].events[0].state,'acknowledged');
105
+ const posts=f.calls.filter(call=>call.route==='/api/v1/sessions/events');assert.deepEqual(posts[0].body,posts[1].body);
106
+ });
107
+ for(const status of ['409','410'])test(`${status} permanently blocks this session without recreating its ID`,async t=>{
108
+ const f=await fixture(t);f.setBehavior(status);await f.hook('UserPromptSubmit',{prompt:'Synthetic blocked event'});const before=f.calls.filter(call=>call.route==='/api/v1/sessions/events').length;
109
+ f.setBehavior('ok');const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,1);await f.hook('Stop',{last_assistant_message:'No resurrection'});assert.equal(f.calls.filter(call=>call.route==='/api/v1/sessions/events').length,before);assert.ok(!result.stdout.includes('PRIVATE_PROVIDER_ERROR'));
110
+ });
111
+ test('capture pause prevents collection and resume starts only new hook messages',async t=>{
112
+ const f=await fixture(t);assert.equal((await f.command('capture','pause','--provider','codex')).code,0);await f.hook('UserPromptSubmit',{prompt:'Do not collect'});assert.equal((await f.readState()).value.sessions.length,0);
113
+ await f.command('capture','resume','--provider','codex');await f.hook('UserPromptSubmit',{prompt:'Collect this future message'});assert.equal([...f.sessions.values()][0].size,1);
114
+ });
115
+ test('deleted session remains permanently blocked through identity failure and recovery',async t=>{
116
+ const f=await fixture(t,{environment:true});f.setBehavior('410');await f.hook('UserPromptSubmit',{prompt:'Deleted synthetic session'});
117
+ const before=f.calls.filter(call=>call.route==='/api/v1/sessions/events').length;
118
+ f.setIdentity({...identity,user:{id:'usr_'+'d'.repeat(32)}});await f.hook('Stop',{last_assistant_message:'Must never be collected'});
119
+ assert.equal((await f.readState()).value.sessions[0].issue,'session_deleted');
120
+ f.setIdentity(identity);f.setBehavior('ok');await f.hook('UserPromptSubmit',{turn_id:'turn-2',prompt:'Must not restart deleted capture'});
121
+ const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,1);
122
+ assert.equal(f.calls.filter(call=>call.route==='/api/v1/sessions/events').length,before);
123
+ const session=(await f.readState()).value.sessions[0];assert.equal(session.issue,'session_deleted');assert.equal(session.events.length,1);
124
+ });
125
+ test('environment install saves no key and rejects a changed account before sending content',async t=>{
126
+ const f=await fixture(t,{environment:true});assert.ok(f.entry.command.includes('--expect-user'));assert.ok(!f.entry.command.includes(key));await assert.rejects(fs.stat(path.join(f.config,'config.json')),{code:'ENOENT'});
127
+ for(const argument of ['capture','status','--provider','codex','--project',f.project,'--credential-env','OPENCRAWL_TEST_CAPTURE_KEY','--server',f.url,'--expect-user',identity.user.id,'--expect-workspace',identity.workspace.id])assert.ok(f.installed.stdout.includes(argument));
128
+ assert.ok(!f.installed.stdout.includes(key));
129
+ await f.hook('UserPromptSubmit',{prompt:'Initial synthetic event'});const before=f.calls.filter(call=>call.route==='/api/v1/sessions/events'||call.route==='/api/v1/context').length;
130
+ f.setIdentity({...identity,user:{id:'usr_'+'d'.repeat(32)}});await f.hook('Stop',{last_assistant_message:'Must stay locally queued'});assert.equal(f.calls.filter(call=>call.route==='/api/v1/sessions/events'||call.route==='/api/v1/context').length,before);
131
+ const result=await f.command('capture','flush','--provider','codex','--json');assert.equal(result.code,1);assert.ok(!result.stdout.includes(key));
132
+ });
133
+ test('missing environment key does not fall back and hooks remain nonblocking',async t=>{
134
+ const f=await fixture(t,{environment:true});f.calls.length=0;const result=await f.hook('UserPromptSubmit',{prompt:'No key'}, {OPENCRAWL_TEST_CAPTURE_KEY:''});assert.equal(result.code,0);assert.deepEqual(JSON.parse(result.stdout),{});assert.equal(f.calls.length,0);
135
+ });
136
+ test('environment-bound retrieval-only hook and MCP reject changed workspace before private queries',async t=>{
137
+ const f=await fixture(t,{environment:true,capture:false,provider:'claude-code'});
138
+ const prompt=JSON.stringify({prompt:'Private synthetic retrieval query'}),rpc='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"opencrawl_search","arguments":{"query":"Private synthetic query"}}}\n';
139
+ assert.match((await f.command('hook')).stdout,/^$/);
140
+ assert.equal((await run(['mcp',...f.args],f.env,f.project,rpc)).code,0);
141
+ assert.ok((await run(['hook',...f.args],f.env,f.project,prompt)).stdout.includes('Earlier synthetic'));
142
+ f.setIdentity({...identity,workspace:{id:'ws_'+'e'.repeat(32)}});f.calls.length=0;
143
+ const hooked=await run(['hook',...f.args],f.env,f.project,prompt);assert.equal(hooked.code,0);assert.equal(hooked.stdout,'');
144
+ assert.equal((await run(['mcp',...f.args],f.env,f.project,rpc)).code,1);
145
+ assert.ok(f.calls.every(call=>call.route==='/api/auth/me'));
146
+ });
147
+ test('oversized messages and orphan Stop cannot create partial conversations',async t=>{
148
+ const f=await fixture(t);await f.hook('Stop',{last_assistant_message:'Orphan'});assert.equal(f.sessions.size,0);
149
+ const result=await f.hook('UserPromptSubmit',{prompt:'x'.repeat(65537)});assert.equal(result.code,0);assert.equal(f.sessions.size,0);assert.equal((await f.readState()).value.sessions.length,0);
150
+ });
151
+ test('invalid receipt does not establish indexing',()=>{
152
+ assert.throws(()=>validateCaptureReceipt({document_id:doc,lexical_ready:true,semantic_ready:false,semantic:{status:'pending',indexed_chunks:0,total_chunks:1},event_count:1,accepted_events:0,replayed_events:0},1));
153
+ });
154
+ test('status network wait releases capture lock for new hook events',async t=>{
155
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'First message'});f.setBehavior('slow_status');
156
+ const pending=f.command('capture','status','--provider','codex','--json');
157
+ for(let index=0;index<100&&!f.calls.some(call=>call.route.includes('/events/status?'));index++)await new Promise(resolve=>setTimeout(resolve,10));
158
+ const reply=await f.hook('Stop',{last_assistant_message:'Captured during a status request'});assert.equal(reply.stderr,'');assert.equal([...f.sessions.values()][0].size,2);assert.equal((await pending).code,0);
159
+ });
160
+ test('failed current verification cannot relabel historical acknowledgements as currently ready',async t=>{
161
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'Earlier acknowledged message'});f.setBehavior('500');
162
+ const result=await f.command('capture','status','--provider','codex','--json'),session=JSON.parse(result.stdout).sessions[0];
163
+ assert.equal(result.code,1);assert.equal(session.state,'verification_unavailable');assert.equal(session.last_acknowledgement.lexical_ready,true);assert.equal(session.current_verification.state,'unavailable');assert.ok(!result.stdout.includes('PRIVATE_PROVIDER_ERROR'));
164
+ });
165
+ test('encoded oversized control characters cannot leave an unsendable pending event',async t=>{
166
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'\u0001'.repeat(50000)});assert.equal(f.sessions.size,0);assert.equal((await f.readState()).value.sessions.length,0);
167
+ });
168
+ test('changed repeated event is blocked without silently replacing captured content',async t=>{
169
+ const f=await fixture(t);await f.hook('UserPromptSubmit',{prompt:'Original event'});await f.hook('UserPromptSubmit',{prompt:'Changed payload for same turn'});
170
+ const state=(await f.readState()).value;assert.equal(state.sessions[0].issue,'event_conflict');assert.equal([...f.sessions.values()][0].size,1);
171
+ });
package/src/client.mjs ADDED
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs/promises';
2
+ import crypto from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ export const configDir = () => process.env.OPENCRAWL_CONFIG_DIR || path.join(os.homedir(), '.config', 'opencrawl');
7
+ export async function readConfig() {
8
+ try {
9
+ const value = JSON.parse(await fs.readFile(path.join(configDir(), 'config.json'), 'utf8'));
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid configuration');
11
+ if (value.version !== undefined && value.version !== 2) throw new Error('Unsupported configuration version');
12
+ return value;
13
+ }
14
+ catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('OpenCrawl config is invalid. Repair config.json or choose a separate --config-dir before signing in.'); }
15
+ }
16
+ export async function writeConfig(value) {
17
+ const dir = configDir();
18
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
19
+ const file = path.join(dir, 'config.json');
20
+ const temp = `${file}.${process.pid}.tmp`;
21
+ await fs.writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
22
+ await fs.rename(temp, file);
23
+ await fs.chmod(file, 0o600);
24
+ }
25
+ export function validateBaseUrl(input) {
26
+ if (typeof input !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#\\]+\/?$/.test(input.trim()))
27
+ throw new Error('Server must be an origin without credentials, a path, query parameters, or a fragment.');
28
+ let url;
29
+ try { url = new URL(input); } catch { throw new Error('Server must be a valid HTTP or HTTPS URL.'); }
30
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Server must be an origin without credentials, a path, query parameters, or a fragment.');
31
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
32
+ throw new Error('Use HTTPS, or HTTP on localhost.');
33
+ }
34
+ return url.origin;
35
+ }
36
+
37
+ const profileId = (server, user, workspace) => 'p_' + crypto.createHash('sha256').update(`${server}\0${user}\0${workspace}`).digest('hex').slice(0, 24);
38
+ function validToken(token) {
39
+ if (typeof token !== 'string' || !token.trim() || token.length > 8192 || /\s/.test(token.trim())) throw new Error('The explicitly selected API key is empty or invalid. Sign in again.');
40
+ return token.trim();
41
+ }
42
+ function selectedProfile(config, id) {
43
+ if (!id) return null;
44
+ if (config.version !== 2 || typeof id !== 'string' || !/^p_[a-f0-9]{24}$/.test(id) || !Object.hasOwn(config.profiles || {}, id))
45
+ throw new Error('Saved account profile not found. Run opencrawl profiles or sign in again.');
46
+ const value = config.profiles[id];
47
+ if (!value || typeof value !== 'object') throw new Error('Saved account profile is invalid. Sign in again.');
48
+ const server = validateBaseUrl(value.server);
49
+ if (value.origin !== server || !/^usr_[a-f0-9]{32}$/.test(value.userId || '') || !/^ws_[a-f0-9]{32}$/.test(value.workspaceId || '') || id !== profileId(server, value.userId, value.workspaceId))
50
+ throw new Error('Saved credential binding is invalid. Sign in again.');
51
+ return { ...value, server, id };
52
+ }
53
+
54
+ /** Pure selection: a saved credential never follows a changed destination. */
55
+ export function resolveCredentials(config, overrides = {}, environment = process.env) {
56
+ const pinned = overrides.account !== undefined;
57
+ const profile = pinned ? selectedProfile(config, overrides.account) : config.version === 2 ? selectedProfile(config, config.activeProfile) : null;
58
+ const baseUrl = validateBaseUrl(overrides.server ?? (pinned ? profile.server : environment.OPENCRAWL_URL ?? config.server ?? profile?.server ?? 'http://localhost:8000'));
59
+ if (pinned && baseUrl !== profile.server) throw new Error('The selected account is bound to another server. Choose that server’s account or sign in there first.');
60
+ // An explicit programmatic token is an intentional destination choice. A
61
+ // pinned account uses its own credential rather than ambient shell defaults.
62
+ const explicit = overrides.token !== undefined ? overrides.token : !pinned && environment.OPENCRAWL_API_KEY !== undefined ? environment.OPENCRAWL_API_KEY : undefined;
63
+ if (explicit !== undefined) return { baseUrl, token: validToken(explicit), credentialSource: 'explicit', profile: null };
64
+ if (profile?.apiKey && profile.origin === baseUrl) return { baseUrl, token: validToken(profile.apiKey), credentialSource: 'profile', profile };
65
+ const credentialError = config.apiKey ? 'This saved key predates server binding. Run opencrawl login with an explicitly supplied key before using it.'
66
+ : profile?.apiKey ? 'The saved key belongs to another server. Sign in to this server explicitly; no saved key was sent.'
67
+ : pinned ? 'This account has no saved key. Sign in to the same server and workspace again, or choose another account.'
68
+ : 'Sign in with opencrawl login, or explicitly set OPENCRAWL_API_KEY for the intended server.';
69
+ return { baseUrl, token: null, credentialSource: 'none', profile, credentialError };
70
+ }
71
+
72
+ export function loggedInConfig(config, server, apiKey, identity) {
73
+ server = validateBaseUrl(server);
74
+ if (!/^usr_[a-f0-9]{32}$/.test(identity?.user?.id || '') || !/^ws_[a-f0-9]{32}$/.test(identity?.workspace?.id || ''))
75
+ throw new Error('The server did not return a valid workspace identity. No credential was saved.');
76
+ const id = profileId(server, identity.user.id, identity.workspace.id);
77
+ const next = { ...config, version: 2, server, activeProfile: id, profiles: { ...(config.version === 2 ? config.profiles : {}),
78
+ [id]: { server, origin: server, userId: identity.user.id, workspaceId: identity.workspace.id,
79
+ userName: String(identity.user.name || '').slice(0, 100), workspaceName: String(identity.workspace.name || '').slice(0, 100), apiKey: validToken(apiKey) } } };
80
+ delete next.apiKey;
81
+ return next;
82
+ }
83
+
84
+ export function profileSummaries(config) {
85
+ return Object.keys(config.version === 2 ? config.profiles || {} : {}).map(id => {
86
+ const profile = selectedProfile(config, id);
87
+ return { id, server: profile.server, user: profile.userName, workspace: profile.workspaceName,
88
+ user_id: profile.userId, workspace_id: profile.workspaceId, configured: !!profile.apiKey, active: id === config.activeProfile };
89
+ });
90
+ }
91
+
92
+ export function loggedOutConfig(config, account) {
93
+ const next = { ...config, profiles: { ...(config.profiles || {}) } };
94
+ const profile = account !== undefined ? selectedProfile(config, account) : config.version === 2 ? selectedProfile(config, config.activeProfile) : null;
95
+ if (profile) { next.profiles[profile.id] = { ...config.profiles[profile.id] }; delete next.profiles[profile.id].apiKey; }
96
+ delete next.apiKey;
97
+ return next;
98
+ }
99
+
100
+ export async function createClient(overrides = {}) {
101
+ const config = await readConfig();
102
+ const { baseUrl, token, profile, credentialSource, credentialError } = resolveCredentials(config, overrides);
103
+ const publicProfile = profile ? Object.fromEntries(['id', 'server', 'userId', 'workspaceId', 'userName', 'workspaceName'].map(key => [key, profile[key]])) : null;
104
+ return { baseUrl, configured: Boolean(token), profile: publicProfile, credentialSource, credentialError, identity: token ? crypto.createHash('sha256').update(token).digest('hex').slice(0, 16) : 'anonymous', async request(route, { method = 'GET', body, authenticated = true, timeout = 30000, protocolVersion, channel, maxResponseBytes, signal, idempotencyKey, responseType } = {}) {
105
+ // The exported client must not let a caller turn an origin into URL userinfo
106
+ // or replace its port/host by supplying something other than a path.
107
+ if (typeof route !== 'string' || !/^\/(?!\/)/.test(route) || /[\\\u0000-\u0020\u007f]/.test(route))
108
+ throw new Error('Request routes must be absolute paths on the selected server.');
109
+ let destination;
110
+ try { destination = new URL(route, baseUrl); } catch { throw new Error('Request route is invalid.'); }
111
+ if (destination.origin !== baseUrl || destination.username || destination.password)
112
+ throw new Error('Request routes cannot change the selected server.');
113
+ if (authenticated && !token) throw new Error(credentialError);
114
+ if(responseType!==undefined&&responseType!=='png')throw new Error('Unsupported response type.');
115
+ const headers = { Accept: responseType==='png'?'image/png':route === '/mcp' ? 'application/json, text/event-stream' : 'application/json', 'X-OpenCrawl-Client': channel || (route === '/mcp' ? 'mcp' : 'cli') };
116
+ if (protocolVersion) headers['MCP-Protocol-Version'] = protocolVersion;
117
+ if (idempotencyKey !== undefined) {
118
+ if (!(method === 'POST' || method === 'GET' && destination.pathname === '/api/research/agents/receipts/by-key') || typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(idempotencyKey)) throw new Error('Invalid idempotency key for this request.');
119
+ headers['Idempotency-Key'] = idempotencyKey;
120
+ }
121
+ if (authenticated) headers.Authorization = `Bearer ${token}`;
122
+ const multipart = typeof FormData !== 'undefined' && body instanceof FormData;
123
+ if (body !== undefined && !multipart) headers['Content-Type'] = 'application/json';
124
+ const controller = new AbortController();
125
+ const abort = () => controller.abort(signal?.reason);
126
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
127
+ const timer = setTimeout(() => controller.abort(new DOMException('Timed out', 'TimeoutError')), timeout);
128
+ timer.unref?.();
129
+ try {
130
+ let response;
131
+ try {
132
+ response = await fetch(destination.href, { method, headers, body: body === undefined ? undefined : multipart ? body : JSON.stringify(body),
133
+ signal: controller.signal, redirect: 'error' });
134
+ } catch (error) {
135
+ const failure=new Error(error.name === 'TimeoutError' ? 'OpenCrawl request timed out.' : 'Cannot reach OpenCrawl. Check your server URL and connection.');
136
+ // A user's own interruption is final; a lost connection or timeout may succeed on another try.
137
+ if(!signal?.aborted)failure.transient=true;
138
+ throw failure;
139
+ }
140
+ if(responseType==='png'&&response.ok){
141
+ const cap=2*1024*1024;if(response.headers.get('content-type')?.split(';')[0]?.trim()!=='image/png'||Number(response.headers.get('content-length'))>cap){await response.body?.cancel();throw new Error('Invalid bounded PNG response.');}
142
+ const reader=response.body?.getReader();if(!reader)throw new Error('Missing PNG response.');const bytes=Buffer.alloc(cap);let length=0;const cancel=()=>{void reader.cancel().catch(()=>{});};if(controller.signal.aborted)cancel();else controller.signal.addEventListener('abort',cancel,{once:true});
143
+ try{while(true){const part=await reader.read();if(part.done)break;if(length+part.value.byteLength>cap){await reader.cancel();throw new Error('PNG response exceeds its limit.');}bytes.set(part.value,length);length+=part.value.byteLength;}}finally{controller.signal.removeEventListener('abort',cancel);reader.releaseLock();}
144
+ if(controller.signal.aborted)throw new Error('PNG request cancelled.');
145
+ if(length<33||!bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))||bytes.subarray(12,16).toString()!=='IHDR'||bytes.readUInt32BE(16)<320||bytes.readUInt32BE(16)>1920||bytes.readUInt32BE(20)<240||bytes.readUInt32BE(20)>1080)throw new Error('Invalid PNG signature.');return bytes.subarray(0,length);
146
+ }
147
+ let text;
148
+ if (maxResponseBytes !== undefined) {
149
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > 4 * 1024 * 1024) throw new Error('Invalid response byte limit.');
150
+ const reader=response.body?.getReader();const bytes=Buffer.alloc(maxResponseBytes);let length=0;
151
+ if (reader) {
152
+ try {
153
+ while (true) { const part=await reader.read();if (part.done) break;length+=part.value.byteLength;
154
+ if (length>maxResponseBytes) { await reader.cancel();throw new Error('OpenCrawl response exceeds the allowed byte limit.'); }
155
+ bytes.set(part.value,length-part.value.byteLength);
156
+ }
157
+ } finally { reader.releaseLock(); }
158
+ }
159
+ text=bytes.subarray(0,length).toString('utf8');
160
+ } else text = await response.text();
161
+ let value;
162
+ try { value = text ? JSON.parse(text) : null; } catch {
163
+ // A proxy's error page (for example a 502 while the server restarts) is not JSON.
164
+ const failure=new Error(`Server returned an unexpected response (${response.status}).`);if(!response.ok)failure.status=response.status;throw failure;
165
+ }
166
+ if (!response.ok) {
167
+ const detail = typeof value?.detail === 'string' ? value.detail : typeof value?.error === 'string' ? value.error : response.statusText;
168
+ const error=new Error(`OpenCrawl ${response.status}: ${detail}`);error.status=response.status;error.detail=String(detail||'').slice(0,300);
169
+ if (typeof value?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(value.code)) error.code=value.code;
170
+ throw error;
171
+ }
172
+ return value;
173
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
174
+ }};
175
+ }
176
+ export async function search(query, options = {}) {
177
+ const client = await createClient(options);
178
+ return client.request('/api/v1/search', { method: 'POST', body: { query, limit: options.limit || 8, ...(options.source_ids === undefined ? {} : {source_ids: options.source_ids}), ...(options.customer ? {customer: options.customer} : {}) } });
179
+ }
180
+ export async function context(query, options = {}) {
181
+ const client = await createClient(options);
182
+ return client.request('/api/v1/context', { method: 'POST', body: { query, limit: options.limit || 8, budget: options.budget || 2000, ...(options.source_ids === undefined ? {} : {source_ids: options.source_ids}), ...(options.customer ? {customer: options.customer} : {}) } });
183
+ }
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateBaseUrl, search, context } from './client.mjs';
4
+ test('credentials are never accepted inside the server URL', () => {
5
+ assert.throws(() => validateBaseUrl('https://secret@example.com'));
6
+ assert.throws(() => validateBaseUrl('https://example.com?key=secret'));
7
+ });
8
+ test('exported retrieval helpers distinguish omitted scope from an explicit empty selection', async t => {
9
+ const bodies = [];
10
+ t.mock.method(globalThis, 'fetch', async (_url, options) => {
11
+ bodies.push(JSON.parse(options.body));
12
+ return new Response('{}', {headers: {'content-type': 'application/json'}});
13
+ });
14
+ for (const invoke of [search, context]) {
15
+ const options = {server: 'https://example.test', token: 'oc_synthetic'};
16
+ await invoke('question', options);
17
+ assert.equal(Object.hasOwn(bodies.at(-1), 'source_ids'), false);
18
+ await invoke('question', {...options, source_ids: []});
19
+ assert.deepEqual(bodies.at(-1).source_ids, []);
20
+ await invoke('question', {...options, source_ids: ['src_alpha']});
21
+ assert.deepEqual(bodies.at(-1).source_ids, ['src_alpha']);
22
+ }
23
+ });
24
+ test('remote hosts require transport encryption', () => {
25
+ assert.throws(() => validateBaseUrl('http://example.com'));
26
+ assert.equal(validateBaseUrl('http://127.0.0.1:8000/'), 'http://127.0.0.1:8000');
27
+ assert.equal(validateBaseUrl('https://opencrawl.example/'), 'https://opencrawl.example');
28
+ });
29
+ test('PNG transport is bounded, authenticated and cancellation-aware',async t=>{
30
+ const {createClient}=await import('./client.mjs'),fs=await import('node:fs/promises');const f=JSON.parse(await fs.readFile(new URL('./fixtures/product-monitor-contract.json',import.meta.url)));const png=Buffer.from(f.capture_request.screenshot_png_base64,'base64'),calls=[];
31
+ t.mock.method(globalThis,'fetch',async(url,o)=>{calls.push([url,o]);return new Response(png,{headers:{'content-type':'image/png'}});});const c=await createClient({server:'https://api.example',token:'synthetic-token'});assert.deepEqual(await c.request('/api/v1/product-monitors/snapshots/fixture/image',{responseType:'png'}),png);assert.equal(calls[0][1].headers.Accept,'image/png');assert.equal(calls[0][1].redirect,'error');assert.equal(calls[0][1].headers.Authorization,'Bearer synthetic-token');
32
+ t.mock.method(globalThis,'fetch',async()=>new Response(new Uint8Array(2097153),{headers:{'content-type':'image/png'}}));await assert.rejects(c.request('/api/v1/product-monitors/snapshots/fixture/image',{responseType:'png'}),/limit/);
33
+ let cancelled=false;const abort=new AbortController();t.mock.method(globalThis,'fetch',async()=>new Response(new ReadableStream({start(){setTimeout(()=>abort.abort(),10);},cancel(){cancelled=true}}),{headers:{'content-type':'image/png'}}));await assert.rejects(c.request('/api/v1/product-monitors/snapshots/fixture/image',{responseType:'png',signal:abort.signal}),/cancelled/);assert.equal(cancelled,true);
34
+ });