|
@@ -10,12 +10,14 @@ import type {RunnerHub} from './runnerHub.js'
|
|
|
import type {WorkspaceAgent} from '../shared/workspace.js'
|
|
import type {WorkspaceAgent} from '../shared/workspace.js'
|
|
|
import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
|
|
import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
|
|
|
import type {BrowserScope,BrowserState,BrowserUpload} from '../runner/browser/types.js'
|
|
import type {BrowserScope,BrowserState,BrowserUpload} from '../runner/browser/types.js'
|
|
|
|
|
+import type {BrowserInstallation,ManagedBrowserCard,ManagedBrowserState} from '../shared/managedBrowser.js'
|
|
|
import {ToolOperationJournal} from './toolOperationJournal.js'
|
|
import {ToolOperationJournal} from './toolOperationJournal.js'
|
|
|
|
|
|
|
|
const digest=(value:string)=>createHash('sha256').update(value).digest('hex')
|
|
const digest=(value:string)=>createHash('sha256').update(value).digest('hex')
|
|
|
const credentials=z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough()
|
|
const credentials=z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough()
|
|
|
-interface Grant {id:string;role:'task'|'human'|'view';resource:Resource;expiresAt:number;authVersion:number;authorize():void;signal?:AbortSignal;requestId?:string;sealedToken?:string;tokenHash?:string}
|
|
|
|
|
-interface Resource {owner:string;agent:WorkspaceAgent;runnerId:string;scope:BrowserScope;state:BrowserState;task?:Grant;human?:Grant;paused:boolean;resetRequired?:boolean;resetting?:Promise<void>;taking?:Promise<void>;closing?:Promise<unknown>;frame?:ComputerFrame;frames?:Map<string,Omit<ComputerFrame,'data'>>;notes?:string}
|
|
|
|
|
|
|
+type RuntimeState=BrowserState&{available?:boolean;installation?:BrowserInstallation}
|
|
|
|
|
+interface Grant {id:string;role:'task'|'human'|'view'|'prepare';resource:Resource;expiresAt:number;authVersion:number;authorize():void;signal?:AbortSignal;requestId?:string;sealedToken?:string;tokenHash?:string}
|
|
|
|
|
+interface Resource {owner:string;agent:WorkspaceAgent;runnerId:string;scope:BrowserScope;state:RuntimeState;task?:Grant;human?:Grant;preparation?:Grant;notify?:()=>void;paused:boolean;retired?:boolean;resetRequired?:boolean;resetting?:Promise<void>;taking?:Promise<void>;closing?:Promise<unknown>;frame?:ComputerFrame;frames?:Map<string,Omit<ComputerFrame,'data'>>;notes?:string}
|
|
|
export interface BrowserTurn {
|
|
export interface BrowserTurn {
|
|
|
call(name:string,args:unknown,callId?:string):Promise<unknown>
|
|
call(name:string,args:unknown,callId?:string):Promise<unknown>
|
|
|
close():Promise<void>
|
|
close():Promise<void>
|
|
@@ -24,6 +26,8 @@ export interface BrowserTurnOptions {
|
|
|
workId:string;signal:AbortSignal;authorize():void
|
|
workId:string;signal:AbortSignal;authorize():void
|
|
|
publish(name:string,bytes:Buffer):Promise<unknown>
|
|
publish(name:string,bytes:Buffer):Promise<unknown>
|
|
|
upload(fileId:string):Promise<BrowserUpload>
|
|
upload(fileId:string):Promise<BrowserUpload>
|
|
|
|
|
+ /** Return false when persistence failed so the next observation can retry. */
|
|
|
|
|
+ onCard?(card:ManagedBrowserCard):boolean|void
|
|
|
toVm?(file:BrowserUpload,path:string,overwrite:boolean):Promise<unknown>
|
|
toVm?(file:BrowserUpload,path:string,overwrite:boolean):Promise<unknown>
|
|
|
fromVm?(path:string):Promise<BrowserUpload>
|
|
fromVm?(path:string):Promise<BrowserUpload>
|
|
|
}
|
|
}
|
|
@@ -55,6 +59,7 @@ export class ManagedBrowsers {
|
|
|
// Source changes revoke every old holder. The old Runner observes the
|
|
// Source changes revoke every old holder. The old Runner observes the
|
|
|
// revoked grant; the current Runner must confirm a close before reuse.
|
|
// revoked grant; the current Runner must confirm a close before reuse.
|
|
|
for(const [id,grant] of this.grants)if(grant.resource===resource)this.grants.delete(id)
|
|
for(const [id,grant] of this.grants)if(grant.resource===resource)this.grants.delete(id)
|
|
|
|
|
+ resource.retired=true;resource.task=undefined;resource.human=undefined;resource.paused=false;resource.notify?.()
|
|
|
resetRequired=true
|
|
resetRequired=true
|
|
|
this.resources.delete(key);resource=undefined
|
|
this.resources.delete(key);resource=undefined
|
|
|
}
|
|
}
|
|
@@ -82,10 +87,11 @@ export class ManagedBrowsers {
|
|
|
const matches=(g:Grant)=>g.resource.runnerId===runnerId&&g.resource.scope.ownerKey===s.ownerKey&&g.resource.scope.environmentId===s.environmentId&&g.resource.scope.profile===s.profile
|
|
const matches=(g:Grant)=>g.resource.runnerId===runnerId&&g.resource.scope.ownerKey===s.ownerKey&&g.resource.scope.environmentId===s.environmentId&&g.resource.scope.profile===s.profile
|
|
|
const valid=(g:Grant)=>{try{this.check(g);return true}catch{return false}}
|
|
const valid=(g:Grant)=>{try{this.check(g);return true}catch{return false}}
|
|
|
if(grantId){const g=this.grants.get(grantId);return !!g&&matches(g)&&valid(g)}
|
|
if(grantId){const g=this.grants.get(grantId);return !!g&&matches(g)&&valid(g)}
|
|
|
- return [...this.grants.values()].some(g=>matches(g)&&valid(g))
|
|
|
|
|
|
|
+ return [...this.grants.values()].some(g=>g.role!=='prepare'&&matches(g)&&valid(g))
|
|
|
}
|
|
}
|
|
|
private async request(grant:Grant,op:string,data:Record<string,unknown>={}){
|
|
private async request(grant:Grant,op:string,data:Record<string,unknown>={}){
|
|
|
this.check(grant)
|
|
this.check(grant)
|
|
|
|
|
+ if(grant.role==='task'&&!['status','close','prepare','resume'].includes(op))await this.waitForControl(grant)
|
|
|
const r=grant.resource
|
|
const r=grant.resource
|
|
|
if(r.resetRequired){
|
|
if(r.resetRequired){
|
|
|
r.resetting??=(async()=>{
|
|
r.resetting??=(async()=>{
|
|
@@ -96,9 +102,21 @@ export class ManagedBrowsers {
|
|
|
this.check(grant)
|
|
this.check(grant)
|
|
|
}
|
|
}
|
|
|
const result=await this.hub.browser(r.owner,r.agent,{scope:r.scope,grantId:grant.id,op,...data},()=>this.check(grant))
|
|
const result=await this.hub.browser(r.owner,r.agent,{scope:r.scope,grantId:grant.id,op,...data},()=>this.check(grant))
|
|
|
- if(result&&typeof result.open==='boolean'&&Number.isInteger(result.generation))r.state=result
|
|
|
|
|
|
|
+ if(result&&typeof result.open==='boolean'&&Number.isInteger(result.generation)){r.state=result;r.notify?.()}
|
|
|
return result
|
|
return result
|
|
|
}
|
|
}
|
|
|
|
|
+ private async waitForControl(g:Grant){
|
|
|
|
|
+ while(g.resource.paused){
|
|
|
|
|
+ this.check(g)
|
|
|
|
|
+ await new Promise<void>((resolve,reject)=>{
|
|
|
|
|
+ const stop=()=>{clearTimeout(timer);reject(new HttpError(410,'浏览器任务已停止','browser_authorization_revoked'))}
|
|
|
|
|
+ const timer=setTimeout(()=>{g.signal?.removeEventListener('abort',stop);resolve()},250)
|
|
|
|
|
+ g.signal?.addEventListener('abort',stop,{once:true})
|
|
|
|
|
+ if(g.signal?.aborted){g.signal.removeEventListener('abort',stop);stop()}
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ this.check(g)
|
|
|
|
|
+ }
|
|
|
private async view<T>(r:Resource,action:(g:Grant)=>Promise<T>){
|
|
private async view<T>(r:Resource,action:(g:Grant)=>Promise<T>){
|
|
|
const grant=this.grant(r,'view',()=>{},undefined,Date.now()+30000)
|
|
const grant=this.grant(r,'view',()=>{},undefined,Date.now()+30000)
|
|
|
try{return await action(grant)}finally{this.grants.delete(grant.id)}
|
|
try{return await action(grant)}finally{this.grants.delete(grant.id)}
|
|
@@ -108,9 +126,41 @@ export class ManagedBrowsers {
|
|
|
}
|
|
}
|
|
|
async state(owner:string,id:string){
|
|
async state(owner:string,id:string){
|
|
|
const enabled=readHostTools(this.store.home).managedBrowser===true
|
|
const enabled=readHostTools(this.store.home).managedBrowser===true
|
|
|
- try{const r=this.resource(owner,id);await this.view(r,g=>this.request(g,'status'));return {enabled,available:true,...r.state}}
|
|
|
|
|
|
|
+ try{const r=this.resource(owner,id);await this.view(r,g=>this.request(g,'status'));this.finishPreparation(r);return this.publicState(r)}
|
|
|
catch(error){return {enabled,available:false,reason:error instanceof Error?error.message:'浏览器不可用',open:false,generation:0,profile:'persistent',tabs:[],downloads:[]}}
|
|
catch(error){return {enabled,available:false,reason:error instanceof Error?error.message:'浏览器不可用',open:false,generation:0,profile:'persistent',tabs:[],downloads:[]}}
|
|
|
}
|
|
}
|
|
|
|
|
+ private publicState(r:Resource):ManagedBrowserState{return {enabled:true,...r.state,available:r.state.available??true,...(r.state.available===false?{reason:r.state.installation?.message??'浏览器环境尚未就绪'}:{})}}
|
|
|
|
|
+ private finishPreparation(r:Resource){
|
|
|
|
|
+ if(r.preparation&&['ready','failed'].includes(r.state.installation?.status??'')){this.grants.delete(r.preparation.id);r.preparation=undefined}
|
|
|
|
|
+ }
|
|
|
|
|
+ async prepare(owner:string,id:string,retry=false){
|
|
|
|
|
+ const r=this.resource(owner,id)
|
|
|
|
|
+ await this.view(r,g=>this.request(g,'status'))
|
|
|
|
|
+ if(r.state.available!==false){this.finishPreparation(r);return this.publicState(r)}
|
|
|
|
|
+ if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
|
|
|
|
|
+ const g=r.preparation??=this.grant(r,'prepare',()=>{},undefined,Date.now()+600000)
|
|
|
|
|
+ try{await this.request(g,'prepare',{retry});this.finishPreparation(r);return this.publicState(r)}
|
|
|
|
|
+ catch(error){if(r.preparation===g){this.grants.delete(g.id);r.preparation=undefined}throw error}
|
|
|
|
|
+ }
|
|
|
|
|
+ private async ensureReady(g:Grant){
|
|
|
|
|
+ await this.request(g,'status')
|
|
|
|
|
+ if(g.resource.state.available!==false)return
|
|
|
|
|
+ await this.request(g,'prepare')
|
|
|
|
|
+ const deadline=Date.now()+540000
|
|
|
|
|
+ while(g.resource.state.available===false){
|
|
|
|
|
+ this.check(g)
|
|
|
|
|
+ const installation=g.resource.state.installation
|
|
|
|
|
+ if(installation?.status==='failed')throw new HttpError(409,installation.error||installation.message,'browser_install_failed')
|
|
|
|
|
+ if(Date.now()>=deadline)throw new HttpError(504,'浏览器环境准备超时,请在卡片中检查状态后重试','browser_install_timeout')
|
|
|
|
|
+ await new Promise<void>((resolve,reject)=>{
|
|
|
|
|
+ const stop=()=>{clearTimeout(timer);reject(new HttpError(410,'浏览器任务已停止','browser_authorization_revoked'))}
|
|
|
|
|
+ const timer=setTimeout(()=>{g.signal?.removeEventListener('abort',stop);resolve()},750)
|
|
|
|
|
+ g.signal?.addEventListener('abort',stop,{once:true})
|
|
|
|
|
+ if(g.signal?.aborted){g.signal.removeEventListener('abort',stop);stop()}
|
|
|
|
|
+ })
|
|
|
|
|
+ await this.request(g,'status')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
private token(owner:string,id:string,value:unknown){
|
|
private token(owner:string,id:string,value:unknown){
|
|
|
const input=parse(credentials,value),grant=this.grants.get(input.controlId)
|
|
const input=parse(credentials,value),grant=this.grants.get(input.controlId)
|
|
|
if(!grant||grant.role!=='human'||grant.resource.owner!==owner||grant.resource.agent.id!==id||!grant.tokenHash||!timingSafeEqual(Buffer.from(grant.tokenHash,'hex'),Buffer.from(digest(input.token),'hex')))throw new HttpError(403,'需要当前浏览器控制凭据','browser_control_forbidden')
|
|
if(!grant||grant.role!=='human'||grant.resource.owner!==owner||grant.resource.agent.id!==id||!grant.tokenHash||!timingSafeEqual(Buffer.from(grant.tokenHash,'hex'),Buffer.from(digest(input.token),'hex')))throw new HttpError(403,'需要当前浏览器控制凭据','browser_control_forbidden')
|
|
@@ -133,14 +183,32 @@ export class ManagedBrowsers {
|
|
|
return {name,mimeType,buffer}
|
|
return {name,mimeType,buffer}
|
|
|
}
|
|
}
|
|
|
openTurn(owner:string,agent:WorkspaceAgent,options:BrowserTurnOptions):BrowserTurn{
|
|
openTurn(owner:string,agent:WorkspaceAgent,options:BrowserTurnOptions):BrowserTurn{
|
|
|
- let grant:Grant|undefined,finished=false
|
|
|
|
|
|
|
+ let grant:Grant|undefined,finished=false,failure:string|undefined,lastCard='',lastTitle:string|undefined,lastUrl:string|undefined,listener:(()=>void)|undefined
|
|
|
|
|
+ const notify=(r:Resource)=>{
|
|
|
|
|
+ if(!options.onCard)return
|
|
|
|
|
+ const tab=r.state.tabs.find(tab=>tab.active)
|
|
|
|
|
+ let url:string|undefined
|
|
|
|
|
+ try{const parsed=new URL(tab?.url??'');if(['https:','http:'].includes(parsed.protocol)){parsed.username='';parsed.password='';parsed.search='';parsed.hash='';url=parsed.href.slice(0,2000)}}catch{}
|
|
|
|
|
+ if(tab){lastTitle=tab.title.slice(0,240);lastUrl=url}
|
|
|
|
|
+ if((r.retired||finished&&!r.human)&&r.notify===listener)r.notify=undefined
|
|
|
|
|
+ const status:ManagedBrowserCard['status']=r.retired?'closed':failure?'failed':finished&&!r.human?'closed':r.state.available===false?'preparing':r.state.open?'active':'ready'
|
|
|
|
|
+ const {updatedAt:_updated,...installation}=r.state.installation??{updatedAt:0}
|
|
|
|
|
+ const body={id:options.workId,agentId:agent.id,agentName:agent.name,status,title:lastTitle,url:lastUrl,
|
|
|
|
|
+ message:r.retired?'浏览器执行来源已改变,本轮会话已结束':failure??(status==='closed'?'本轮浏览器操作已结束':r.human?(finished?'人工控制中,交还后结束本轮会话':'人工控制中,可交还机器人继续'):r.paused?'接管已过期,请重新接管后交还机器人':r.state.installation?.message),installation:r.state.installation}
|
|
|
|
|
+ const signature=JSON.stringify({...body,installation})
|
|
|
|
|
+ if(signature===lastCard)return
|
|
|
|
|
+ // Chat persistence is an observer; a deleted history row or storage error
|
|
|
|
|
+ // must never block control handoff, authorization cleanup, or the sweep.
|
|
|
|
|
+ try{if(options.onCard({...body,updatedAt:Date.now()})!==false)lastCard=signature}catch{}
|
|
|
|
|
+ }
|
|
|
const get=()=>{
|
|
const get=()=>{
|
|
|
options.authorize();options.signal.throwIfAborted()
|
|
options.authorize();options.signal.throwIfAborted()
|
|
|
if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
|
|
if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
|
|
|
if(!grant){
|
|
if(!grant){
|
|
|
const r=this.resource(owner,agent.id)
|
|
const r=this.resource(owner,agent.id)
|
|
|
if(r.closing||r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
|
|
if(r.closing||r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
|
|
|
- grant=this.grant(r,'task',options.authorize,options.signal);r.task=grant
|
|
|
|
|
|
|
+ r.notify?.()
|
|
|
|
|
+ grant=this.grant(r,'task',options.authorize,options.signal);r.task=grant;listener=()=>notify(r);r.notify=listener
|
|
|
}
|
|
}
|
|
|
this.check(grant);return grant
|
|
this.check(grant);return grant
|
|
|
}
|
|
}
|
|
@@ -149,17 +217,21 @@ export class ManagedBrowsers {
|
|
|
if(!grant)return
|
|
if(!grant)return
|
|
|
const g=grant,r=g.resource;this.grants.delete(g.id);if(r.task===g)r.task=undefined
|
|
const g=grant,r=g.resource;this.grants.delete(g.id);if(r.task===g)r.task=undefined
|
|
|
if(!r.human){r.paused=false;r.closing=this.view(r,view=>this.request(view,'close')).catch(()=>{});try{await r.closing}finally{r.closing=undefined}}
|
|
if(!r.human){r.paused=false;r.closing=this.view(r,view=>this.request(view,'close')).catch(()=>{});try{await r.closing}finally{r.closing=undefined}}
|
|
|
|
|
+ notify(r)
|
|
|
}
|
|
}
|
|
|
const abort=()=>{void close()};options.signal.addEventListener('abort',abort,{once:true})
|
|
const abort=()=>{void close()};options.signal.addEventListener('abort',abort,{once:true})
|
|
|
return {close,call:async(name,raw,callId)=>{
|
|
return {close,call:async(name,raw,callId)=>{
|
|
|
const g=get(),r=g.resource
|
|
const g=get(),r=g.resource
|
|
|
|
|
+ notify(r)
|
|
|
|
|
+ try{
|
|
|
// Hold the current tool call while the user operates this exact session.
|
|
// Hold the current tool call while the user operates this exact session.
|
|
|
- while(r.paused){this.check(g);await new Promise<void>((resolve,reject)=>{const abort=()=>{clearTimeout(timer);reject(new HttpError(410,'浏览器任务已停止','browser_authorization_revoked'))};const timer=setTimeout(()=>{options.signal.removeEventListener('abort',abort);resolve()},250);options.signal.addEventListener('abort',abort,{once:true})})}
|
|
|
|
|
|
|
+ await this.waitForControl(g)
|
|
|
const args=z.record(z.string(),z.unknown()).parse(raw??{})
|
|
const args=z.record(z.string(),z.unknown()).parse(raw??{})
|
|
|
const result=await this.journal.execute(owner,options.workId,callId,name,args,()=>this.check(g),async()=>{
|
|
const result=await this.journal.execute(owner,options.workId,callId,name,args,()=>this.check(g),async()=>{
|
|
|
if(name==='managed_browser_state')return this.request(g,'status')
|
|
if(name==='managed_browser_state')return this.request(g,'status')
|
|
|
if(name==='managed_browser_open'){
|
|
if(name==='managed_browser_open'){
|
|
|
const body=z.object({url:z.string().optional()}).strict().parse(args)
|
|
const body=z.object({url:z.string().optional()}).strict().parse(args)
|
|
|
|
|
+ await this.ensureReady(g)
|
|
|
await this.request(g,'open')
|
|
await this.request(g,'open')
|
|
|
if(body.url)await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action:{kind:'navigate',url:body.url}}})
|
|
if(body.url)await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action:{kind:'navigate',url:body.url}}})
|
|
|
return this.request(g,'status')
|
|
return this.request(g,'status')
|
|
@@ -182,22 +254,28 @@ export class ManagedBrowsers {
|
|
|
else if(name==='managed_browser_action')action=z.object({action:z.record(z.string(),z.unknown())}).strict().parse(args).action
|
|
else if(name==='managed_browser_action')action=z.object({action:z.record(z.string(),z.unknown())}).strict().parse(args).action
|
|
|
else throw new HttpError(403,'本轮未授权此浏览器工具','browser_tool_forbidden')
|
|
else throw new HttpError(403,'本轮未授权此浏览器工具','browser_tool_forbidden')
|
|
|
const value=await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action},...(upload?{upload}:{})})
|
|
const value=await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action},...(upload?{upload}:{})})
|
|
|
|
|
+ if(name==='managed_browser_action'&&!['snapshot','screenshot','downloads','state'].includes(String((action as {kind?:unknown})?.kind)))await this.request(g,'status')
|
|
|
if(value?.mimeType==='image/png'&&typeof value.data==='string')return {content:[{type:'image',mimeType:'image/png',data:value.data},{type:'text',text:JSON.stringify({width:value.width,height:value.height,generation:value.generation})}]}
|
|
if(value?.mimeType==='image/png'&&typeof value.data==='string')return {content:[{type:'image',mimeType:'image/png',data:value.data},{type:'text',text:JSON.stringify({width:value.width,height:value.height,generation:value.generation})}]}
|
|
|
return value
|
|
return value
|
|
|
})
|
|
})
|
|
|
|
|
+ failure=undefined;notify(r)
|
|
|
if(r.notes){const notes=r.notes;r.notes=undefined;return {result,humanNote:notes,instruction:'用户刚交还浏览器;重新读取页面后继续。'}}
|
|
if(r.notes){const notes=r.notes;r.notes=undefined;return {result,humanNote:notes,instruction:'用户刚交还浏览器;重新读取页面后继续。'}}
|
|
|
return result
|
|
return result
|
|
|
|
|
+ }catch(error){failure=error instanceof Error?error.message:'浏览器操作失败';notify(r);throw error}
|
|
|
}}
|
|
}}
|
|
|
}
|
|
}
|
|
|
router(){
|
|
router(){
|
|
|
const router=new Router(),matches=(ctx:{query:Record<string,unknown>})=>ctx.query.backend==='managed-browser'
|
|
const router=new Router(),matches=(ctx:{query:Record<string,unknown>})=>ctx.query.backend==='managed-browser'
|
|
|
router.get('/api/app/agents/:id/managed-browser',async ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.set('Cache-Control','no-store');ctx.body=await this.state(owner,ctx.params.id)})
|
|
router.get('/api/app/agents/:id/managed-browser',async ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.set('Cache-Control','no-store');ctx.body=await this.state(owner,ctx.params.id)})
|
|
|
|
|
+ router.post('/api/app/agents/:id/managed-browser/prepare',async ctx=>{const owner=this.auth.require(ctx).id,body=parse(z.object({retry:z.boolean().default(false)}).strict(),(ctx.request as any).body??{});ctx.body=await this.prepare(owner,ctx.params.id,body.retry)})
|
|
|
router.get('/api/app/agents/:id/computer',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);await this.view(r,g=>this.request(g,'status'));ctx.body=this.controlStatus(r)})
|
|
router.get('/api/app/agents/:id/computer',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);await this.view(r,g=>this.request(g,'status'));ctx.body=this.controlStatus(r)})
|
|
|
router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);const shot=await this.view(r,g=>this.request(g,'execute',{operation:{generation:r.state.generation,operationId:randomUUID(),action:{kind:'screenshot'}}}));r.frame={id:shot.frameId,generation:shot.generation,data:shot.data,width:shot.width,height:shot.height,capturedAt:shot.createdAt};r.frames??=new Map();const {data:_data,...metadata}=r.frame;r.frames.set(metadata.id,metadata);for(const [id,frame] of r.frames)if(frame.capturedAt<Date.now()-30000||r.frames.size>32)r.frames.delete(id);ctx.set('Cache-Control','no-store');ctx.body=r.frame})
|
|
router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);const shot=await this.view(r,g=>this.request(g,'execute',{operation:{generation:r.state.generation,operationId:randomUUID(),action:{kind:'screenshot'}}}));r.frame={id:shot.frameId,generation:shot.generation,data:shot.data,width:shot.width,height:shot.height,capturedAt:shot.createdAt};r.frames??=new Map();const {data:_data,...metadata}=r.frame;r.frames.set(metadata.id,metadata);for(const [id,frame] of r.frames)if(frame.capturedAt<Date.now()-30000||r.frames.size>32)r.frames.delete(id);ctx.set('Cache-Control','no-store');ctx.body=r.frame})
|
|
|
router.post('/api/app/agents/:id/computer/take',async(ctx,next)=>{
|
|
router.post('/api/app/agents/:id/computer/take',async(ctx,next)=>{
|
|
|
if(!matches(ctx))return next()
|
|
if(!matches(ctx))return next()
|
|
|
const r=this.resource(this.auth.require(ctx).id,ctx.params.id),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
|
|
const r=this.resource(this.auth.require(ctx).id,ctx.params.id),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
|
|
|
if(r.closing)await r.closing
|
|
if(r.closing)await r.closing
|
|
|
|
|
+ await this.view(r,g=>this.request(g,'status'))
|
|
|
|
|
+ if(r.state.available===false)throw new HttpError(409,'浏览器环境尚未就绪,请先等待自动准备完成','browser_preparing')
|
|
|
if(r.human&&r.human.expiresAt>Date.now()){
|
|
if(r.human&&r.human.expiresAt>Date.now()){
|
|
|
this.check(r.human)
|
|
this.check(r.human)
|
|
|
if(r.human.requestId!==body.requestId)throw new HttpError(409,'浏览器已由另一页面接管','browser_control_busy')
|
|
if(r.human.requestId!==body.requestId)throw new HttpError(409,'浏览器已由另一页面接管','browser_control_busy')
|
|
@@ -207,9 +285,9 @@ export class ManagedBrowsers {
|
|
|
}
|
|
}
|
|
|
const token=randomBytes(32).toString('base64url'),g=this.grant(r,'human',()=>{},undefined,Date.now()+30000)
|
|
const token=randomBytes(32).toString('base64url'),g=this.grant(r,'human',()=>{},undefined,Date.now()+30000)
|
|
|
Object.assign(g,{requestId:body.requestId,tokenHash:digest(token),sealedToken:this.nodes.seal(token)});r.human=g;r.paused=true
|
|
Object.assign(g,{requestId:body.requestId,tokenHash:digest(token),sealedToken:this.nodes.seal(token)});r.human=g;r.paused=true
|
|
|
- r.taking=(async()=>{await this.request(g,'pause');await this.request(g,'open');r.frame=undefined;r.frames?.clear()})()
|
|
|
|
|
|
|
+ r.taking=(async()=>{await this.request(g,'pause');await this.request(g,'open');r.frame=undefined;r.frames?.clear();r.notify?.()})()
|
|
|
try{await r.taking;r.taking=undefined;ctx.body={...this.controlStatus(r),controlId:g.id,token,expiresAt:g.expiresAt}}
|
|
try{await r.taking;r.taking=undefined;ctx.body={...this.controlStatus(r),controlId:g.id,token,expiresAt:g.expiresAt}}
|
|
|
- catch(error){this.grants.delete(g.id);if(r.human===g)r.human=undefined;throw error}
|
|
|
|
|
|
|
+ catch(error){this.grants.delete(g.id);if(r.human===g)r.human=undefined;r.notify?.();throw error}
|
|
|
finally{r.taking=undefined}
|
|
finally{r.taking=undefined}
|
|
|
})
|
|
})
|
|
|
router.post('/api/app/agents/:id/computer/renew',(ctx,next)=>{if(!matches(ctx))return next();const g=this.token(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body);g.expiresAt=Date.now()+30000;ctx.body={expiresAt:g.expiresAt}})
|
|
router.post('/api/app/agents/:id/computer/renew',(ctx,next)=>{if(!matches(ctx))return next();const g=this.token(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body);g.expiresAt=Date.now()+30000;ctx.body={expiresAt:g.expiresAt}})
|
|
@@ -244,6 +322,7 @@ export class ManagedBrowsers {
|
|
|
// Keep the manual grant alive until the runner confirms the generation handoff.
|
|
// Keep the manual grant alive until the runner confirms the generation handoff.
|
|
|
await this.request(r.task??g,'resume');r.notes=body.notes;r.human=undefined;r.paused=false;r.frame=undefined;r.frames?.clear();this.grants.delete(g.id)
|
|
await this.request(r.task??g,'resume');r.notes=body.notes;r.human=undefined;r.paused=false;r.frame=undefined;r.frames?.clear();this.grants.delete(g.id)
|
|
|
if(!r.task){r.closing=this.view(r,view=>this.request(view,'close'));try{await r.closing}finally{r.closing=undefined}}
|
|
if(!r.task){r.closing=this.view(r,view=>this.request(view,'close'));try{await r.closing}finally{r.closing=undefined}}
|
|
|
|
|
+ r.notify?.()
|
|
|
ctx.body=this.controlStatus(r)
|
|
ctx.body=this.controlStatus(r)
|
|
|
})
|
|
})
|
|
|
return router
|
|
return router
|
|
@@ -252,6 +331,8 @@ export class ManagedBrowsers {
|
|
|
for(const r of this.resources.values()){
|
|
for(const r of this.resources.values()){
|
|
|
if(r.human){let valid=true;try{this.check(r.human)}catch{valid=false}if(!valid){this.grants.delete(r.human.id);r.human=undefined;r.paused=true;r.frame=undefined}}
|
|
if(r.human){let valid=true;try{this.check(r.human)}catch{valid=false}if(!valid){this.grants.delete(r.human.id);r.human=undefined;r.paused=true;r.frame=undefined}}
|
|
|
if(r.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined}}
|
|
if(r.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined}}
|
|
|
|
|
+ if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
|
|
|
|
|
+ r.notify?.()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
close(){this.closed=true;clearInterval(this.timer);this.grants.clear();this.resources.clear()}
|
|
close(){this.closed=true;clearInterval(this.timer);this.grants.clear();this.resources.clear()}
|