Эх сурвалжийг харах

fix: report VM proxy readiness failures accurately

Samien 1 сар өмнө
parent
commit
2c26a3ca5e

+ 1 - 1
docs/execution-environments.md

@@ -34,7 +34,7 @@
 
 DNS 在虚拟机内映射为临时地址,连接时还原域名;Runner 经同一上游代理查询 DNS over HTTPS,并检查实际目标地址。继续限制为公网 80/443,拒绝内网、宿主、元数据地址以及其他端口。UDP/QUIC、IPv6 直接出口不开放;无法代理的流量不会改为直连。关闭代理保留原有网络行为:动态环境使用原来的受控应用代理,旧 Compose 环境保持断网。
 
-修改代理先关闭旧通道并结束相关虚拟机工具任务,之后使用新配置。节点状态区分离线、旧版本、待更新、连接失败与已同步;已同步后仍会在实例启动时检查出口隔离。代理测试从执行节点发起,测试已保存的配置,不代表某个服务的账号授权有效。Yaoyao 后端代发的 HTTP 服务/MCP 请求使用后端网络配置。
+修改代理先关闭旧通道并结束相关虚拟机工具任务,之后使用新配置。节点状态区分离线、旧版本、待更新、未生效与已同步,并提示缺少网络网关等具体原因;已同步后仍会在实例启动时检查出口隔离。代理测试从执行节点发起,先检查网络网关,再测试已保存的代理连接,不代表某个服务的账号授权有效。服务器本机命令、Hermes 模型请求、托管浏览器及 Yaoyao 后端代发的 HTTP 服务/MCP 请求使用各自的网络配置。
 
 ### 更新部署
 

+ 2 - 1
src/client/components/app/ExecutionSettingsPanel.vue

@@ -19,7 +19,7 @@ async function saveResource(){await action(async()=>{
 },'系统授权已保存')}
 async function saveEnvironment(){await action(async()=>{await apiRequest(root+'/environment',{method:'PUT',body:{mode:mode.value,revision:state.value!.execution.revision}});await load()},'运行环境已保存,从下一轮任务生效')}
 async function saveProxy(){await action(async()=>{const {hasPassword,...proxy}=state.value!.proxy;await apiRequest(root+'/proxy',{method:'PUT',timeoutMs:60000,body:{...proxy,...(clearPassword.value?{password:''}:password.value?{password:password.value}:{})}});password.value='';clearPassword.value=false;await load()},'代理配置已保存,请查看各节点生效状态')}
-async function testProxy(){await action(async()=>{const result=await apiRequest<{nodes:Array<{name:string;ok:boolean}>}>(root+'/proxy/test',{method:'POST',timeoutMs:60000,body:{}});if(result.nodes.some(n=>!n.ok))throw new Error(result.nodes.filter(n=>!n.ok).map(n=>n.name).join('、')+'连接失败,请核对地址和认证')},'在线节点均可连接已保存的代理')}
+async function testProxy(){await action(async()=>{const result=await apiRequest<{nodes:Array<{name:string;ok:boolean;error?:string}>}>(root+'/proxy/test',{method:'POST',timeoutMs:60000,body:{}});if(result.nodes.some(n=>!n.ok))throw new Error(result.nodes.filter(n=>!n.ok).map(n=>`${n.name}:${n.error||'代理连接失败,请核对地址和认证'}`).join(';'))},'在线节点的网络网关已就绪,均可连接已保存的代理')}
 onMounted(()=>action(load,''))
 </script>
 
@@ -57,6 +57,7 @@ onMounted(()=>action(load,''))
       </section>
       <form @submit.prevent="saveProxy">
         <h4>虚拟机全局代理</h4>
+        <p v-if="state.execution.mode==='server'">当前运行环境是「服务器本机」。此处代理仅用于虚拟机;本机命令、Hermes 模型请求和托管浏览器仍使用各自的网络配置。</p>
         <p>所有受管虚拟环境的程序共用出口。保留公网 HTTP/HTTPS 限制,其他协议阻断;代理故障时不直连。保存会断开旧网络任务。</p>
         <label class="check"><input v-model="state.proxy.enabled" type="checkbox" :disabled="busy">启用统一代理</label>
         <template v-if="state.proxy.enabled">

+ 10 - 1
src/runner/agent.ts

@@ -208,7 +208,16 @@ export class RunnerAgent {
       const response=await this.target.session.request(path,{search,maxResponseBytes:8*1024*1024})
       return {status:response.status,headers:Object.fromEntries(response.headers.entries()),body:response.body.toString('base64')}
     }
-    if(command.kind==='network.configure'||command.kind==='network.test'){if(!this.computers)throw new Error('节点没有虚拟环境');const {proxy}=await this.api('network-settings',{});if(command.kind==='network.test')return checkProxy(proxy);await this.computers.updateProxy(proxy);return {revision:proxy?.revision??0}}
+    if(command.kind==='network.configure'||command.kind==='network.test'){
+      if(!this.computers)throw new Error('节点没有虚拟环境')
+      const {proxy}=await this.api('network-settings',{})
+      if(command.kind==='network.test'){
+        // A reachable upstream alone does not make the VM's network usable.
+        await this.computers.provider.verifyNetwork()
+        return checkProxy(proxy)
+      }
+      await this.computers.updateProxy(proxy);return {revision:proxy?.revision??0}
+    }
     const connectionId=String(p.connectionId??'')
     if(command.kind==='gateway.open') {
       if(this.closedConnections.has(connectionId)||this.connections.has(connectionId)||!/^[0-9a-f-]{36}$/.test(connectionId)||this.connections.size>=64)throw new Error('执行通道无效或已达上限')

+ 11 - 3
src/server/runnerHub.ts

@@ -23,11 +23,19 @@ interface Pending { command:RunnerCommand; resolve(value:any):void; reject(error
 interface GatewayConnection { taskEnvironment?:()=>import('../shared/executionEnvironment.js').TaskEnvironment; runnerId:string;computer?:{environmentId:string;ownerKey:string;agentId:string};cleanupOnly?:boolean;publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>; valid():boolean; onEvent(frame:GatewayFrame):void; onDisconnect():void }
 interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerprint:string;result:Promise<unknown>}> }
 
+// Only expose known diagnostics; arbitrary transport errors can contain credentials.
+function networkFailure(error:unknown,fallback:string){
+  if(!(error instanceof HttpError))return fallback
+  if(error.code==='computer_network_upgrade_required')return '缺少虚拟机网络网关,请在 Bot 设置 → 本地虚拟机中重新准备网络镜像;Compose 节点请更新网络网关'
+  if(error.code==='computer_network_cleanup_failed')return '旧网络任务未完成清理,请重试保存代理'
+  return fallback
+}
+
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
   executionSettings?:import('./executionSettings.js').ExecutionSettings
   private networkApplied=new Map<string,{revision:number;error?:string}>()
-  networkStatus(){return this.records().filter(r=>r.enabled).map(r=>{const s=this.summary(r),applied=this.networkApplied.get(r.id);return {id:r.id,name:r.name,online:s.online,status:!s.online?'离线':!s.features.includes('vm-egress-v1')?'需要更新 Runner':applied?.error?'连接失败':applied?.revision===this.executionSettings?.proxy().revision?'已同步,启动时校验出口':'待更新'}})}
+  networkStatus(){return this.records().filter(r=>r.enabled).map(r=>{const s=this.summary(r),applied=this.networkApplied.get(r.id);return {id:r.id,name:r.name,online:s.online,status:!s.online?'离线':!s.features.includes('vm-egress-v1')?'需要更新 Runner':applied?.error?`未生效:${applied.error}`:applied?.revision===this.executionSettings?.proxy().revision?'已同步,启动时校验出口':'待更新'}})}
   private networkSupported(id:string){return !this.executionSettings?.proxy().enabled||this.online.get(id)?.features.includes('vm-egress-v1')===true}
   private networkCommandAllowed(id:string,kind:RunnerCommand['kind'],payload:Record<string,unknown>){return kind!=='computer.control'||this.networkSupported(id)||!(payload.op==='take'||payload.op==='input'||payload.op==='lifecycle'&&!['stop','remove'].includes(String(payload.action)))}
   async configureNetworks(){await Promise.all(this.records().filter(r=>r.enabled&&this.summary(r).online).map(async r=>{
@@ -35,9 +43,9 @@ export class RunnerHub {
       if(!this.networkSupported(r.id))await Promise.all([...this.connections].filter(([,c])=>c.runnerId===r.id&&c.computer).map(async([id,c])=>{this.connections.delete(id);c.onDisconnect();await this.request(r.id,'gateway.close',{connectionId:id}).catch(()=>{})}))
       return
     }
-    try{const result=await this.request(r.id,'network.configure',{});this.networkApplied.set(r.id,{revision:result.revision})}catch{this.networkApplied.set(r.id,{revision:-1,error:'网络配置未生效'})}
+    try{const result=await this.request(r.id,'network.configure',{});this.networkApplied.set(r.id,{revision:result.revision})}catch(error){this.networkApplied.set(r.id,{revision:-1,error:networkFailure(error,'网络配置未生效,请检查执行节点后重试保存')})}
   }))}
-  async testNetworks(){const nodes=this.records().filter(r=>r.enabled&&this.summary(r).online&&this.summary(r).features.includes('vm-egress-v1'));if(!nodes.length)throw new HttpError(409,'没有支持代理的在线执行节点','proxy_node_unavailable');return {nodes:await Promise.all(nodes.map(async r=>{try{await this.request(r.id,'network.test',{});return {id:r.id,name:r.name,ok:true}}catch{return {id:r.id,name:r.name,ok:false}}}))}}
+  async testNetworks(){const nodes=this.records().filter(r=>r.enabled&&this.summary(r).online&&this.summary(r).features.includes('vm-egress-v1'));if(!nodes.length)throw new HttpError(409,'没有支持代理的在线执行节点','proxy_node_unavailable');return {nodes:await Promise.all(nodes.map(async r=>{try{await this.request(r.id,'network.test',{});return {id:r.id,name:r.name,ok:true}}catch(error){return {id:r.id,name:r.name,ok:false,error:networkFailure(error,'代理连接失败,请核对地址、认证和执行节点网络')}}}))}}
   composeDesktops=new ComposeDesktops([])
   get idleForUpdate(): boolean { return this.localVmActivity.size === 0 && [...this.pending.values()].every(commands => commands.size === 0) && [...this.artifacts.values()].every(upload => upload.result !== undefined) }
   private localVmActivity = new Map<string, { owner: string; runnerId: string }>()

+ 7 - 0
tests/client/executionSettingsPanel.test.ts

@@ -27,3 +27,10 @@ it('does not grant new resources to every Bot and reports invalid variable JSON
   const count=request.mock.calls.length;await wrapper.findAll('form')[1]!.trigger('submit');await flushPromises()
   expect(request.mock.calls.length).toBe(count);expect(wrapper.get('[role=alert]').text()).not.toBe('');wrapper.unmount()
 })
+it('shows the node readiness failure instead of blaming proxy credentials',async()=>{
+  const wrapper=mount(ExecutionSettingsPanel);await flushPromises()
+  request.mockResolvedValueOnce({nodes:[{name:'本机虚拟机',ok:false,error:'缺少虚拟机网络网关,请重新准备网络镜像'}]})
+  await wrapper.findAll('button').find(b=>b.text()==='测试已保存的代理')!.trigger('click');await flushPromises()
+  expect(wrapper.get('[role=alert]').text()).toBe('本机虚拟机:缺少虚拟机网络网关,请重新准备网络镜像')
+  expect(wrapper.find('[role=status]').exists()).toBe(false);wrapper.unmount()
+})

+ 27 - 0
tests/server/runner.test.ts

@@ -80,6 +80,33 @@ it('blocks old Runner control paths when the global VM proxy is enabled',async()
   expect(await (runner as any).api('control-check',{controlId:randomUUID()})).toEqual({allowed:false})
   expect((hub as any).networkCommandAllowed(config.runnerId,'computer.control',{op:'lifecycle',action:'stop'})).toBe(true)
 })
+it('reports missing network prerequisites for saved proxy settings and clears the error after applying them',async()=>{
+  hub.executionSettings={proxy:()=>({enabled:true,revision:4})} as any
+  ;(hub as any).online.get(config.runnerId).features.push('vm-egress-v1')
+  const request=vi.spyOn(hub as any,'request').mockRejectedValue(new HttpError(502,'private transport details','computer_network_upgrade_required'))
+  try{
+    await hub.configureNetworks()
+    expect(hub.networkStatus()[0]?.status).toContain('缺少虚拟机网络网关')
+    const result=await hub.testNetworks()
+    expect(result.nodes[0]).toMatchObject({ok:false,error:expect.stringContaining('重新准备网络镜像')})
+    expect(JSON.stringify(result)).not.toContain('private transport details')
+    request.mockRejectedValue(new Error('http://user:secret@proxy.invalid'))
+    expect(JSON.stringify(await hub.testNetworks())).not.toContain('secret')
+    request.mockResolvedValue({revision:4})
+    await hub.configureNetworks()
+    expect(hub.networkStatus()[0]?.status).toBe('已同步,启动时校验出口')
+  }finally{request.mockRestore()}
+})
+it('does not report a successful VM proxy test when the network gateway is missing',async()=>{
+  hub.executionSettings={proxy:()=>({enabled:true,revision:4})} as any
+  const verifyNetwork=vi.fn().mockRejectedValue(new HttpError(409,'缺少网络网关','computer_network_upgrade_required'))
+  const original=(runner as any).computers
+  ;(runner as any).computers={provider:{verifyNetwork},config:{imageId:'sha256:'+'a'.repeat(64)}}
+  try{
+    await expect((runner as any).execute({kind:'network.test',payload:{}})).rejects.toMatchObject({code:'computer_network_upgrade_required'})
+    expect(verifyNetwork).toHaveBeenCalledOnce()
+  }finally{(runner as any).computers=original}
+})
 it('admits virtual computer calls only on a computer gateway with an owned session',async()=>{
   const connectionId=randomUUID(),sessionId=randomUUID(),rpc=vi.fn(async()=>({ok:true}))
   const computer=Object.assign(Object.create(ComputerGateway.prototype),{rpc,close:()=>{}}) as ComputerGateway