|
@@ -16,11 +16,11 @@ 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()
|
|
|
type RuntimeState=BrowserState&{available?:boolean;installation?:BrowserInstallation}
|
|
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}
|
|
|
|
|
|
|
+interface Grant {id:string;role:'task'|'human'|'view'|'prepare'|'session';resource:Resource;expiresAt:number;authVersion:number;authorize():void;signal?:AbortSignal;parking?:boolean;requestId?:string;sealedToken?:string;tokenHash?:string}
|
|
|
|
|
+interface Resource {owner:string;agent:WorkspaceAgent;runnerId:string;runnerEpoch?:string;authVersion:number;scope:BrowserScope;state:RuntimeState;task?:Grant;human?:Grant;session?:Grant;parked?:boolean;nextStatusAt?:number;refreshing?:Promise<void>;preparation?:Grant;notify?:()=>void;paused:boolean;forceClose?:boolean;retired?:boolean;resetRequired?:boolean;resetting?:Promise<void>;transition?:Promise<unknown>;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(options?:{retain?:boolean}):Promise<void>
|
|
|
}
|
|
}
|
|
|
export interface BrowserTurnOptions {
|
|
export interface BrowserTurnOptions {
|
|
|
workId:string;signal:AbortSignal;authorize():void
|
|
workId:string;signal:AbortSignal;authorize():void
|
|
@@ -55,17 +55,16 @@ export class ManagedBrowsers {
|
|
|
private resource(owner:string,id:string){
|
|
private resource(owner:string,id:string){
|
|
|
const agent=this.agent(owner,id),runner=this.hub.browserRunner(owner,agent),key=owner+':'+id
|
|
const agent=this.agent(owner,id),runner=this.hub.browserRunner(owner,agent),key=owner+':'+id
|
|
|
let resource=this.resources.get(key),resetRequired=false
|
|
let resource=this.resources.get(key),resetRequired=false
|
|
|
- if(resource&&(resource.runnerId!==runner.id||resource.agent.profile!==agent.profile||resource.agent.nodeId!==agent.nodeId)){
|
|
|
|
|
|
|
+ if(resource&&(resource.retired||resource.authVersion!==(this.auth.pushAuthorizationVersion(owner)??0)||resource.runnerId!==runner.id||resource.runnerEpoch!==runner.browserEpoch||resource.agent.profile!==agent.profile||resource.agent.nodeId!==agent.nodeId)){
|
|
|
// 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)
|
|
|
|
|
- resource.retired=true;resource.task=undefined;resource.human=undefined;resource.paused=false;resource.notify?.()
|
|
|
|
|
|
|
+ this.retire(resource)
|
|
|
resetRequired=true
|
|
resetRequired=true
|
|
|
this.resources.delete(key);resource=undefined
|
|
this.resources.delete(key);resource=undefined
|
|
|
}
|
|
}
|
|
|
if(!resource){
|
|
if(!resource){
|
|
|
const scope:BrowserScope={ownerKey:digest(owner),environmentId:agent.id,profile:agent.temporaryGoalId?'temporary':'persistent'}
|
|
const scope:BrowserScope={ownerKey:digest(owner),environmentId:agent.id,profile:agent.temporaryGoalId?'temporary':'persistent'}
|
|
|
- resource={owner,agent,runnerId:runner.id,scope,state:{open:false,generation:1,profile:scope.profile,tabs:[],downloads:[]},paused:false,resetRequired}
|
|
|
|
|
|
|
+ resource={owner,agent,runnerId:runner.id,runnerEpoch:runner.browserEpoch,authVersion:this.auth.pushAuthorizationVersion(owner)??0,scope,state:{open:false,generation:1,profile:scope.profile,tabs:[],downloads:[]},paused:false,resetRequired}
|
|
|
this.resources.set(key,resource)
|
|
this.resources.set(key,resource)
|
|
|
}
|
|
}
|
|
|
return resource
|
|
return resource
|
|
@@ -77,20 +76,66 @@ export class ManagedBrowsers {
|
|
|
private check(grant:Grant){
|
|
private check(grant:Grant){
|
|
|
const r=grant.resource
|
|
const r=grant.resource
|
|
|
if(this.resources.get(r.owner+':'+r.agent.id)!==r||this.grants.get(grant.id)!==grant||grant.expiresAt<=Date.now()||grant.signal?.aborted||!this.auth.isUserActive(r.owner)||(this.auth.pushAuthorizationVersion(r.owner)??0)!==grant.authVersion)throw new HttpError(410,'浏览器操作授权已结束','browser_authorization_revoked')
|
|
if(this.resources.get(r.owner+':'+r.agent.id)!==r||this.grants.get(grant.id)!==grant||grant.expiresAt<=Date.now()||grant.signal?.aborted||!this.auth.isUserActive(r.owner)||(this.auth.pushAuthorizationVersion(r.owner)??0)!==grant.authVersion)throw new HttpError(410,'浏览器操作授权已结束','browser_authorization_revoked')
|
|
|
- const agent=this.agent(r.owner,r.agent.id)
|
|
|
|
|
- if(agent.nodeId!==r.agent.nodeId||agent.profile!==r.agent.profile||this.hub.browserRunner(r.owner,agent).id!==r.runnerId)throw new HttpError(410,'浏览器执行来源已改变','browser_source_changed')
|
|
|
|
|
|
|
+ this.checkResource(r)
|
|
|
grant.authorize()
|
|
grant.authorize()
|
|
|
}
|
|
}
|
|
|
|
|
+ private checkResource(r:Resource){
|
|
|
|
|
+ if(r.retired||this.resources.get(r.owner+':'+r.agent.id)!==r||!this.auth.isUserActive(r.owner)||(this.auth.pushAuthorizationVersion(r.owner)??0)!==r.authVersion)throw new HttpError(410,'浏览器操作授权已结束','browser_authorization_revoked')
|
|
|
|
|
+ const agent=this.agent(r.owner,r.agent.id),runner=this.hub.browserRunner(r.owner,agent)
|
|
|
|
|
+ if(agent.nodeId!==r.agent.nodeId||agent.profile!==r.agent.profile||runner.id!==r.runnerId||runner.browserEpoch!==r.runnerEpoch)throw new HttpError(410,'浏览器执行来源已改变','browser_source_changed')
|
|
|
|
|
+ }
|
|
|
|
|
+ private retire(r:Resource){
|
|
|
|
|
+ for(const [id,g] of this.grants)if(g.resource===r)this.grants.delete(id)
|
|
|
|
|
+ r.retired=true;r.task=undefined;r.human=undefined;r.session=undefined;r.preparation=undefined;r.paused=false;r.notify?.()
|
|
|
|
|
+ }
|
|
|
|
|
+ private dropSession(r:Resource){if(r.session)this.grants.delete(r.session.id);r.session=undefined;r.parked=false}
|
|
|
|
|
+ private keepSession(r:Resource){
|
|
|
|
|
+ if(!r.forceClose&&r.scope.profile==='persistent'&&this.hub.supportsBrowserRetention(r.owner,r.agent))r.session??=this.grant(r,'session',()=>{})
|
|
|
|
|
+ }
|
|
|
|
|
+ private transition<T>(r:Resource,action:()=>Promise<T>):Promise<T>{
|
|
|
|
|
+ const pending=(r.transition??Promise.resolve()).catch(()=>{}).then(action)
|
|
|
|
|
+ r.transition=pending
|
|
|
|
|
+ void pending.finally(()=>{if(r.transition===pending)r.transition=undefined}).catch(()=>{})
|
|
|
|
|
+ return pending
|
|
|
|
|
+ }
|
|
|
|
|
+ private async rest(r:Resource,retain:boolean){
|
|
|
|
|
+ this.checkResource(r)
|
|
|
|
|
+ r.frame=undefined;r.frames?.clear();r.paused=false
|
|
|
|
|
+ if(retain&&!r.forceClose&&r.scope.profile==='persistent'&&r.state.open&&this.hub.supportsBrowserRetention(r.owner,r.agent)){
|
|
|
|
|
+ const session=r.session??=this.grant(r,'session',()=>{})
|
|
|
|
|
+ try{
|
|
|
|
|
+ const state=await this.request(session,'park')
|
|
|
|
|
+ if(!state||typeof state.open!=='boolean'||!Number.isInteger(state.generation))throw new HttpError(502,'浏览器未确认页面保留','browser_park_unconfirmed')
|
|
|
|
|
+ if(r.state.open){r.parked=true;r.nextStatusAt=Date.now()+30000}else this.dropSession(r)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ catch{this.dropSession(r)}
|
|
|
|
|
+ }
|
|
|
|
|
+ this.dropSession(r)
|
|
|
|
|
+ try{await this.view(r,g=>this.request(g,'close'));r.forceClose=false}
|
|
|
|
|
+ catch(error){r.resetRequired=true;throw error}
|
|
|
|
|
+ }
|
|
|
|
|
+ private async activate(g:Grant){
|
|
|
|
|
+ while(g.resource.parked){
|
|
|
|
|
+ await this.waitForControl(g)
|
|
|
|
|
+ await this.transition(g.resource,async()=>{
|
|
|
|
|
+ const r=g.resource;this.check(g)
|
|
|
|
|
+ if(r.paused||!r.parked)return
|
|
|
|
|
+ await this.request(g,'resume');r.parked=false;r.frame=undefined;r.frames?.clear()
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
allowed(runnerId:string,scope:unknown,grantId?:string){
|
|
allowed(runnerId:string,scope:unknown,grantId?:string){
|
|
|
if(!scope||typeof scope!=='object')return false
|
|
if(!scope||typeof scope!=='object')return false
|
|
|
const s=scope as BrowserScope
|
|
const s=scope as BrowserScope
|
|
|
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&&(g.role!=='session'||g.parking===true)&&matches(g)&&valid(g)}
|
|
|
return [...this.grants.values()].some(g=>g.role!=='prepare'&&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==='session'&&op!=='park')throw new HttpError(403,'保留会话不授权浏览器操作','browser_tool_forbidden')
|
|
|
if(grant.role==='task'&&!['status','close','prepare','resume'].includes(op))await this.waitForControl(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){
|
|
@@ -101,9 +146,12 @@ export class ManagedBrowsers {
|
|
|
await r.resetting
|
|
await r.resetting
|
|
|
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))
|
|
|
|
|
- if(result&&typeof result.open==='boolean'&&Number.isInteger(result.generation)){r.state=result;r.notify?.()}
|
|
|
|
|
- return result
|
|
|
|
|
|
|
+ if(grant.role==='session')grant.parking=true
|
|
|
|
|
+ try{
|
|
|
|
|
+ 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)&&result.generation>=r.state.generation){r.state=result;if(!r.state.open)this.dropSession(r);else if(op==='open'&&(grant.role==='task'||grant.role==='human'))this.keepSession(r);r.notify?.()}
|
|
|
|
|
+ return result
|
|
|
|
|
+ }finally{grant.parking=false}
|
|
|
}
|
|
}
|
|
|
private async waitForControl(g:Grant){
|
|
private async waitForControl(g:Grant){
|
|
|
while(g.resource.paused){
|
|
while(g.resource.paused){
|
|
@@ -129,7 +177,7 @@ export class ManagedBrowsers {
|
|
|
try{const r=this.resource(owner,id);await this.view(r,g=>this.request(g,'status'));this.finishPreparation(r);return this.publicState(r)}
|
|
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 publicState(r:Resource):ManagedBrowserState{return {enabled:true,...r.state,supportsRetention:this.hub.supportsBrowserRetention(r.owner,r.agent),available:r.state.available??true,...(r.state.available===false?{reason:r.state.installation?.message??'浏览器环境尚未就绪'}:{})}}
|
|
|
private finishPreparation(r:Resource){
|
|
private finishPreparation(r:Resource){
|
|
|
if(r.preparation&&['ready','failed'].includes(r.state.installation?.status??'')){this.grants.delete(r.preparation.id);r.preparation=undefined}
|
|
if(r.preparation&&['ready','failed'].includes(r.state.installation?.status??'')){this.grants.delete(r.preparation.id);r.preparation=undefined}
|
|
|
}
|
|
}
|
|
@@ -183,49 +231,67 @@ 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,failure:string|undefined,lastCard='',lastTitle:string|undefined,lastUrl:string|undefined,listener:(()=>void)|undefined
|
|
|
|
|
|
|
+ let grant:Grant|undefined,finished=false,activated=false,closing:Promise<void>|undefined,failure:string|undefined,lastCard='',lastTitle:string|undefined,lastUrl:string|undefined,listener:(()=>void)|undefined
|
|
|
const notify=(r:Resource)=>{
|
|
const notify=(r:Resource)=>{
|
|
|
if(!options.onCard)return
|
|
if(!options.onCard)return
|
|
|
const tab=r.state.tabs.find(tab=>tab.active)
|
|
const tab=r.state.tabs.find(tab=>tab.active)
|
|
|
let url:string|undefined
|
|
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{}
|
|
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(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'
|
|
|
|
|
|
|
+ if((r.retired||finished&&!r.human&&!r.session)&&r.notify===listener)r.notify=undefined
|
|
|
|
|
+ const status:ManagedBrowserCard['status']=r.retired?'closed':failure?'failed':finished&&!r.human?(r.session&&r.state.open?'idle':'closed'):r.state.available===false?'preparing':r.state.open?'active':'ready'
|
|
|
const {updatedAt:_updated,...installation}=r.state.installation??{updatedAt:0}
|
|
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,
|
|
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}
|
|
|
|
|
|
|
+ message:r.retired?'浏览器执行来源已改变,本轮会话已结束':failure??(status==='idle'?'本轮操作已结束,页面已保留':status==='closed'?'本轮浏览器操作已结束':r.human?(finished?(r.session&&!r.forceClose?'人工控制中,交还后保留页面':'人工控制中,交还后结束本轮会话'):'人工控制中,可交还机器人继续'):r.paused?'接管已过期,请重新接管后交还机器人':r.state.installation?.message),installation:r.state.installation}
|
|
|
const signature=JSON.stringify({...body,installation})
|
|
const signature=JSON.stringify({...body,installation})
|
|
|
if(signature===lastCard)return
|
|
if(signature===lastCard)return
|
|
|
// Chat persistence is an observer; a deleted history row or storage error
|
|
// Chat persistence is an observer; a deleted history row or storage error
|
|
|
// must never block control handoff, authorization cleanup, or the sweep.
|
|
// must never block control handoff, authorization cleanup, or the sweep.
|
|
|
try{if(options.onCard({...body,updatedAt:Date.now()})!==false)lastCard=signature}catch{}
|
|
try{if(options.onCard({...body,updatedAt:Date.now()})!==false)lastCard=signature}catch{}
|
|
|
}
|
|
}
|
|
|
- const get=()=>{
|
|
|
|
|
|
|
+ const get=async()=>{
|
|
|
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')
|
|
|
|
|
|
|
+ while(r.transition){
|
|
|
|
|
+ await r.transition.catch(()=>{});options.authorize();options.signal.throwIfAborted()
|
|
|
|
|
+ if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
|
|
|
|
|
+ }
|
|
|
|
|
+ this.checkResource(r)
|
|
|
|
|
+ if(r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
|
|
|
r.notify?.()
|
|
r.notify?.()
|
|
|
|
|
+ if(!r.human)r.forceClose=false
|
|
|
grant=this.grant(r,'task',options.authorize,options.signal);r.task=grant;listener=()=>notify(r);r.notify=listener
|
|
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
|
|
|
}
|
|
}
|
|
|
- const close=async()=>{
|
|
|
|
|
- if(finished)return;finished=true;options.signal.removeEventListener('abort',abort)
|
|
|
|
|
- if(!grant)return
|
|
|
|
|
|
|
+ const close=(value:{retain?:boolean}={}):Promise<void>=>{
|
|
|
|
|
+ if(finished)return closing??Promise.resolve();finished=true;options.signal.removeEventListener('abort',abort)
|
|
|
|
|
+ if(!grant)return Promise.resolve()
|
|
|
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}}
|
|
|
|
|
- notify(r)
|
|
|
|
|
|
|
+ const retain=value.retain!==false&&!options.signal.aborted
|
|
|
|
|
+ if(!retain){r.forceClose=true;this.dropSession(r)}
|
|
|
|
|
+ closing=this.transition(r,async()=>{
|
|
|
|
|
+ try{
|
|
|
|
|
+ this.checkResource(r)
|
|
|
|
|
+ if(r.human){try{this.check(r.human);return}catch{this.grants.delete(r.human.id);r.human=undefined}}
|
|
|
|
|
+ // A status-only turn must not refresh the retained session's idle deadline.
|
|
|
|
|
+ if(retain&&!activated&&r.session&&r.parked)return
|
|
|
|
|
+ await this.rest(r,retain)
|
|
|
|
|
+ }catch{ /* Revoked resources are fenced and reclaimed by the Runner. */ }
|
|
|
|
|
+ finally{notify(r)}
|
|
|
|
|
+ }).finally(()=>{if(r.closing===closing)r.closing=undefined})
|
|
|
|
|
+ r.closing=closing;return closing
|
|
|
}
|
|
}
|
|
|
- const abort=()=>{void close()};options.signal.addEventListener('abort',abort,{once:true})
|
|
|
|
|
|
|
+ const abort=()=>{void close({retain:false})};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=await get(),r=g.resource
|
|
|
notify(r)
|
|
notify(r)
|
|
|
try{
|
|
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.
|
|
|
await this.waitForControl(g)
|
|
await this.waitForControl(g)
|
|
|
|
|
+ if(name!=='managed_browser_state'){await this.activate(g);activated=true}
|
|
|
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')
|
|
@@ -273,7 +339,7 @@ export class ManagedBrowsers {
|
|
|
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
|
|
|
|
|
|
|
+ ctx.body=await this.transition(r,async()=>{
|
|
|
await this.view(r,g=>this.request(g,'status'))
|
|
await this.view(r,g=>this.request(g,'status'))
|
|
|
if(r.state.available===false)throw new HttpError(409,'浏览器环境尚未就绪,请先等待自动准备完成','browser_preparing')
|
|
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()){
|
|
@@ -281,20 +347,22 @@ export class ManagedBrowsers {
|
|
|
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')
|
|
|
if(r.taking)await r.taking
|
|
if(r.taking)await r.taking
|
|
|
if(!r.human)throw new HttpError(409,'接管未完成,请重新接管','browser_control_expired')
|
|
if(!r.human)throw new HttpError(409,'接管未完成,请重新接管','browser_control_expired')
|
|
|
- ctx.body={...this.controlStatus(r),controlId:r.human.id,token:this.nodes.open(r.human.sealedToken!),expiresAt:r.human.expiresAt};return
|
|
|
|
|
|
|
+ return {...this.controlStatus(r),controlId:r.human.id,token:this.nodes.open(r.human.sealedToken!),expiresAt:r.human.expiresAt}
|
|
|
}
|
|
}
|
|
|
|
|
+ if(r.human)this.grants.delete(r.human.id)
|
|
|
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.notify?.()})()
|
|
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.parked=false;r.taking=undefined;return {...this.controlStatus(r),controlId:g.id,token,expiresAt:g.expiresAt}}
|
|
|
catch(error){this.grants.delete(g.id);if(r.human===g)r.human=undefined;r.notify?.();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}})
|
|
|
const input=async(owner:string,id:string,raw:unknown,navigation:boolean)=>{
|
|
const input=async(owner:string,id:string,raw:unknown,navigation:boolean)=>{
|
|
|
const body=parse(credentials.extend({requestId:z.string().uuid(),generation:z.number().int().positive(),frameId:z.string().uuid().optional(),action:z.record(z.string(),z.unknown())}).strict(),raw),g=this.token(owner,id,body),r=g.resource
|
|
const body=parse(credentials.extend({requestId:z.string().uuid(),generation:z.number().int().positive(),frameId:z.string().uuid().optional(),action:z.record(z.string(),z.unknown())}).strict(),raw),g=this.token(owner,id,body),r=g.resource
|
|
|
return this.journal.execute(owner,g.id,body.requestId,'managed_browser_human_input',{generation:body.generation,frameId:body.frameId,action:body.action,navigation},()=>{this.token(owner,id,body)},async()=>{
|
|
return this.journal.execute(owner,g.id,body.requestId,'managed_browser_human_input',{generation:body.generation,frameId:body.frameId,action:body.action,navigation},()=>{this.token(owner,id,body)},async()=>{
|
|
|
- if(r.taking)throw new HttpError(409,'请等待当前操作结束后接管','browser_control_pending')
|
|
|
|
|
|
|
+ if(r.taking||r.transition)throw new HttpError(409,'请等待当前操作结束后接管','browser_control_pending')
|
|
|
if(body.generation!==r.state.generation)throw new HttpError(409,'控制代次已改变,请刷新','browser_generation_expired')
|
|
if(body.generation!==r.state.generation)throw new HttpError(409,'控制代次已改变,请刷新','browser_generation_expired')
|
|
|
let action=body.action
|
|
let action=body.action
|
|
|
if(!navigation){
|
|
if(!navigation){
|
|
@@ -319,19 +387,42 @@ export class ManagedBrowsers {
|
|
|
router.post('/api/app/agents/:id/computer/giveback',async(ctx,next)=>{
|
|
router.post('/api/app/agents/:id/computer/giveback',async(ctx,next)=>{
|
|
|
if(!matches(ctx))return next()
|
|
if(!matches(ctx))return next()
|
|
|
const owner=this.auth.require(ctx).id,body=parse(credentials.extend({notes:z.string().max(4000).default('')}).strict(),(ctx.request as any).body),g=this.token(owner,ctx.params.id,body),r=g.resource
|
|
const owner=this.auth.require(ctx).id,body=parse(credentials.extend({notes:z.string().max(4000).default('')}).strict(),(ctx.request as any).body),g=this.token(owner,ctx.params.id,body),r=g.resource
|
|
|
|
|
+ ctx.body=await this.transition(r,async()=>{
|
|
|
|
|
+ this.token(owner,ctx.params.id,body)
|
|
|
// 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)
|
|
|
|
|
- if(!r.task){r.closing=this.view(r,view=>this.request(view,'close'));try{await r.closing}finally{r.closing=undefined}}
|
|
|
|
|
|
|
+ if(r.task)await this.request(r.task,'resume')
|
|
|
|
|
+ else await this.rest(r,true)
|
|
|
|
|
+ r.notes=body.notes;r.human=undefined;r.paused=false;r.frame=undefined;r.frames?.clear();this.grants.delete(g.id)
|
|
|
r.notify?.()
|
|
r.notify?.()
|
|
|
- ctx.body=this.controlStatus(r)
|
|
|
|
|
|
|
+ return this.controlStatus(r)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+ router.post('/api/app/agents/:id/managed-browser/close',async ctx=>{
|
|
|
|
|
+ const owner=this.auth.require(ctx).id,body=parse(credentials,(ctx.request as any).body),g=this.token(owner,ctx.params.id,body),r=g.resource
|
|
|
|
|
+ ctx.body=await this.transition(r,async()=>{
|
|
|
|
|
+ this.token(owner,ctx.params.id,body)
|
|
|
|
|
+ if(r.task)throw new HttpError(409,'机器人任务仍在使用浏览器,请先停止任务','browser_busy')
|
|
|
|
|
+ await this.rest(r,false)
|
|
|
|
|
+ this.grants.delete(g.id);r.human=undefined;r.paused=false;r.notes=undefined;r.notify?.()
|
|
|
|
|
+ return this.controlStatus(r)
|
|
|
|
|
+ })
|
|
|
})
|
|
})
|
|
|
return router
|
|
return router
|
|
|
}
|
|
}
|
|
|
private async sweep(){
|
|
private async sweep(){
|
|
|
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.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined}}
|
|
|
|
|
|
|
+ try{this.checkResource(r)}catch{this.retire(r);continue}
|
|
|
|
|
+ if(r.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined;r.forceClose=true;this.dropSession(r)}}
|
|
|
|
|
+ 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=!!r.task;r.frame=undefined;r.frames?.clear()
|
|
|
|
|
+ if(!r.task)await this.transition(r,async()=>{if(!r.task&&!r.human)await this.rest(r,true)}).catch(()=>{})
|
|
|
|
|
+ }}
|
|
|
if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
|
|
if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
|
|
|
|
|
+ if(r.session&&!r.task&&!r.human&&!r.transition&&!r.refreshing&&Date.now()>=(r.nextStatusAt??0)){
|
|
|
|
|
+ r.nextStatusAt=Date.now()+30000
|
|
|
|
|
+ r.refreshing=this.transition(r,async()=>{if(!r.task&&!r.human&&r.session)await this.view(r,g=>this.request(g,'status'))})
|
|
|
|
|
+ .then(()=>{},()=>this.retire(r)).finally(()=>{r.refreshing=undefined})
|
|
|
|
|
+ }
|
|
|
r.notify?.()
|
|
r.notify?.()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|