|
|
@@ -16,7 +16,7 @@ let home:string,store:WorkspaceStore,service:DesktopEnvironments,agent:Workspace
|
|
|
const auth={require:(ctx:any)=>({id:ctx.get('x-user')||'owner'}),requireAdmin:(ctx:any)=>{if(ctx.get('x-user'))throw new HttpError(403,'仅限管理员','admin_required')},pushAuthorizationVersion:()=>version} as any
|
|
|
const nodes={localNodeID:'local-node',requireSource:()=>{}} as any
|
|
|
const ownerKey=(owner:string)=>createHash('sha256').update('local-node:'+owner).digest('hex')
|
|
|
-beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Browser',nodeId:'local',profile:'default',computer:'browser'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
|
|
|
+beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-env-'));store=new WorkspaceStore(home);service=new DesktopEnvironments(store,auth,nodes);version=1;agent=store.createAgent('owner',{name:'Desktop',nodeId:'local',profile:'default',computer:'local'});host={id:randomUUID(),name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[ownerKey('owner')]}})
|
|
|
const pollers=new Set<ReturnType<typeof setInterval>>()
|
|
|
afterEach(()=>{clearInterval(poller);for(const p of pollers)clearInterval(p);pollers.clear();service.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
|
|
|
const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
|
|
|
@@ -135,12 +135,12 @@ it('routes parallel desktop tools to the host named in the call',async()=>{
|
|
|
expect(service.onlineHostsLine('owner')).toContain('书房 iMac')
|
|
|
}finally{clearInterval(pollerB)}
|
|
|
})
|
|
|
-it('keeps desktop and browser tools open together when both environments are on',async()=>{
|
|
|
+it('ignores a stored browser environment and keeps desktop tools',async()=>{
|
|
|
agent=store.updateAgent('owner',agent.id,{envs:{vm:true,desktop:true,browser:true,cloud:false}})
|
|
|
drive()
|
|
|
expect(service.selected('owner',agent)).toBe('local')
|
|
|
- expect(service.toolMode('owner',agent)).toBe('local')
|
|
|
- await expect(service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:'https://example.test/both'},new AbortController().signal,()=>{},'browser',service.epoch)).resolves.toEqual({ok:true})
|
|
|
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
|
|
|
+ await expect(service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:'https://example.test/both'},new AbortController().signal,()=>{},'browser',service.epoch)).rejects.toMatchObject({status:404,code:'tool_not_found'})
|
|
|
const view=await service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch)
|
|
|
expect(view.content[0].type).toBe('image')
|
|
|
})
|
|
|
@@ -148,10 +148,10 @@ it('degrades envs desktop tools when no host is ready or online',async()=>{
|
|
|
agent=store.updateAgent('owner',agent.id,{computer:'off',envs:{desktop:true,browser:true}})
|
|
|
expect(service.envTools('owner',agent)).toEqual({view:false,browser:false,file:false})
|
|
|
service.exchange({host,results:[]})
|
|
|
- expect(service.envTools('owner',agent)).toEqual({view:true,browser:true,file:false})
|
|
|
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:false})
|
|
|
host.full=[ownerKey('owner')]
|
|
|
service.exchange({host,results:[]})
|
|
|
- expect(service.envTools('owner',agent)).toEqual({view:true,browser:true,file:true})
|
|
|
+ expect(service.envTools('owner',agent)).toEqual({view:true,browser:false,file:true})
|
|
|
})
|
|
|
it('keeps manual takeover scoped per desktop host',async()=>{
|
|
|
agent=store.updateAgent('owner',agent.id,{computer:'local',desktopHost:HOST_A})
|
|
|
@@ -173,33 +173,11 @@ it('keeps manual takeover scoped per desktop host',async()=>{
|
|
|
})
|
|
|
async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
|
|
|
async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}
|
|
|
-it('queues browser operations from concurrent sessions and cancels only the waiting session',async()=>{
|
|
|
- service.exchange({host,results:[]})
|
|
|
- const first=new AbortController(),cancelled=new AbortController(),third=new AbortController()
|
|
|
- const navigate=(signal:AbortSignal,path:string)=>service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:`https://example.test/${path}`},signal,()=>{},'browser',service.epoch)
|
|
|
- const one=navigate(first.signal,'first'),two=navigate(cancelled.signal,'cancelled').catch(error=>error),three=navigate(third.signal,'third')
|
|
|
- let commands:any[]=[]
|
|
|
- await vi.waitFor(()=>{commands=service.exchange({host,results:[]}).commands;expect(commands).toHaveLength(1)})
|
|
|
- const initial=commands[0]
|
|
|
- expect(initial.action.url).toBe('https://example.test/first')
|
|
|
- expect(service.exchange({host,results:[]}).commands).toEqual([])
|
|
|
- cancelled.abort()
|
|
|
- service.exchange({host,results:[{id:initial.id,value:{ok:true}}]})
|
|
|
- await one
|
|
|
- expect(await two).toMatchObject({name:'AbortError'})
|
|
|
- await vi.waitFor(()=>{commands=service.exchange({host,results:[]}).commands;expect(commands).toHaveLength(1)})
|
|
|
- expect(commands[0].action.url).toBe('https://example.test/third')
|
|
|
- expect(commands[0].resource).toBe(initial.resource)
|
|
|
- service.exchange({host,results:[{id:commands[0].id,value:{ok:true}}]})
|
|
|
- await expect(three).resolves.toEqual({ok:true})
|
|
|
- expect(first.signal.aborted).toBe(false)
|
|
|
- expect(third.signal.aborted).toBe(false)
|
|
|
-})
|
|
|
it('keeps capability private and refuses origins before parsing native commands',async()=>{const instance=acquireServiceInstance(home,'0.4.3'),a=new Koa().use(instance.middleware(async()=>{},()=>true,ctx=>service.bridge(ctx)));try{await request(a.callback()).post('/desktop/environment').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).set('Origin','http://evil.test').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).send({host,results:[]}).expect(200);expect(service.online).toBe(true)}finally{instance.release()}})
|
|
|
it('auto selects only an approved and ready local host; observing never opens a browser',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'auto'});expect(service.selected('owner',agent)).toBeUndefined();expect(service.exchange({host:{...host,approved:[]},results:[]}).commands).toEqual([]);expect(service.selected('owner',agent)).toBeUndefined();service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');host.screen=false;service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBeUndefined();await request(app()).get(base()+'/desktop-environment').set('x-user','other').expect(404)})
|
|
|
it('shares the physical desktop across owners and robots but scopes manual tokens to their owner and robot',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'local'});const other=store.createAgent('other',{name:'Other',nodeId:'local',profile:'default',computer:'local'});host.approved.push(ownerKey('other'));drive();const ticket=await take();await request(app()).post('/api/app/agents/'+other.id+'/computer/take').set('x-user','other').send({requestId:randomUUID()}).expect(409);await request(app()).post('/api/app/agents/'+other.id+'/computer/renew').set('x-user','other').send(ticket).expect(410);const state=(await request(app()).get('/api/app/agents/'+other.id+'/computer').set('x-user','other')).body;expect(state.controlId).toBeUndefined();await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)})
|
|
|
it('rejects stale frames and replays an input result exactly once',async()=>{const calls:any[]=[];drive(c=>{calls.push(c);return c.operation==='view'?{data:'Zm9v',width:1280,height:800}:{ok:true}});const t=await take(),f=await frame(),body={controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'click',x:10,y:10}};await request(app()).post(base()+'/computer/input').send({...body,frameId:randomUUID()}).expect(409);await request(app()).post(base()+'/computer/input').send(body).expect(200);await request(app()).post(base()+'/computer/input').send(body).expect(200);expect(calls.filter(c=>c.operation==='input')).toHaveLength(1);await request(app()).post(base()+'/computer/input').send({...body,action:{kind:'text',text:'different'}}).expect(409);await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
|
|
|
it('expired control remains paused until a new takeover is explicitly returned',async()=>{drive();const t=await take();const original=Date.now;const time=vi.spyOn(Date,'now').mockImplementation(()=>original()+31000);service.exchange({host,results:[]});await request(app()).post(base()+'/computer/renew').send(t).expect(410);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('error');time.mockRestore();const next=await take();await request(app()).post(base()+'/computer/giveback').send({...next,notes:'已完成登录'}).expect(200);expect((await request(app()).get(base()+'/computer')).body.mode).toBe('idle')})
|
|
|
-it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'browser',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'browser',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
|
|
|
-it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'browser',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
|
|
|
-it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(400);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
|
|
|
+it('pins tool calls to the host epoch and does not dispatch after permissions change',async()=>{drive();const epoch=service.epoch,signal=new AbortController().signal;await service.call('owner',agent.id,'desktop_environment_view',{},signal,()=>{},'local',epoch);host.id=randomUUID();service.exchange({host,results:[]});await expect(service.call('owner',agent.id,'desktop_environment_action',{kind:'text',text:'no'},signal,()=>{},'local',epoch)).rejects.toThrow('连接已改变');version++;const t=await take();version++;await request(app()).post(base()+'/computer/renew').send(t).expect(410)})
|
|
|
+it('waits for human release before the robot runs and forwards handoff notes',async()=>{drive();const t=await take();let complete=false;const call=service.call('owner',agent.id,'desktop_environment_view',{},new AbortController().signal,()=>{},'local',service.epoch).then(value=>{complete=true;return value});await new Promise(resolve=>setTimeout(resolve,30));expect(complete).toBe(false);await request(app()).post(base()+'/computer/giveback').send({...t,notes:'已登录测试账号'}).expect(200);const value=await call;expect(JSON.stringify(value)).toContain('已登录测试账号')})
|
|
|
+it('refuses dangerous navigation and prevents changing profile during active work',async()=>{drive();const t=await take(),f=await frame();await request(app()).post(base()+'/computer/browser').send({controlId:t.controlId,token:t.token,requestId:randomUUID(),generation:f.generation,frameId:f.id,action:{kind:'navigate',url:'file:///etc/passwd'}}).expect(409);await request(app()).post(base()+'/computer/giveback').send(t).expect(200);store.put('owner','turn','turn',{agentId:agent.id,status:'running'});await request(app()).put(base()+'/browser-profile').send({profile:'temporary'}).expect(409)})
|