// P1 acceptance only: run via acceptance.sh in a fresh private user/network namespace. import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import { spawn, execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { initialConfig } from '../../../../packages/model-proxy/src/settings.mjs'; import { ManagementClient } from '../../../../packages/model-proxy/src/client.mjs'; const root = process.argv[2], assets = process.argv[3]; if (!root?.startsWith('/mnt/storage/scratch/tmp/') || process.env.P1_ISOLATED !== 'yes') throw Error('acceptance requires wrapper'); const children=[], servers=[]; const delay=ms=>new Promise(r=>setTimeout(r,ms)); const home=path.join(root,'home');fs.mkdirSync(home,{recursive:true}); const env={HOME:home,PATH:'/usr/bin:/bin',LANG:'C.UTF-8'}; const port=18318, forwardPort=18319; let upstreamCalls=0; const forwarded=[]; const enforce=async(label,key,wanted)=>{const started=Date.now(),statuses=[];for(let i=0;i<50;i++){const r=await request('/v1/models',{key});statuses.push(r.status);if(r.status===wanted){console.log(label,JSON.stringify({elapsedMs:Date.now()-started,statuses,body:wanted===401?r.body:'[models]'}));return;}await delay(100);}throw Error(label+' enforcement absent '+JSON.stringify(statuses));}; async function serve(handler, p){const s=http.createServer(handler);await new Promise(r=>s.listen(p,'127.0.0.1',r));servers.push(s);return s;} async function request(route,{method='GET',key='p1-client',body,management=false}={}){ const r=await fetch(`http://127.0.0.1:${port}${route}`,{method,headers:{...(key?{Authorization:`Bearer ${key}`} : {}),...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(5000)}); const text=await r.text();return {status:r.status,body:text}; } function launch(command,args,extra={}){const out=fs.openSync(path.join(root,`${children.length}.stdout`),'w');const c=spawn(command,args,{cwd:root,env,stdio:['ignore',out,out],...extra});fs.closeSync(out);children.push(c);return c;} async function wait(p){for(let i=0;i<100;i++){try{await fetch(`http://127.0.0.1:${p}`,{signal:AbortSignal.timeout(300)});return;}catch{}await delay(100);}throw Error(`listener ${p} absent`);} const config=initialConfig({dataRoot:root,port,key:'p1-management',sentinel:'p1-client'}); config['api-keys']={claude:[{name:'standin','base-url':'http://127.0.0.1:18401',keys:[{'api-key':'p1-upstream'}]}]}; config.oauth['auth-dir']=path.join(root,'auth'); fs.mkdirSync(path.join(root,'auth'),{mode:0o700});fs.mkdirSync(path.join(root,'static')); fs.copyFileSync(path.join(assets,'management.html'),path.join(root,'static','management.html')); fs.writeFileSync(path.join(root,'config.yaml'),JSON.stringify(config),{mode:0o600}); let sandbox=false; try{ console.log('P1 isolated acceptance root',root,'nice',execFileSync('nice',{encoding:'utf8'}).trim()); console.log('binary sha256',createHash('sha256').update(fs.readFileSync(path.join(assets,'release','cli-proxy-api'))).digest('hex')); await serve((q,s)=>{upstreamCalls++;s.setHeader('Content-Type','application/json');s.end(JSON.stringify({id:'msg_p1',type:'message',role:'assistant',model:'claude-sonnet-5-5',content:[{type:'text',text:'hello'}],stop_reason:'end_turn',usage:{input_tokens:1,output_tokens:1}}));},18401); launch(path.join(assets,'release','cli-proxy-api'),['-config',path.join(root,'config.yaml'),'-local-model'],{env:{...env,MANAGEMENT_STATIC_PATH:path.join(root,'static')}});await wait(port); const management=new ManagementClient({baseUrl:`http://127.0.0.1:${port}`,key:'p1-management',receipt:entry=>console.log('P1 client receipt',JSON.stringify(entry))}); console.log('P1 client real API GET count',(await management.getKeys()).length);console.log('P1 client real API PUT',JSON.stringify(await management.putKeys(['p1-client']))); const panelResponse=await request('/management.html',{key:null});console.log('pinned panel served',panelResponse.status,createHash('sha256').update(panelResponse.body).digest('hex')); const cliHelp=launch(path.join(assets,'release','cli-proxy-api'),['-help']);await new Promise(r=>cliHelp.on('close',r));console.log('pinned login flags',fs.readFileSync(path.join(root,`${children.length-1}.stdout`),'utf8').split('\n').filter((l,i,a)=>/-(claude|codex)-login|-no-browser/.test(l)).join(' | ')); for(const key of ['wrong',null])console.log('wrong/missing key models',key===null?'missing':'wrong',JSON.stringify(await request('/v1/models',{key}))); const before=upstreamCalls;const models=await request('/v1/models');console.log('models local',models.status,'upstream delta',upstreamCalls-before,'body length',models.body.length); const message={model:'claude-sonnet-5-5',max_tokens:32,messages:[{role:'user',content:'hello'}]}; const inferenceBefore=upstreamCalls;console.log('valid messages',JSON.stringify(await request('/v1/messages',{method:'POST',body:message})),'upstream delta',upstreamCalls-inferenceBefore); console.log('wrong messages',JSON.stringify(await request('/v1/messages',{method:'POST',key:'wrong',body:message}))); console.log('initial key list',JSON.stringify(await request('/v8/management/config/access/api-keys',{key:'p1-management'}))); console.log('set empty',JSON.stringify(await request('/v8/management/config/access/api-keys',{method:'PUT',key:'p1-management',body:[]}))); await enforce('empty list settled',null,200); console.log('empty list missing key status',(await request('/v1/models',{key:null})).status); console.log('empty list missing key messages',JSON.stringify(await request('/v1/messages',{method:'POST',key:null,body:message}))); console.log('restore list',JSON.stringify(await request('/v8/management/config/access/api-keys',{method:'PUT',key:'p1-management',body:['p1-client']}))); await enforce('restored list settled wrong key','wrong',401); console.log('revoke client',JSON.stringify(await request('/v8/management/config/access/api-keys',{method:'PUT',key:'p1-management',body:['p1-sentinel']}))); await enforce('revoked key settled','p1-client',401); console.log('revoked key models',JSON.stringify(await request('/v1/models',{key:'p1-client'}))); console.log('revoked messages',JSON.stringify(await request('/v1/messages',{method:'POST',key:'p1-client',body:message}))); console.log('per-key model object attempt',JSON.stringify(await request('/v8/management/config/access/api-keys',{method:'PUT',key:'p1-management',body:[{key:'p1-limited',models:['claude-sonnet-5-5']}]}))); await request('/v8/management/config/access/api-keys',{method:'PUT',key:'p1-management',body:['p1-client']}); await enforce('final restored list settled','wrong',401); console.log('v8 key list',JSON.stringify(await request('/v8/management/config/access/api-keys',{key:'p1-management'}))); for(const provider of ['anthropic','codex'])console.log('oauth start',provider,JSON.stringify(await request(`/v0/management/${provider}-auth-url?is_webui=true`,{key:'p1-management'}))); if(process.argv[4]!=='api-only'){ await serve((q,s)=>{ forwarded.push({method:q.method,path:q.url,origin:q.headers.origin??null,cookie:!!q.headers.cookie}); if(sandbox)s.setHeader('Content-Security-Policy','sandbox allow-scripts allow-forms'); // Minimal /proxy/ prefix strip. Cookies stay host-only SameSite=Lax for browser checks. s.setHeader('Set-Cookie','p1-session=owner; HttpOnly; SameSite=Lax; Path=/'); if(!q.url.startsWith('/proxy/')){s.statusCode=404;s.end('outside prefix');return;} const upstream=http.request({hostname:'127.0.0.1',port,path:q.url.replace(/^\/proxy\//,'/'),method:q.method,headers:{...q.headers,host:`127.0.0.1:${port}`}},r=>{s.statusCode=r.statusCode;for(const [k,v]of Object.entries(r.headers))if(!['content-security-policy','set-cookie','transfer-encoding'].includes(k))s.setHeader(k,v);r.pipe(s);}); upstream.on('error',()=>{s.statusCode=502;s.end();});q.pipe(upstream); },forwardPort); const chrome=launch('/usr/bin/chromium',['--headless','--no-sandbox','--disable-dev-shm-usage','--disable-background-networking','--no-first-run','--remote-allow-origins=*','--remote-debugging-port=19222',`--user-data-dir=${root}/chromium`,'about:blank']);await wait(19222); const targets=await (await fetch('http://127.0.0.1:19222/json')).json(); const ws=new WebSocket(targets.find(t=>t.type==='page').webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;}); let serial=0;const pending=new Map(),exceptions=[]; ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}else if(m.method==='Runtime.exceptionThrown')exceptions.push(m.params.exceptionDetails.text+' '+(m.params.exceptionDetails.exception?.description??''));}; const call=(method,params={})=>new Promise((resolve,reject)=>{const id=++serial;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));}); const evaluate=async expression=>(await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true})); await call('Runtime.enable');await call('Page.enable'); for(const mode of ['plain','sandbox']){ sandbox=mode==='sandbox';exceptions.length=0;forwarded.length=0; await call('Page.navigate',{url:`http://127.0.0.1:${forwardPort}/proxy/management.html`});await delay(2500); console.log('panel',mode,'initial',JSON.stringify(await evaluate(`({text:document.body.innerText.slice(0,1800),inputs:[...document.querySelectorAll('input')].map(x=>({type:x.type,placeholder:x.placeholder,value:x.value})),buttons:[...document.querySelectorAll('button')].map(x=>x.innerText)})`))); console.log('panel',mode,'storage',JSON.stringify(await evaluate(`(()=>{try{return {available:!!localStorage}}catch(e){return {error:e.name,message:e.message}}})()`))); await evaluate(`document.querySelector('input[type=checkbox]')?.click()`);await delay(300); // React input setter; same panel base URL explicitly includes the forward prefix. console.log('panel',mode,'login interaction',JSON.stringify(await evaluate(`(()=>{const inputs=[...document.querySelectorAll('input')];const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;const base=inputs.find(x=>x.type!=='password'&&x.type!=='checkbox');if(base){setter.call(base,'http://127.0.0.1:${forwardPort}/proxy');base.dispatchEvent(new Event('input',{bubbles:true}));}const pass=inputs.find(x=>x.type==='password');if(pass){setter.call(pass,'p1-management');pass.dispatchEvent(new Event('input',{bubbles:true}));}return inputs.map(x=>({type:x.type,value:x.type==='password'?'[fixture]':x.value}));})()`))); await delay(300);await evaluate(`(()=>{const b=[...document.querySelectorAll('button')].find(x=>/connect|login|登录|连接/i.test(x.innerText));b?.click();return b?.innerText})()`);await delay(2500); console.log('panel',mode,'after login',JSON.stringify(await evaluate(`document.body.innerText.slice(0,2000)`))); console.log('panel',mode,'requests',JSON.stringify(forwarded));console.log('panel',mode,'exceptions',JSON.stringify(exceptions)); } console.log('sandbox explicit null-origin fetch',JSON.stringify(await evaluate(`fetch('http://127.0.0.1:${forwardPort}/proxy/v8/management/config/access/api-keys',{headers:{Authorization:'Bearer p1-management'},credentials:'include'}).then(async r=>({status:r.status,body:await r.text()})).catch(e=>({error:e.name,message:e.message}))`)));console.log('sandbox explicit requests',JSON.stringify(forwarded)); for(const callback of ['/proxy/callback?code=fixture','/proxy/auth/callback?code=fixture']){const r=await fetch(`http://127.0.0.1:${forwardPort}${callback}`);console.log('forward callback',callback,r.status);} ws.close();chrome.kill('SIGTERM'); const settings=path.join(root,'claude-settings.json'),mcp=path.join(root,'mcp.json');fs.writeFileSync(settings,'{}');fs.writeFileSync(mcp,JSON.stringify({mcpServers:{}})); for(const key of ['wrong','p1-revoked']){ const chome=path.join(root,`claude-${key}`);fs.mkdirSync(chome); const c=launch('/usr/bin/timeout',['-k','2','45','/home/jwoltje/.local/bin/claude','-p','Reply hello.','--model','claude-sonnet-5-5','--output-format','text','--restricted','--tools','','--permission-mode','dontAsk','--settings',settings,'--strict-mcp-config','--mcp-config',mcp,'--disable-slash-commands'],{env:{...env,HOME:chome,ANTHROPIC_BASE_URL:`http://127.0.0.1:${port}`,ANTHROPIC_AUTH_TOKEN:key,CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:'1'}}); const index=children.length-1;const result=await new Promise(r=>c.on('close',(code,signal)=>r({code,signal})));console.log('Claude Code wrong/revoked key',key,JSON.stringify(result),'output',JSON.stringify(fs.readFileSync(path.join(root,`${index}.stdout`),'utf8').slice(0,2000))); } } console.log('acceptance probes complete'); }finally{for(const child of children)child.kill('SIGTERM');for(const s of servers)s.closeAllConnections?.();for(const s of servers)s.close();}