Просмотр исходного кода

feat: 增加统一运行环境、系统服务授权和虚拟机全局代理

Samien 1 месяц назад
Родитель
Сommit
9f8f01f266
46 измененных файлов с 1310 добавлено и 99 удалено
  1. 27 5
      compose.desktops.cursor.yaml
  2. 53 9
      compose.desktops.yaml
  3. 9 0
      deploy/computer/Dockerfile.network
  4. 197 0
      deploy/computer/network_gateway.py
  5. 54 0
      docs/execution-environments.md
  6. 16 5
      integrations/hermes-bots-bridge/bridge_runtime.py
  7. 9 0
      integrations/hermes-bots-bridge/tests/test_computer_policy.py
  8. 27 7
      scripts/generate-desktop-compose.mjs
  9. 80 0
      src/client/components/app/ExecutionSettingsPanel.vue
  10. 2 1
      src/client/components/app/HostToolsPanel.vue
  11. 3 2
      src/client/components/workspace/BotSettingsDialog.vue
  12. 13 3
      src/runner/agent.ts
  13. 1 0
      src/runner/computers/compose.ts
  14. 38 8
      src/runner/computers/container.ts
  15. 4 0
      src/runner/computers/images.ts
  16. 3 3
      src/runner/computers/localVm.ts
  17. 45 0
      src/runner/network/managedProxy.ts
  18. 6 3
      src/runner/network/socketBroker.ts
  19. 59 0
      src/runner/network/upstreamProxy.ts
  20. 5 3
      src/runner/worker/control.ts
  21. 47 9
      src/runner/worker/gateway.ts
  22. 31 0
      src/runner/worker/taskEnvironment.ts
  23. 9 1
      src/server/app.ts
  24. 2 1
      src/server/botPlugins/workspacePlugins.ts
  25. 5 3
      src/server/composeDesktops.ts
  26. 92 0
      src/server/executionSettings.ts
  27. 32 10
      src/server/runnerHub.ts
  28. 2 2
      src/server/vmComputer.ts
  29. 15 9
      src/server/workspaceEnvironment.ts
  30. 1 1
      src/server/workspaceGateway.ts
  31. 3 2
      src/server/workspacePrompt.ts
  32. 28 9
      src/server/workspaceRuntime.ts
  33. 3 1
      src/server/workspaceToolLease.ts
  34. 1 0
      src/shared/botEnvironment.ts
  35. 1 1
      src/shared/composeDesktops.ts
  36. 17 0
      src/shared/executionEnvironment.ts
  37. 1 1
      src/shared/runner.ts
  38. 29 0
      tests/client/executionSettingsPanel.test.ts
  39. 66 0
      tests/live/managedNetwork.live.test.ts
  40. 84 0
      tests/server/executionProxyRuntime.test.ts
  41. 109 0
      tests/server/executionSettings.test.ts
  42. 1 0
      tests/server/localVm.test.ts
  43. 7 0
      tests/server/runner.test.ts
  44. 65 0
      tests/server/upstreamProxy.test.ts
  45. 6 0
      tests/server/workspaceEnvironment.test.ts
  46. 2 0
      tests/server/workspaceStopUi.test.ts

+ 27 - 5
compose.desktops.cursor.yaml

@@ -29,7 +29,7 @@ services:
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: "1"
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
-      HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"647fbb98-0c40-4d96-91e0-3290eb9020eb\",\"name\":\"Cursor 开发桌面\",\"socketPath\":\"/run/yaoyao-desktops/desktop-cursor/desktop.sock\",\"imageKey\":\"cursor\"}]"
+      HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"647fbb98-0c40-4d96-91e0-3290eb9020eb\",\"name\":\"Cursor 开发桌面\",\"socketPath\":\"/run/yaoyao-desktops/desktop-cursor/desktop.sock\",\"networkSocketPath\":\"/run/yaoyao-networks/desktop-cursor/gateway.sock\",\"imageKey\":\"cursor\"}]"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
       HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/yaoyao.git}"
       HERMES_YAOYAO_HOME: /home/node/.yaoyao
@@ -40,6 +40,7 @@ services:
     volumes:
       - yaoyao-data:/home/node/.yaoyao
       - desktop-cursor-ipc:/run/yaoyao-desktops/desktop-cursor:ro
+      - desktop-cursor-network-ipc:/run/yaoyao-networks/desktop-cursor:ro
     tmpfs:
       - /tmp:mode=1777
 
@@ -49,12 +50,12 @@ services:
     build:
       context: ./deploy/computer
       dockerfile: Dockerfile.cursor
-    hostname: desktop-cursor
-    extra_hosts:
-      - "desktop-cursor:127.0.0.1"
     init: true
     restart: unless-stopped
-    network_mode: none
+    network_mode: "service:desktop-cursor-network"
+    depends_on:
+      desktop-cursor-network:
+        condition: service_started
     shm_size: 512m
     mem_limit: 4g
     cpus: 2
@@ -71,9 +72,30 @@ services:
     volumes:
       - desktop-cursor-workspace:/home/cua/workspace
       - desktop-cursor-ipc:/run/yaoyao-private/bridge
+  desktop-cursor-network:
+    image: localhost/yaoyao/network:1
+    build:
+      context: ./deploy/computer
+      dockerfile: Dockerfile.network
+    command: ["--init", "--socket"]
+    init: true
+    restart: unless-stopped
+    network_mode: none
+    dns: ["198.18.0.1"]
+    read_only: true
+    cap_drop: ["ALL"]
+    cap_add: ["NET_ADMIN"]
+    devices: ["/dev/net/tun:/dev/net/tun"]
+    mem_limit: 128m
+    pids_limit: 64
+    environment:
+      YAOYAO_COMPOSE_DESKTOP_ID: "647fbb98-0c40-4d96-91e0-3290eb9020eb"
+    volumes:
+      - desktop-cursor-network-ipc:/run/yaoyao-network
 
 volumes:
   yaoyao-data:
 
   desktop-cursor-workspace:
   desktop-cursor-ipc:
+  desktop-cursor-network-ipc:

+ 53 - 9
compose.desktops.yaml

@@ -29,7 +29,7 @@ services:
       HERMES_YAOYAO_ALLOW_INSECURE_LAN: "1"
       HERMES_YAOYAO_SUPERVISE_DASHBOARD: "0"
       HERMES_YAOYAO_LOCAL_VM_HOST: "runner"
-      HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"160fe7cd-e530-4ac5-a97f-26c19a362b11\",\"name\":\"共享桌面 1\",\"socketPath\":\"/run/yaoyao-desktops/desktop-1/desktop.sock\",\"imageKey\":\"standard\"},{\"id\":\"eba5caaf-744e-4f27-bcdd-1f1f35d05a42\",\"name\":\"共享桌面 2\",\"socketPath\":\"/run/yaoyao-desktops/desktop-2/desktop.sock\",\"imageKey\":\"standard\"}]"
+      HERMES_YAOYAO_COMPOSE_DESKTOPS: "[{\"id\":\"160fe7cd-e530-4ac5-a97f-26c19a362b11\",\"name\":\"共享桌面 1\",\"socketPath\":\"/run/yaoyao-desktops/desktop-1/desktop.sock\",\"networkSocketPath\":\"/run/yaoyao-networks/desktop-1/gateway.sock\",\"imageKey\":\"standard\"},{\"id\":\"eba5caaf-744e-4f27-bcdd-1f1f35d05a42\",\"name\":\"共享桌面 2\",\"socketPath\":\"/run/yaoyao-desktops/desktop-2/desktop.sock\",\"networkSocketPath\":\"/run/yaoyao-networks/desktop-2/gateway.sock\",\"imageKey\":\"standard\"}]"
       HERMES_YAOYAO_CHAT_CACHE_MODE: "${HERMES_YAOYAO_CHAT_CACHE_MODE:-prefer-local}"
       HERMES_YAOYAO_RELEASE_SOURCE: "${HERMES_YAOYAO_RELEASE_SOURCE:-https://github.com/Lsamien/yaoyao.git}"
       HERMES_YAOYAO_HOME: /home/node/.yaoyao
@@ -40,7 +40,9 @@ services:
     volumes:
       - yaoyao-data:/home/node/.yaoyao
       - desktop-1-ipc:/run/yaoyao-desktops/desktop-1:ro
+      - desktop-1-network-ipc:/run/yaoyao-networks/desktop-1:ro
       - desktop-2-ipc:/run/yaoyao-desktops/desktop-2:ro
+      - desktop-2-network-ipc:/run/yaoyao-networks/desktop-2:ro
     tmpfs:
       - /tmp:mode=1777
 
@@ -49,12 +51,12 @@ services:
     build:
       context: ./deploy/computer
       dockerfile: Dockerfile
-    hostname: desktop-1
-    extra_hosts:
-      - "desktop-1:127.0.0.1"
     init: true
     restart: unless-stopped
-    network_mode: none
+    network_mode: "service:desktop-1-network"
+    depends_on:
+      desktop-1-network:
+        condition: service_started
     shm_size: 512m
     mem_limit: 4g
     cpus: 2
@@ -71,17 +73,37 @@ services:
     volumes:
       - desktop-1-workspace:/home/cua/workspace
       - desktop-1-ipc:/run/yaoyao-private/bridge
+  desktop-1-network:
+    image: localhost/yaoyao/network:1
+    build:
+      context: ./deploy/computer
+      dockerfile: Dockerfile.network
+    command: ["--init", "--socket"]
+    init: true
+    restart: unless-stopped
+    network_mode: none
+    dns: ["198.18.0.1"]
+    read_only: true
+    cap_drop: ["ALL"]
+    cap_add: ["NET_ADMIN"]
+    devices: ["/dev/net/tun:/dev/net/tun"]
+    mem_limit: 128m
+    pids_limit: 64
+    environment:
+      YAOYAO_COMPOSE_DESKTOP_ID: "160fe7cd-e530-4ac5-a97f-26c19a362b11"
+    volumes:
+      - desktop-1-network-ipc:/run/yaoyao-network
   desktop-2:
     image: "${YAOYAO_DESKTOP_IMAGE:-hermes-yaoyao-desktop:0.4.0}"
     build:
       context: ./deploy/computer
       dockerfile: Dockerfile
-    hostname: desktop-2
-    extra_hosts:
-      - "desktop-2:127.0.0.1"
     init: true
     restart: unless-stopped
-    network_mode: none
+    network_mode: "service:desktop-2-network"
+    depends_on:
+      desktop-2-network:
+        condition: service_started
     shm_size: 512m
     mem_limit: 4g
     cpus: 2
@@ -98,11 +120,33 @@ services:
     volumes:
       - desktop-2-workspace:/home/cua/workspace
       - desktop-2-ipc:/run/yaoyao-private/bridge
+  desktop-2-network:
+    image: localhost/yaoyao/network:1
+    build:
+      context: ./deploy/computer
+      dockerfile: Dockerfile.network
+    command: ["--init", "--socket"]
+    init: true
+    restart: unless-stopped
+    network_mode: none
+    dns: ["198.18.0.1"]
+    read_only: true
+    cap_drop: ["ALL"]
+    cap_add: ["NET_ADMIN"]
+    devices: ["/dev/net/tun:/dev/net/tun"]
+    mem_limit: 128m
+    pids_limit: 64
+    environment:
+      YAOYAO_COMPOSE_DESKTOP_ID: "eba5caaf-744e-4f27-bcdd-1f1f35d05a42"
+    volumes:
+      - desktop-2-network-ipc:/run/yaoyao-network
 
 volumes:
   yaoyao-data:
 
   desktop-1-workspace:
   desktop-1-ipc:
+  desktop-1-network-ipc:
   desktop-2-workspace:
   desktop-2-ipc:
+  desktop-2-network-ipc:

+ 9 - 0
deploy/computer/Dockerfile.network

@@ -0,0 +1,9 @@
+# Source/version fixed; Go module downloads are verified by the module checksum database.
+FROM golang:1.24.4-bookworm AS builder
+RUN CGO_ENABLED=0 GOBIN=/out go install github.com/xjasonlyu/tun2socks/v2@v2.6.0
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends python3 iproute2 && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /out/tun2socks /usr/local/bin/tun2socks
+COPY network_gateway.py /usr/local/libexec/yaoyao/network_gateway.py
+LABEL cn.samien.yaoyao.network-gateway="1"
+ENTRYPOINT ["python3","/usr/local/libexec/yaoyao/network_gateway.py"]

+ 197 - 0
deploy/computer/network_gateway.py

@@ -0,0 +1,197 @@
+"""A network namespace with only loopback + TUN. No physical/default internet NIC.
+All TCP sockets belong to the authenticated parent broker. Guest processes never
+receive proxy credentials. Fake DNS maps back to hostnames at the SOCKS boundary;
+the parent resolves those through the upstream proxy and checks public addresses.
+"""
+import asyncio
+import base64
+import contextlib
+import ipaddress
+import json
+import os
+from pathlib import Path
+import secrets
+import socket
+import struct
+import subprocess
+import sys
+import time
+import uuid
+
+SOCKS = 3129
+DNS = '198.18.0.1'
+
+class Tunnel:
+    def __init__(self, send):
+        self.send = send
+        self.streams = {}
+        self.names = {}
+        self.addresses = {}
+
+    def dns(self, packet):
+        if len(packet) < 12 or packet[2] & 0x80 or struct.unpack('!H', packet[4:6])[0] != 1:
+            raise ValueError('invalid DNS')
+        end, labels = 12, []
+        while end < len(packet) and packet[end]:
+            length = packet[end]
+            if length > 63 or end + length + 1 > len(packet): raise ValueError('invalid label')
+            labels.append(packet[end+1:end+1+length].decode('ascii'))
+            end += length + 1
+        end += 1
+        kind, cls = struct.unpack('!HH', packet[end:end+4])
+        question = packet[12:end+4]
+        name = '.'.join(labels).lower()
+        answer = b''
+        if cls == 1 and kind == 1 and name and len(name) <= 253:
+            if name not in self.names:
+                if len(self.names) >= 65000: raise ValueError('DNS limit')
+                address = str(ipaddress.IPv4Address(int(ipaddress.IPv4Address('198.19.0.1')) + len(self.names)))
+                self.names[name] = address
+                self.addresses[address] = name
+            answer = b'\xc0\x0c' + struct.pack('!HHIH', 1, 1, 60, 4) + socket.inet_aton(self.names[name])
+        return packet[:2] + struct.pack('!HHHHH', 0x8180, 1, bool(answer), 0, 0) + question + answer
+
+    async def socks(self, reader, writer):
+        identity = str(uuid.uuid4())
+        opened = asyncio.get_running_loop().create_future()
+        if len(self.streams) >= 32:
+            writer.close(); return
+        self.streams[identity] = (writer, opened)
+        try:
+            version, count = await asyncio.wait_for(reader.readexactly(2), 10)
+            methods = await reader.readexactly(count)
+            if version != 5 or 0 not in methods: raise ValueError('SOCKS version')
+            writer.write(b'\x05\x00'); await writer.drain()
+            version, command, reserved, kind = await reader.readexactly(4)
+            if version != 5 or command != 1 or reserved: raise ValueError('TCP CONNECT only')
+            if kind == 1: host = socket.inet_ntop(socket.AF_INET, await reader.readexactly(4))
+            elif kind == 4: host = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
+            elif kind == 3: host = (await reader.readexactly((await reader.readexactly(1))[0])).decode('ascii')
+            else: raise ValueError('SOCKS address')
+            port = struct.unpack('!H', await reader.readexactly(2))[0]
+            await self.send({'op':'open','id':identity,'host':self.addresses.get(host,host),'port':port})
+            await asyncio.wait_for(opened, 25)
+            writer.write(b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00'); await writer.drain()
+            while True:
+                data = await reader.read(32768)
+                if not data:
+                    await self.send({'op':'end','id':identity}); break
+                await self.send({'op':'data','id':identity,'data':base64.b64encode(data).decode()})
+        except Exception:
+            with contextlib.suppress(Exception): writer.write(b'\x05\x01\x00\x01'+b'\x00'*6); await writer.drain()
+        finally:
+            self.streams.pop(identity,None); writer.close()
+            with contextlib.suppress(Exception): await self.send({'op':'close','id':identity})
+
+    async def receive(self, frame):
+        writer, opened = self.streams.get(frame.get('id'), (None,None))
+        if writer is None: return
+        if frame['op'] == 'opened':
+            if not opened.done(): opened.set_result(True)
+        elif frame['op'] == 'data':
+            data = base64.b64decode(frame['data'],validate=True)
+            if len(data) > 65536: raise ValueError('frame limit')
+            writer.write(data); await asyncio.wait_for(writer.drain(),10)
+        elif frame['op'] in ('error','end','close'):
+            if not opened.done(): opened.set_exception(ConnectionError('network unavailable'))
+            writer.close()
+
+    def close(self):
+        for writer, opened in list(self.streams.values()):
+            if not opened.done(): opened.cancel()
+            writer.close()
+        self.streams.clear()
+
+class DNSProtocol(asyncio.DatagramProtocol):
+    def __init__(self, tunnel): self.tunnel=tunnel
+    def connection_made(self, transport): self.transport=transport
+    def datagram_received(self, data, addr):
+        with contextlib.suppress(Exception): self.transport.sendto(self.tunnel.dns(data),addr)
+
+async def main():
+    tun_process = None
+    # This container has no workspace mounts or business processes.
+    if '--init' in sys.argv:
+        subprocess.run(['ip','tuntap','add','mode','tun','dev','tun0'],check=True)
+        subprocess.run(['ip','addr','add',DNS+'/15','dev','tun0'],check=True)
+        subprocess.run(['ip','link','set','tun0','up'],check=True)
+        subprocess.run(['ip','route','add','default','dev','tun0'],check=True)
+        argv=['tun2socks','--device','tun0','--proxy','socks5://127.0.0.1:'+str(SOCKS),'--loglevel','error']
+        if '--socket' not in sys.argv: os.execvp('tun2socks',argv)
+        tun_process=subprocess.Popen(argv,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+    loop=asyncio.get_running_loop()
+    queue=asyncio.Queue(maxsize=128)
+    async def send(frame): await asyncio.wait_for(queue.put(frame),10)
+    tunnel=Tunnel(send)
+    tcp=await asyncio.start_server(tunnel.socks,'127.0.0.1',SOCKS)
+    dns,_=await loop.create_datagram_endpoint(lambda:DNSProtocol(tunnel),local_addr=(DNS,53))
+    async def dns_tcp(reader,writer):
+        try:
+            while True:
+                length=struct.unpack('!H',await reader.readexactly(2))[0]
+                answer=tunnel.dns(await reader.readexactly(length))
+                writer.write(struct.pack('!H',len(answer))+answer);await writer.drain()
+        except Exception: pass
+        finally: writer.close()
+    dns_server=await asyncio.start_server(dns_tcp,DNS,53)
+    try:
+        if '--socket' not in sys.argv:
+            transport,protocol=await loop.connect_write_pipe(asyncio.streams.FlowControlMixin,sys.stdout.buffer)
+            wire=asyncio.StreamWriter(transport,protocol,None,loop)
+            incoming=asyncio.StreamReader(limit=200000);await loop.connect_read_pipe(lambda:asyncio.StreamReaderProtocol(incoming),sys.stdin.buffer)
+            async def output():
+                while True: wire.write((json.dumps(await queue.get())+'\n').encode());await wire.drain()
+            task=asyncio.create_task(output());await send({'op':'ready','port':SOCKS})
+            try:
+                while True:
+                    line=await incoming.readline()
+                    if not line: break
+                    await tunnel.receive(json.loads(line))
+            finally: task.cancel();wire.close()
+        else:
+            path=Path('/run/yaoyao-network/gateway.sock');path.parent.mkdir(parents=True,exist_ok=True)
+            path.unlink(missing_ok=True)
+            token=None;last_seen=0
+            async def http(reader,writer):
+                nonlocal token,last_seen
+                try:
+                    header=await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'),10)
+                    lines=header.decode('ascii').split('\r\n');op=lines[0].split(' ')[1].removeprefix('/network-')
+                    length=int(next(line.split(':',1)[1] for line in lines[1:] if line.lower().startswith('content-length:')))
+                    if not 0<=length<=4*1024*1024: raise ValueError('limit')
+                    body=json.loads(await reader.readexactly(length))
+                    if body.get('desktopId')!=os.environ['YAOYAO_COMPOSE_DESKTOP_ID']: raise ValueError('identity')
+                    if op=='health': result={'ready':Path('/sys/class/net/tun0').exists() and (tun_process is None or tun_process.poll() is None),'version':1}
+                    elif op=='open':
+                        if token and time.monotonic()-last_seen<30: raise ValueError('busy')
+                        tunnel.close()
+                        while not queue.empty(): queue.get_nowait()
+                        token=secrets.token_hex(32);result={'token':token,'ready':True}
+                    else:
+                        if not token or not secrets.compare_digest(str(body.get('token','')),token): raise ValueError('token')
+                        if op=='close': tunnel.close();token=None;result={'ok':True}
+                        elif op=='exchange':
+                            for frame in body.get('frames',[]): await tunnel.receive(frame)
+                            frames=[]
+                            if queue.empty():
+                                with contextlib.suppress(asyncio.TimeoutError): frames.append(await asyncio.wait_for(queue.get(),1))
+                            while not queue.empty() and len(frames)<32: frames.append(queue.get_nowait())
+                            result={'frames':frames}
+                        else: raise ValueError('operation')
+                    last_seen=time.monotonic()
+                    data=json.dumps(result).encode();status='200 OK'
+                except Exception: data=b'{"error":"network gateway unavailable"}';status='409 Conflict'
+                writer.write(('HTTP/1.1 '+status+'\r\nContent-Type: application/json\r\nx-yaoyao-desktop-id: '+os.environ['YAOYAO_COMPOSE_DESKTOP_ID']+'\r\nContent-Length: '+str(len(data))+'\r\nConnection: close\r\n\r\n').encode()+data)
+                with contextlib.suppress(Exception): await writer.drain()
+                writer.close()
+            async def watchdog():
+                nonlocal token
+                while True:
+                    await asyncio.sleep(5)
+                    if tun_process is not None and tun_process.poll() is not None: raise RuntimeError('TUN process stopped')
+                    if token and time.monotonic()-last_seen>30: tunnel.close();token=None
+            server=await asyncio.start_unix_server(http,str(path));os.chmod(path,0o666)
+            async with server: await asyncio.gather(server.serve_forever(),watchdog())
+    finally: tcp.close();dns.close();dns_server.close();tunnel.close()
+
+if __name__=='__main__': asyncio.run(main())

+ 54 - 0
docs/execution-environments.md

@@ -0,0 +1,54 @@
+# 系统运行环境、服务授权与虚拟机网络
+
+在 **Bot 设置 → 环境与授权** 中由管理员统一配置。Hermes 继续运行主模型和会话;这些设置只决定工具在哪里执行、从哪里取得服务凭据。
+
+| 运行环境 | 执行与授权 |
+| --- | --- |
+| 不指定 | 可使用获准的 Yaoyao 服务工具、HTTP MCP 和已连接应用;不能运行宿主终端、文件、浏览器或委派工具 |
+| 服务器本机 | Hermes 所属节点的原生环境和登录;不注入系统共享变量,不借用 Yaoyao 系统服务凭据 |
+| 虚拟环境 | 使用 Bot 已有的虚拟机归属;`computer_*` 在虚拟环境执行,脚本获得获准的系统变量和临时授权文件 |
+
+每轮绑定环境版本。更改环境从下一轮生效,并重新建立 Hermes 会话,避免沿用旧环境上下文。现有安装迁移为服务器本机;新安装默认不指定。新版工具桥声明 `execution_policy_version: 1`;旧桥或旧 Runner 不能接受受限环境的任务。
+
+环境限制在工具桥调用前执行;未识别的原生工具同样被拒绝,不能通过审批放行或委派绕过。Skill 发现和阅读继续可用,脚本必须走当前执行环境。受限环境不启动宿主 stdio MCP,也不在宿主后台维护 Skill。外接电脑、云电脑和独立托管浏览器的能力开关不覆盖系统选择。
+
+服务器模式的原生工具跟随 Hermes 节点。Yaoyao 的服务器桌面桥只在默认 Hermes 使用回环地址直连时挂载,并要求原有桌面授权;远程 Hermes 不会借用 Yaoyao 主机桌面。文件工具中的 `local` 别名也固定指向该服务器,不解析为消息来源设备。
+
+## 系统变量与授权
+
+配置使用现有服务器加密密钥密封保存到系统空间;管理接口只回显名称、用途、变量名和 Bot 授权列表。新增 Bot 没有默认使用权限。账号级已连接应用保持原有归属,不自动升级为全系统共享凭据。
+
+支持三类系统资源:
+
+- **环境变量**:JSON 字符串映射,按资源授予 Bot。保留运行时内部变量、进程加载选项、HOME/PATH 和网络代理变量,防止覆盖执行边界。同一 Bot 的变量名不能重复。
+- **API 服务**:管理员指定 HTTPS 根地址、Bearer 令牌,以及可选的脚本变量名。适用于服务本身发行的模型或生图 API 密钥;`yaoyao_service_request` 只接受该根路径下的请求,不跟随重定向。具体请求格式由 Skill 指定。返回体上限 8 MiB,超大的图像响应应通过虚拟机 SDK 写入文件再导出。
+- **Codex CLI 登录**:显式导入 `auth.json`,仅在虚拟机命令期间生成临时 `CODEX_HOME`;CLI 必须已安装。登录文件采用临时副本,CLI 的续期结果不回写全系统授权;到期后由管理员重新导入。它不会被解释为通用生图 API 密钥。Grok Bot 云登录同样不能直接当作 xAI API 密钥。
+
+普通接口、模型提示和工具参数不含凭据。Runner 在任务授权有效时通过专用接口读取任务环境;值不写入命令队列或全局进程环境。命令通过 stdin 收到变量和文件,临时文件结束后清理,常见输出中的凭据值被隐藏。授权变更或撤销使旧任务后续调用失效。
+
+共享虚拟机内的程序属于同一个操作系统用户。为避免把一个 Bot 的凭据暴露给未获授权的同机成员,注入脚本变量前会检查共享成员均获准使用相应资源;否则应选择独立环境或使用后端服务工具。
+
+## 虚拟机代理
+
+启用后,所有受管虚拟环境使用独立网络网关的命名空间。网关只有 `lo` 与 `tun0`,没有可以直连公网的网卡;桌面容器没有 `NET_ADMIN` 权限。`tun2socks` 将所有 TCP 转交给 Runner 的受控连接代理,支持 HTTP CONNECT 和 SOCKS5 上游认证。上游密码留在 Runner 内存,不放进桌面或网关镜像。
+
+DNS 在虚拟机内映射为临时地址,连接时还原域名;Runner 经同一上游代理查询 DNS over HTTPS,并检查实际目标地址。继续限制为公网 80/443,拒绝内网、宿主、元数据地址以及其他端口。UDP/QUIC、IPv6 直接出口不开放;无法代理的流量不会改为直连。关闭代理保留原有网络行为:动态环境使用原来的受控应用代理,旧 Compose 环境保持断网。
+
+修改代理先关闭旧通道并结束相关虚拟机工具任务,之后使用新配置。节点状态区分离线、旧版本、待更新、连接失败与已同步;已同步后仍会在实例启动时检查出口隔离。代理测试从执行节点发起,测试已保存的配置,不代表某个服务的账号授权有效。Yaoyao 后端代发的 HTTP 服务/MCP 请求使用后端网络配置。
+
+### 更新部署
+
+1. 更新 Web、Runner 和 Hermes 工具桥。工具桥改动需要重新加载 Hermes,不能只复制文件。
+2. 动态 Docker/Podman:重新准备一次虚拟机镜像,会同时构建 `localhost/yaoyao/network:1`。需容器运行时支持 `/dev/net/tun` 和隔离网关的 `NET_ADMIN`;业务容器不会获得它们。
+3. Compose:使用重新生成的 `compose.desktops.yaml` 或 `compose.desktops.cursor.yaml` 构建并更新部署。每个桌面新增一个网络网关和独立 IPC 卷,Web 只读挂载该卷;桌面不挂载网关 IPC。自定义清单须增加 `networkSocketPath`,缺失时明确拒绝启用。
+4. 在设置页保存代理并测试。旧实例的工作目录保留,网络模式变化由现有资源池在释放后重建容器。
+
+网关基于 [tun2socks 官方 Linux 配置](https://github.com/xjasonlyu/tun2socks/wiki/Examples),固定使用 v2.6.0 源码构建;与示例不同,本实现没有备用直连网卡。
+
+## 接口与验证
+
+管理员接口前缀 `/api/app/admin/execution`:`GET /`、`PUT /environment`、`PUT /proxy`、`POST /proxy/test`,以及 `/resources` 的新增、按 ID 更新和删除。更新使用配置版本防止覆盖并发编辑;密钥字段省略表示保留,代理密码传空字符串表示清除。
+
+Runner 新增 `execution-context-v1` 和 `vm-egress-v1` 能力、仅机器认证可访问的任务环境/网络配置接口,以及 `network.configure`、`network.test` 指令。任务上下文回调仍绑定原连接、Bot、账号和本轮授权。
+
+自动测试覆盖管理员边界、环境来源、迁移、加密存储、Bot 授权撤销与共享成员检查、服务请求范围、命令临时文件清理、HTTP/SOCKS5 握手与经代理的 TLS DNS 请求、代理并发切换与失效连接回收、强制工具限制、表单行为;`tests/live/managedNetwork.live.test.ts` 使用真实 Docker 验证无代理变量的 TCP 程序、DNS、代理故障无直连、业务 root 无网络管理权以及资源清理。

+ 16 - 5
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -384,7 +384,7 @@ def capabilities(profile: str = "default") -> dict:
             if not {"yaoyao_tools", "yaoyao_call"} <= names:
                 raise BridgeError(f"Profile {profile} 尚未安装或启用夭夭工具桥,请检查该 Profile 的插件与工具集设置", 503, "profile_tools_unavailable")
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
-                    "computer_runtime_version": 2, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
+                    "computer_runtime_version": 2, "execution_policy_version": 1, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
                     "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1}
         finally:
@@ -508,7 +508,7 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
         raise BridgeError("记忆隔离设置格式无效", 400, "invalid_memory_policy")
     policy = body.get("computer_policy")
     if policy is not None and (not isinstance(policy, dict) or set(policy) != {"mode", "hostAccess"}
-                               or policy.get("mode") not in {"isolated", "profile"}
+                               or policy.get("mode") not in {"isolated", "profile", "none", "virtual"}
                                or type(policy.get("hostAccess")) is not bool):
         raise BridgeError("电脑会话权限格式无效", 400, "invalid_computer_policy")
     server = _server()
@@ -577,7 +577,7 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
             current.expires_at = max(current.expires_at, expiry)
             current.stored_session_id = stored
             return {"ok": True, "generation": generation, "expires_at": current.expires_at,
-                    "native_tools": bool(current.toolset), "computer_runtime_version": 2, "workspace_memory": current.workspace_memory}
+                    "native_tools": bool(current.toolset), "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": current.workspace_memory}
         previous = _generations.get(sid)
         seen = previous[1] if previous and previous[0] is session else set()
         if generation in seen:
@@ -612,7 +612,7 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
                 _retire(binding)
                 raise
         return {"ok": True, "generation": generation, "expires_at": expiry, "native_tools": bool(binding.toolset),
-                "computer_runtime_version": 2, "workspace_memory": binding.workspace_memory}
+                "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": binding.workspace_memory}
 
 
 def unbind(owner: tuple[str, str], body: dict) -> dict:
@@ -863,7 +863,7 @@ def _isolate_profile_context(agent, binding):
     Apply on every bind/rebuild, including inherited children, before the model
     runs. Skills have their own loader; history and Profile config stay intact.
     """
-    if (binding.computer_policy or {}).get("mode") != "isolated":
+    if (binding.computer_policy or {}).get("mode") not in {"isolated", "none", "virtual"}:
         return
     invalidate = getattr(agent, "_invalidate_system_prompt", None)
     if not all(hasattr(agent, name) for name in ("skip_context_files", "load_soul_identity")) or not callable(invalidate):
@@ -945,6 +945,8 @@ class _SkillReviewParent:
 
 def _enable_skill_review(agent, binding):
     """Use Hermes' native trigger/queue/review, while allowing skills only."""
+    if (binding.computer_policy or {}).get("mode") in {"none", "virtual"}:
+        return  # Background review must not write skills into the unselected host.
     native = getattr(agent, "_spawn_background_review_now", None)
     required = ("skip_context_files", "load_soul_identity", "_background_review_agent", "_background_review_run")
     if (not all(hasattr(agent, name) for name in required) or not callable(native)
@@ -1023,6 +1025,13 @@ def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
         return {"action": "block", "message": "此 Bot 使用独立记忆,请使用本轮授权的 workspace 记忆工具。"}
     if not policy:
         return None if binding.workspace_memory else {"action": "block", "message": "当前轮次缺少电脑权限声明。"}
+    if policy["mode"] in {"none", "virtual"}:
+        # Unknown native tools are denied too. A newly installed plugin must not
+        # regain access to Profile credentials or launch unrestricted children.
+        dynamic = tool_name in binding.native_names
+        if dynamic or tool_name in {"skills_list", "skill_view"}:
+            return None
+        return {"action": "block", "message": "系统未选择服务器本机,请使用本轮授权的虚拟环境或系统服务工具。"}
     if policy["mode"] == "profile" or policy["hostAccess"]:
         return None
     # Skill discovery, vision, and authenticated services retain the Profile's
@@ -1046,6 +1055,8 @@ def computer_file(owner, body):
     binding = _resolve(_string(body, "session_id"))
     if binding.owner != owner or binding.generation != _string(body, "generation") or not binding.computer_policy:
         raise BridgeError("文件请求不属于当前电脑会话")
+    if binding.computer_policy.get("mode") in {"none", "virtual"}:
+        raise BridgeError("当前环境禁止访问 Hermes 宿主文件")
     action, raw = body.get("action"), _string(body, "path", 4096)
     transfer = body.get("transfer") if action == "transfer" else None
     if action == "transfer":

+ 9 - 0
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -54,6 +54,15 @@ class ComputerPolicyTests(unittest.TestCase):
         for p in reversed(self.patches): p.stop()
         self.temp.cleanup()
 
+    def test_strict_environment_blocks_unknown_native_tools_and_cannot_approve_host_access(self):
+        for mode in ('none', 'virtual'):
+            self.binding.computer_policy = {'mode': mode, 'hostAccess': True}
+            self.binding.native_names = {'yaoyao_fixture_123'}
+            for name in ('terminal', 'read_file', 'delegate_task', 'new_native_plugin', 'skill_manage'):
+                self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=name, args={})['action'], 'block')
+            for name in ('skills_list', 'skill_view', 'yaoyao_fixture_123'):
+                self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={}))
+
     def test_lazy_binding_starts_native_initialization_without_submitting_a_prompt(self):
         self.session.update(agent=None, running=False, agent_error=None)
         started = []

+ 27 - 7
scripts/generate-desktop-compose.mjs

@@ -14,9 +14,9 @@ export function generateDesktopCompose(base,entries,source='deploy/compose-deskt
   }
   if(new Set(entries.map(d=>d.id)).size!==entries.length||new Set(entries.map(d=>d.service)).size!==entries.length)throw new Error('Compose 桌面定义重复')
   const q=JSON.stringify
-  const targets=entries.map(d=>({id:d.id,name:d.name,socketPath:`/run/yaoyao-desktops/${d.service}/desktop.sock`,imageKey:d.imageKey??'standard'}))
+  const targets=entries.map(d=>({id:d.id,name:d.name,socketPath:`/run/yaoyao-desktops/${d.service}/desktop.sock`,networkSocketPath:`/run/yaoyao-networks/${d.service}/gateway.sock`,imageKey:d.imageKey??'standard'}))
   base=base.replace('      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"','      HERMES_YAOYAO_LOCAL_VM_HOST: "runner"\n      HERMES_YAOYAO_COMPOSE_DESKTOPS: '+q(JSON.stringify(targets)))
-    .replace('      - yaoyao-data:/home/node/.yaoyao','      - yaoyao-data:/home/node/.yaoyao\n'+entries.map(d=>`      - ${d.service}-ipc:/run/yaoyao-desktops/${d.service}:ro`).join('\n'))
+    .replace('      - yaoyao-data:/home/node/.yaoyao','      - yaoyao-data:/home/node/.yaoyao\n'+entries.map(d=>`      - ${d.service}-ipc:/run/yaoyao-desktops/${d.service}:ro\n      - ${d.service}-network-ipc:/run/yaoyao-networks/${d.service}:ro`).join('\n'))
   const volumesAt=base.indexOf('\nvolumes:')
   if(volumesAt<0||!base.includes('HERMES_YAOYAO_COMPOSE_DESKTOPS'))throw new Error('compose.yaml 模板结构已变化')
   let yaml=`# Generated from compose.yaml and ${source}.\n`+base.slice(0,volumesAt)+'\n'
@@ -27,12 +27,12 @@ export function generateDesktopCompose(base,entries,source='deploy/compose-deskt
 ${recipe.platform?`    platform: ${recipe.platform}\n`:''}    build:
       context: ./deploy/computer
       dockerfile: ${recipe.dockerfile}
-    hostname: ${d.service}
-    extra_hosts:
-      - "${d.service}:127.0.0.1"
     init: true
     restart: unless-stopped
-    network_mode: none
+    network_mode: "service:${d.service}-network"
+    depends_on:
+      ${d.service}-network:
+        condition: service_started
     shm_size: 512m
     mem_limit: 4g
     cpus: 2
@@ -49,10 +49,30 @@ ${recipe.platform?`    platform: ${recipe.platform}\n`:''}    build:
     volumes:
       - ${d.service}-workspace:/home/cua/workspace
       - ${d.service}-ipc:/run/yaoyao-private/bridge
+  ${d.service}-network:
+    image: localhost/yaoyao/network:1
+    build:
+      context: ./deploy/computer
+      dockerfile: Dockerfile.network
+    command: ["--init", "--socket"]
+    init: true
+    restart: unless-stopped
+    network_mode: none
+    dns: ["198.18.0.1"]
+    read_only: true
+    cap_drop: ["ALL"]
+    cap_add: ["NET_ADMIN"]
+    devices: ["/dev/net/tun:/dev/net/tun"]
+    mem_limit: 128m
+    pids_limit: 64
+    environment:
+      YAOYAO_COMPOSE_DESKTOP_ID: ${q(d.id)}
+    volumes:
+      - ${d.service}-network-ipc:/run/yaoyao-network
 `
   }
   yaml+=base.slice(volumesAt)+'\n'
-  for(const d of entries)yaml+=`  ${d.service}-workspace:\n  ${d.service}-ipc:\n`
+  for(const d of entries)yaml+=`  ${d.service}-workspace:\n  ${d.service}-ipc:\n  ${d.service}-network-ipc:\n`
   return yaml
 }
 if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){

+ 80 - 0
src/client/components/app/ExecutionSettingsPanel.vue

@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import {onMounted,ref,nextTick} from 'vue'
+import {apiRequest} from '@/api/client'
+import type {ExecutionSettingsView,SystemResourceSummary} from '@shared/executionEnvironment'
+const errorBox=ref<HTMLElement>()
+const state=ref<ExecutionSettingsView>(),busy=ref(false),error=ref(''),status=ref('')
+const mode=ref<'none'|'server'|'virtual'>('none'),password=ref(''),clearPassword=ref(false)
+const editing=ref<SystemResourceSummary>(),showResource=ref(false)
+const name=ref(''),kind=ref<'variables'|'api'|'codex'>('variables'),values=ref(''),baseUrl=ref(''),token=ref(''),tokenEnv=ref(''),authJson=ref(''),agentIds=ref<string[]>([])
+const root='/api/app/admin/execution'
+async function load(){state.value=await apiRequest<ExecutionSettingsView>(root);mode.value=state.value.execution.mode}
+async function action(work:()=>Promise<void>,message:string){if(busy.value)return;busy.value=true;error.value='';status.value='';try{await work();status.value=message}catch(e){error.value=e instanceof Error?e.message:'保存失败';await nextTick();errorBox.value?.focus()}finally{busy.value=false}}
+function edit(resource?:SystemResourceSummary){editing.value=resource;showResource.value=true;name.value=resource?.name??'';kind.value=resource?.kind??'variables';agentIds.value=[...(resource?.agentIds??[])];baseUrl.value=resource?.baseUrl??'';values.value='';token.value='';authJson.value='';tokenEnv.value=resource?.tokenEnv??''}
+async function saveResource(){await action(async()=>{
+  let vars:Record<string,string>|undefined
+  if(values.value.trim()){vars=JSON.parse(values.value);if(!vars||Array.isArray(vars)||typeof vars!=='object')throw new Error('环境变量需要 JSON 对象,例如 {"MY_API_KEY":"值"}')}
+  const body={name:name.value,kind:kind.value,agentIds:agentIds.value,revision:editing.value?.revision??0,...(vars?{variables:vars}:{}),...(kind.value==='api'?{baseUrl:baseUrl.value,...(token.value?{token:token.value}:{}),tokenEnv:tokenEnv.value}:{}),...(kind.value==='codex'&&authJson.value?{authJson:authJson.value}:{})}
+  await apiRequest(root+'/resources'+(editing.value?'/'+editing.value.id:''),{method:editing.value?'PUT':'POST',body});showResource.value=false;values.value='';token.value='';authJson.value='';await load()
+},'系统授权已保存')}
+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('、')+'连接失败,请核对地址和认证')},'在线节点均可连接已保存的代理')}
+onMounted(()=>action(load,''))
+</script>
+
+<template>
+  <div class="execution-settings" :aria-busy="busy">
+    <p v-if="error" ref="errorBox" role="alert" tabindex="-1">{{error}}</p>
+    <p v-if="status" role="status">{{status}}</p>
+    <p v-if="!state">正在加载运行环境…</p>
+    <template v-else>
+      <form @submit.prevent="saveEnvironment">
+        <h4>统一运行环境</h4>
+        <p>所有 Bot 从下一轮任务开始使用所选环境。Hermes 继续负责推理与调度。</p>
+        <label>执行位置<select v-model="mode" :disabled="busy"><option value="none">不指定环境</option><option value="server">服务器本机 · Hermes 所在节点</option><option value="virtual">虚拟环境 · Bot 对应的隔离环境</option></select></label>
+        <p v-if="mode==='none'">可使用下方授权的系统服务;终端、文件和桌面操作不可用。</p>
+        <p v-else-if="mode==='server'">使用 Hermes 所在节点的环境变量和本机登录。缺少授权时不会借用 Yaoyao 系统授权。</p>
+        <p v-else>脚本在虚拟环境中运行,使用下方授予该 Bot 的系统变量和服务授权。</p>
+        <button :disabled="busy||mode===state.execution.mode">保存运行环境</button>
+        <p v-for="turn in state.activeTurns.filter(t=>t.revision!==state!.execution.revision)" :key="turn.agentId">{{turn.agentId}} 的当前任务仍使用 {{turn.mode}},结束后更新。</p>
+      </form>
+      <section>
+        <h4>Yaoyao 环境与服务授权</h4>
+        <p>系统统一保存,按 Bot 授权使用。适用于未指定环境和虚拟环境;授权值不会回显。共享虚拟机的成员必须统一获得变量权限,才能注入脚本。</p>
+        <ul><li v-for="resource in state.resources" :key="resource.id"><div><strong>{{resource.name}}</strong><p>{{resource.kind==='codex'?'Codex CLI 登录':resource.kind==='api'?'模型 / 生图等 API 服务':'环境变量'}} · {{resource.agentIds.length}} 个 Bot · {{resource.envKeys.join('、')||'通过服务工具调用'}}</p></div><button type="button" :disabled="busy" @click="edit(resource)">编辑 {{resource.name}}</button><button type="button" :disabled="busy" @click="action(async()=>{await apiRequest(root+'/resources/'+resource.id,{method:'DELETE'});await load()},'授权已撤销')">撤销</button></li></ul>
+        <button type="button" :disabled="busy" @click="edit()">添加变量或服务授权</button>
+        <form v-if="showResource" @submit.prevent="saveResource">
+          <label>名称<input v-model="name" required maxlength="80" :disabled="busy"></label>
+          <label>类型<select v-model="kind" :disabled="busy||!!editing"><option value="variables">环境变量</option><option value="api">API 服务(含 Grok 等模型与生图服务)</option><option value="codex">Codex CLI 登录</option></select></label>
+          <template v-if="kind==='api'"><label>服务根地址<input v-model="baseUrl" type="url" placeholder="https://api.example.com/v1/" required :disabled="busy"></label><label>API 授权令牌<input v-model="token" type="password" autocomplete="new-password" :required="!editing" :placeholder="editing?'留空保留原授权':''" :disabled="busy"></label><label>提供给脚本的变量名(可选)<input v-model="tokenEnv" placeholder="例如 XAI_API_KEY;留空仅供服务工具使用" :disabled="busy"></label><p>使用该服务发行的 API 凭据。Grok Bot 云登录与通用生图 API 授权用途不同。</p></template>
+          <p v-if="kind==='codex'">供虚拟机中的 Codex CLI 使用临时登录副本,续期不回写系统;到期后请重新导入。服务器模式使用节点本机登录。</p>
+          <label v-if="kind==='codex'">Codex auth.json<textarea v-model="authJson" autocomplete="off" spellcheck="false" :required="!editing" :placeholder="editing?'留空保留原登录':'粘贴要共享的 Codex auth.json 内容'" :disabled="busy" /></label>
+          <label>环境变量 JSON{{editing?'(留空保留原值)':''}}<textarea v-model="values" autocomplete="off" spellcheck="false" placeholder='{"MY_API_KEY":"值"}' :disabled="busy" /></label>
+          <fieldset :disabled="busy"><legend>允许使用的 Bot</legend><p v-if="!state.agents.length">创建 Bot 后可回来授予权限。</p><label v-for="agent in state.agents" :key="agent.id" class="check"><input v-model="agentIds" type="checkbox" :value="agent.id">{{agent.name}} <small>账号 {{agent.owner}}</small></label></fieldset>
+          <div class="actions"><button :disabled="busy">保存授权</button><button type="button" :disabled="busy" @click="showResource=false;values='';token='';authJson=''">取消</button></div>
+        </form>
+      </section>
+      <form @submit.prevent="saveProxy">
+        <h4>虚拟机全局代理</h4>
+        <p>所有受管虚拟环境的程序共用出口。保留公网 HTTP/HTTPS 限制,其他协议阻断;代理故障时不直连。保存会断开旧网络任务。</p>
+        <label class="check"><input v-model="state.proxy.enabled" type="checkbox" :disabled="busy">启用统一代理</label>
+        <template v-if="state.proxy.enabled">
+          <label>协议<select v-model="state.proxy.protocol" :disabled="busy"><option value="http">HTTP CONNECT</option><option value="socks5">SOCKS5</option></select></label>
+          <label>代理地址<input v-model="state.proxy.host" required placeholder="执行节点可以访问的主机名或 IP" :disabled="busy"></label>
+          <label>端口<input v-model.number="state.proxy.port" type="number" min="1" max="65535" required :disabled="busy"></label>
+          <label>用户名<input v-model="state.proxy.username" autocomplete="off" :disabled="busy"></label>
+          <label v-if="state.proxy.hasPassword" class="check"><input v-model="clearPassword" type="checkbox" :disabled="busy">清除已保存的代理密码</label>
+          <label>密码<input v-model="password" type="password" autocomplete="new-password" :placeholder="state.proxy.hasPassword?'留空保留已保存密码':''" :disabled="busy"></label>
+        </template>
+        <div class="actions"><button :disabled="busy">保存代理</button><button type="button" :disabled="busy||!state.proxy.enabled" @click="testProxy">测试已保存的代理</button><button type="button" :disabled="busy" @click="action(load,'状态已刷新')">刷新状态</button></div>
+        <p v-for="node in state.nodes" :key="node.id">{{node.name}}:{{node.status}}</p>
+        <p>Yaoyao 后端代发的服务请求使用后端网络配置。Compose 部署需要更新网络网关;旧环境不会假报代理已生效。</p>
+      </form>
+    </template>
+  </div>
+</template>
+
+<style scoped>
+.execution-settings{display:grid;gap:24px;margin-bottom:28px;font-size:14px}.execution-settings form,.execution-settings section{display:grid;gap:14px;padding:20px;background:var(--settings-panel);border:1px solid var(--line);border-radius:12px;min-width:0}.execution-settings h4{margin:0;font-size:16px}.execution-settings p{margin:0;color:var(--text-secondary);line-height:1.65;overflow-wrap:anywhere}.execution-settings label{display:grid;gap:8px}.execution-settings input,.execution-settings select,.execution-settings textarea,.execution-settings button{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);font:inherit;max-width:100%;box-sizing:border-box}.execution-settings textarea{min-height:96px;resize:vertical;width:100%}.execution-settings button{cursor:pointer;justify-self:start}.execution-settings :disabled{opacity:.6;cursor:default}.execution-settings :focus-visible{outline:2px solid var(--accent);outline-offset:2px}.execution-settings [role=alert]{color:var(--danger)}.execution-settings .check{display:flex;align-items:center;gap:10px;min-height:44px;flex-wrap:wrap}.check input{min-height:20px;width:20px;height:20px}.execution-settings fieldset{border:1px solid var(--line);border-radius:8px;min-width:0}.execution-settings ul{list-style:none;padding:0;margin:0;display:grid;gap:12px}.execution-settings li{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.execution-settings li>div{flex:1;min-width:140px}.execution-settings .actions{display:flex;gap:10px;flex-wrap:wrap}@media(max-width:600px){.execution-settings form,.execution-settings section{padding:14px}.execution-settings form form{padding:10px}}
+</style>

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

@@ -107,12 +107,13 @@ onMounted(() => { void load(); void loadNames() })
   <section class="host-tools" aria-label="电脑与服务端工具">
     <p>这些开关对所有机器人生效,不再按单个机器人勾选。所有 Bot 共用电脑权限和审批策略。设备是否在线、是否获得系统授权会单独显示。</p>
     <form @submit.prevent="save" @change="changed">
+      <p>这些开关控制设备能力是否开放;实际操作仍受上方系统运行环境选择约束。</p>
       <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>电脑</strong><small>连接服务器的 Mac 或 Windows 电脑。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
       <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。文件走文件工具,命令走 shell;只有看窗口或点按时才截图操作。</small></span></label>
       <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
       <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
       <label class="toggle"><input v-model="managedBrowser" type="checkbox" :disabled="loading || saving" /><span><strong>托管浏览器</strong><small>通过已启用浏览器能力的执行节点处理网页,无需启动虚拟机。默认关闭,打开后可在电脑面板查看和接管。</small></span></label>
-      <label>所有 Bot 的审批策略<select v-model="approvalPolicy" :disabled="loading || saving"><option value="ask">询问我</option><option value="allow">自动允许</option><option value="deny">自动拒绝</option></select><small>统一用于新的工具审批请求;已等待的请求仍由你答复。Hermes 原生终端和文件始终操作服务器。</small></label>
+      <label>所有 Bot 的审批策略<select v-model="approvalPolicy" :disabled="loading || saving"><option value="ask">询问我</option><option value="allow">自动允许</option><option value="deny">自动拒绝</option></select><small>统一用于新的工具审批请求;已等待的请求仍由你答复。Hermes 原生终端和文件仅在系统选择服务器本机时可用。</small></label>
       <label for="file-transfer-limit">单文件传输上限(MiB)<input id="file-transfer-limit" v-model.number="fileTransferMaxMiB" type="number" min="1" max="100" step="1" :disabled="loading || saving" :aria-invalid="!!transferError" aria-describedby="file-transfer-help file-transfer-error" @input="changed" /><small id="file-transfer-help">1–100 MiB,默认 25 MiB。电脑、服务器和当前 Bot 虚拟机之间的复制共用此上限,从下一次传输生效。</small><small v-if="transferError" id="file-transfer-error" class="error" role="alert">{{ transferError }}</small></label>
       <p v-if="error" class="error" role="alert">{{ error }}</p>
       <p v-if="saved" role="status">已保存。所有 Bot 的新操作将使用统一设置。</p>

+ 3 - 2
src/client/components/workspace/BotSettingsDialog.vue

@@ -6,6 +6,7 @@ import StandaloneDialog from '@/components/common/StandaloneDialog.vue'
 import WorkspaceKnowledgePanel from './WorkspaceKnowledgePanel.vue'
 import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
 import HostToolsPanel from '@/components/app/HostToolsPanel.vue'
+import ExecutionSettingsPanel from '@/components/app/ExecutionSettingsPanel.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import type {WorkspaceAgent, WorkspaceConversation} from '@shared/workspace'
 
@@ -22,7 +23,7 @@ const pages = computed(() => [
   {id:'projects' as const, label:'项目', icon:'files' as const},
   {id:'user' as const, label:'用户记忆', icon:'users' as const},
   ...(props.isAdmin ? [
-    {id:'computers' as const, label:'电脑', icon:'monitor' as const},
+    {id:'computers' as const, label:'环境与授权', icon:'monitor' as const},
     {id:'files' as const, label:'文件访问', icon:'files' as const},
     {id:'memory' as const, label:'Bot 记忆', icon:'users' as const},
     {id:'vm' as const, label:'本地虚拟机', icon:'monitor' as const},
@@ -55,7 +56,7 @@ onBeforeUnmount(() => { closed = true })
         <button v-for="item in pages" :key="item.id" type="button" :aria-pressed="page === item.id" @click="page = item.id"><AppIcon :name="item.icon" :size="18" /><span>{{item.label}}</span></button>
       </nav>
       <section v-if="page === 'vm' && isAdmin" class="bot-settings__content bot-settings__vm"><header><h3>本地虚拟机</h3><p>为 Bot 配置隔离的工作环境。</p></header><LocalVmSettingsPanel /></section>
-      <section v-else-if="page === 'computers' && isAdmin" class="bot-settings__content"><header><h3>电脑</h3><p>电脑、服务器、虚拟环境和云虚拟机对所有机器人一起开关。</p></header><HostToolsPanel /></section>
+      <section v-else-if="page === 'computers' && isAdmin" class="bot-settings__content"><header><h3>运行环境与授权</h3><p>管理系统执行位置、共享服务授权和虚拟机网络。</p></header><ExecutionSettingsPanel /><HostToolsPanel /></section>
       <section v-else-if="page === 'files' && isAdmin" class="bot-settings__content"><header><h3>文件访问</h3><p>控制聊天里服务器文件的预览和下载。</p></header><FileAccessPanel /></section>
       <section v-else-if="page === 'memory' && isAdmin" class="bot-settings__content"><header><h3>Bot 记忆</h3><p>管理 Bot 使用的记忆服务。</p></header><SystemManagementPanel section="memory" :active="page === 'memory'" /></section>
       <section v-else class="bot-settings__content bot-settings__knowledge" :aria-label="page === 'projects' ? '项目设置' : '用户记忆设置'" :aria-busy="loading">

+ 13 - 3
src/runner/agent.ts

@@ -1,3 +1,4 @@
+import {checkProxy} from './network/upstreamProxy.js'
 import { randomUUID, createHash } from 'node:crypto'
 import { DatabaseSync } from 'node:sqlite'
 import { realpath } from 'node:fs/promises'
@@ -66,7 +67,7 @@ export class RunnerAgent {
     const toolId=body&&typeof body==='object'&&'toolId' in body?body.toolId:undefined
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&toolId.startsWith('managed_browser_')?600000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'execution-context-v1,vm-egress-v1,workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'execution-context-v1,workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){
@@ -105,6 +106,11 @@ export class RunnerAgent {
     ]).then(()=>{}).finally(()=>{if(this.disconnecting===pending)this.disconnecting=undefined})
     this.disconnecting=pending;return pending
   }
+  private async syncNetworkSettings(){
+    const {proxy}=await this.api('network-settings',{})
+    await this.computers!.updateProxy(proxy)
+    await this.api('network-settings',{appliedRevision:this.computers!.proxySettings?.revision??0})
+  }
   private async execute(command:RunnerCommand):Promise<any> {
     const p=command.payload
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
@@ -148,12 +154,13 @@ export class RunnerAgent {
       if(input.op==='image')return this.localVm!.select(input.target,z.enum(LOCAL_VM_IMAGE_KEYS).parse(input.imageKey),authorize)
       if(input.op==='lifecycle'){
         const action=z.enum(['create','start','stop','recreate','remove']).parse(input.action)
+        if(!['stop','remove'].includes(action))await this.syncNetworkSettings()
         await this.computers.desktop(input.target,input.profile,action,authorize)
         return {ok:true}
       }
       if(input.op==='status')return this.computers.controls.status(input.target)
       if(input.op==='frame')return this.computers.controls.frame(input.target,authorize)
-      if(input.op==='take')return this.computers.controls.take(input.target,input.profile,input.controlId!,check)
+      if(input.op==='take'){await this.syncNetworkSettings();return this.computers.controls.take(input.target,input.profile,input.controlId!,check)}
       if(input.op==='input')return this.computers.controls.input(input.target,input.controlId!,input.requestId!,input.generation!,input.frameId!,input.action)
       return this.computers.controls.giveBack(input.target,input.controlId!,input.notes??'')
     }
@@ -201,6 +208,7 @@ 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}}
     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('执行通道无效或已达上限')
@@ -208,10 +216,12 @@ export class RunnerAgent {
       if(p.computer){
         const meta=p.computer as ComputerTarget
         if(!this.computers||![meta.environmentId,meta.agentId,String(p.workId)].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(409,'电脑任务配置或授权无效','computer_unavailable')
+        await this.syncNetworkSettings()
         const trustedMeta={...meta,hermesRuntime:true}
         gateway=new ComputerGateway(this.computers,trustedMeta,String(p.workId),async()=>{const state=await this.api('check',{connectionId});if(!this.active||!this.connected||state.allowed!==true)throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked');trustedMeta.fileTransferMaxBytes=Number.isInteger(state.fileTransferMaxBytes)&&state.fileTransferMaxBytes>=1024*1024&&state.fileTransferMaxBytes<=100*1024*1024?state.fileTransferMaxBytes:25*1024*1024},body=>this.api('artifact',{connectionId,...body}),this.target)
       }else gateway=new WorkspaceGateway(this.target)
       const connection:Connection={cleanupOnly:p.cleanupOnly===true,gateway,sessions:new Map(),running:new Set(),events:Promise.resolve()}
+      if(gateway instanceof ComputerGateway)gateway.taskEnvironment=async()=> (await this.api('execution-context',{connectionId})).environment
       this.connections.set(connectionId,connection)
       gateway.onEvent=(frame:GatewayFrame)=>{
         if(frame.session_id&&!connection.sessions.has(frame.session_id))return
@@ -254,7 +264,7 @@ export class RunnerAgent {
         this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
       }
       const lease=await createWorkspaceToolLease({target:this.target,profile:String(p.profile),workId:String(p.workId),session:()=>session,signal:controller.signal,
-        workspaceMemory:p.workspaceMemory===true,
+        workspaceMemory:p.workspaceMemory===true,computerPolicy:p.computerPolicy as import('../server/workspaceToolLease.js').LeaseInput['computerPolicy'],
         assertActive:()=>{if(!this.connected||controller.signal.aborted)throw new Error('工具授权已失效')},
         catalog:()=>p.catalog as any[],call:async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId:callId??randomUUID()})).result,
         onFailure:()=>{controller.abort()},

+ 1 - 0
src/runner/computers/compose.ts

@@ -8,6 +8,7 @@ export class ComposeComputerProvider extends ContainerComputerProvider {
   constructor(runnerId:string,home:string,readonly relay:DesktopRelay){super('docker',runnerId,home)}
   async inventory(){return this.relay('','list',{})}
   async verifyRuntime(){await this.inventory()}
+  async verifyNetwork(){for(const desktop of await this.inventory()){const health=await this.relay(desktop.id,'network-health',{});if(health.ready!==true)throw new ComputerError('computer_network_upgrade_required','请更新 Compose 网络网关')}}
   async inspect(spec:ComputerSpecification):Promise<ComputerState|undefined>{
     this.validateSpecification(spec)
     const state=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey})

+ 38 - 8
src/runner/computers/container.ts

@@ -25,7 +25,7 @@ const command:ContainerCommand=(runtime,args,options={})=>new Promise((resolveRe
   child.stdin.end(options.input)
 })
 const schema=z.object({
-  network:z.enum(['none','public-proxy']).default('none'),
+  network:z.enum(['none','public-proxy','managed-proxy']).default('none'),
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
   cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
@@ -127,6 +127,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     if(!parsed.success)throw new ComputerError('computer_spec_invalid','电脑环境配置无效')
     return parsed.data
   }
+  private networkIds=new Map<string,string>()
   private name(spec:Spec){return `yaoyao-computer-${digest([this.runnerId,spec.id]).slice(0,24)}`}
   private labels(spec:Spec){return {[managedLabel]:spec.id,[runnerLabel]:this.runnerId,[ownerLabel]:digest(spec.ownerKey),[specLabel]:digest(spec)}}
   private async workspace(spec:Spec) {
@@ -159,7 +160,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     const destinations=spec.cwd===COMPUTER_WORKSPACE?[COMPUTER_WORKSPACE]:[COMPUTER_WORKSPACE,spec.cwd]
     if(detail.Config?.Labels?.[specLabel]!==digest(spec)||detail.Image!==spec.imageId||host.Privileged!==false||host.Memory!==spec.memoryMiB*mib||host.MemorySwap!==spec.memoryMiB*mib
       ||nanoCPUs!==spec.cpus*1e9||host.PidsLimit!==spec.pids||host.IpcMode!=='private'||host.CgroupnsMode!=='private'
-      ||host.NetworkMode!=='none'||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
+      ||(spec.network==='managed-proxy'?![`container:${this.name(spec)}-network`,`container:${this.networkIds.get(spec.id)}`].includes(host.NetworkMode):host.NetworkMode!=='none')||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
       ||(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value)||/unconfined/.test(value))
       ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
       ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
@@ -190,13 +191,13 @@ export class ContainerComputerProvider implements ComputerProvider {
   async inspect(value:ComputerSpecification):Promise<ComputerState|undefined> {
     await this.verifyRuntime()
     const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
+    if(detail&&spec.network==='managed-proxy'){const gateway=await this.inspectRaw(this.name(spec)+'-network');this.assertNetwork(spec,gateway);this.networkIds.set(spec.id,gateway.Id)}
     return detail?this.state(spec,detail,workspace):undefined
   }
   private args(spec:Spec,workspace:string) {
-    return ['create','--name',this.name(spec),'--hostname',this.name(spec),// The XFCE/VNC startup resolves its own hostname even without a network.
-      '--add-host',`${this.name(spec)}:127.0.0.1`,
+    return ['create','--name',this.name(spec),...(spec.network==='managed-proxy'?[]:['--hostname',this.name(spec),'--add-host',`${this.name(spec)}:127.0.0.1`]),
       ...Object.entries(this.labels(spec)).flatMap(([key,value])=>['--label',`${key}=${value}`]),
-      '--restart','no','--network','none','--ipc','private','--cgroupns','private',
+      '--restart','no','--network',spec.network==='managed-proxy'?`container:${this.name(spec)}-network`:'none','--ipc','private','--cgroupns','private',
       '--memory',`${spec.memoryMiB}m`,'--memory-swap',`${spec.memoryMiB}m`,'--cpus',String(spec.cpus),'--pids-limit',String(spec.pids),
       '--shm-size','512m','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
       ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
@@ -209,6 +210,7 @@ export class ContainerComputerProvider implements ComputerProvider {
     return this.lane(spec.id).write(async()=>{
       authorize();await this.verifyRuntime();authorize()
       const workspace=await this.workspace(spec)
+      if(spec.network==='managed-proxy')await this.ensureNetwork(spec,authorize)
       let detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
       if(detail?.HostConfig?.SecurityOpt?.some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))) {
         await this.stopOwned(spec,detail.Id)
@@ -251,9 +253,9 @@ export class ContainerComputerProvider implements ComputerProvider {
     const spec=this.validateSpecification(value)
     return this.lane(spec.id).write(async()=>{
       await this.verifyRuntime()
-      const detail=await this.inspectRaw(this.name(spec));if(!detail)return
-      this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)
-      await this.run(this.runtime,['rm','--',detail.Id])
+      const detail=await this.inspectRaw(this.name(spec))
+      if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id);await this.run(this.runtime,['rm','--',detail.Id])}
+      if(spec.network==='managed-proxy'){const gateway=await this.inspectRaw(this.name(spec)+'-network');if(gateway){this.assertNetwork(spec,gateway);await this.run(this.runtime,['rm','-f','--',gateway.Id])}this.networkIds.delete(spec.id)}
       // Persistent workspace deletion is a separate, explicit operation.
     })
   }
@@ -356,6 +358,34 @@ export class ContainerComputerProvider implements ComputerProvider {
       await rm(path,{recursive:true})
     })
   }
+  private assertNetwork(spec:Spec,gateway:any){
+    const host=gateway?.HostConfig??{}
+    if(!gateway||gateway.Config?.Labels?.['cn.samien.yaoyao.network-owner']!==digest({id:spec.id,ownerKey:spec.ownerKey})||host.NetworkMode!=='none'||host.Privileged!==false||host.PidMode==='host'||(gateway.Mounts??[]).some((m:any)=>m.Type!=='tmpfs')||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(['NET_ADMIN'])||!capabilities(host.CapDrop).includes('ALL')||(host.Devices??[]).length!==1||host.Devices[0].PathInContainer!=='/dev/net/tun'||Object.keys(host.PortBindings??{}).length)throw new ComputerError('computer_network_unsafe','网络网关隔离配置不匹配')
+  }
+  async verifyNetwork(){
+    await this.verifyRuntime()
+    try{const image=JSON.parse((await this.run(this.runtime,['image','inspect','localhost/yaoyao/network:1'])).stdout)[0];if(image?.Config?.Labels?.['cn.samien.yaoyao.network-gateway']!=='1')throw new Error()}catch{throw new ComputerError('computer_network_upgrade_required','请在本地虚拟机设置中重新准备网络镜像')}
+  }
+  private async ensureNetwork(spec:Spec,authorize:()=>void){
+    const name=this.name(spec)+'-network'
+    let gateway=await this.inspectRaw(name)
+    if(gateway){
+      this.assertNetwork(spec,gateway)
+    }else{
+      const image=JSON.parse((await this.run(this.runtime,['image','inspect','localhost/yaoyao/network:1'])).stdout)[0]
+      if(image?.Config?.Labels?.['cn.samien.yaoyao.network-gateway']!=='1')throw new ComputerError('computer_network_upgrade_required','请重新准备虚拟机网络镜像')
+      authorize()
+      await this.run(this.runtime,['create','--name',name,'--label',`cn.samien.yaoyao.network-owner=${digest({id:spec.id,ownerKey:spec.ownerKey})}`,'--network','none','--dns','198.18.0.1','--cap-drop','ALL','--cap-add','NET_ADMIN','--device','/dev/net/tun','--memory','128m','--pids-limit','64','--read-only','--tmpfs','/run',image.Id,'--init'])
+      gateway=await this.inspectRaw(name)
+    }
+    this.assertNetwork(spec,gateway);this.networkIds.set(spec.id,gateway.Id)
+    authorize();if(!gateway?.State?.Running)await this.run(this.runtime,['start',name])
+  }
+  async openNetworkPipe(value:ComputerSpecification,authorize:()=>void){
+    const spec=this.validateSpecification(value);authorize()
+    await this.ensureNetwork(spec,authorize)
+    return spawn(this.runtime,['exec','-i','--user','0:0','--',this.name(spec)+'-network','python3','/usr/local/libexec/yaoyao/network_gateway.py'],{stdio:'pipe'})
+  }
   async openPipe(value:ComputerSpecification,argv:string[],authorize:()=>void){
     const spec=this.validateSpecification(value)
     authorize();const state=await this.inspect(spec);authorize()

+ 4 - 0
src/runner/computers/images.ts

@@ -36,7 +36,11 @@ export class ComputerImages {
     if(image?.Id!==id||image.Os!=='linux'||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')throw new Error('镜像身份或电脑驱动不兼容')
     return imageManifest.parse({protocol:1,imageId:id,architecture:image.Architecture,driver:'0.20.0',layer:'5',imageKey:image.Config.Labels['cn.samien.yaoyao.image-key']??'standard',createdAt:Date.now()})
   }
+  async prepareNetwork(recipe:string):Promise<void>{
+    await this.run(this.runtime,['build','--pull=false','--file',join(resolve(recipe),'Dockerfile.network'),'--tag','localhost/yaoyao/network:1',resolve(recipe)])
+  }
   async prepare(recipe:string,key:LocalVmImageKey='standard'):Promise<ComputerImageManifest>{
+    await this.prepareNetwork(recipe)
     const tag=`localhost/yaoyao/computer:prepare-${randomUUID()}`
     await this.run(this.runtime,['build','--pull=false',...(key==='cursor'?['--platform','linux/amd64','--file',join(resolve(recipe),'Dockerfile.cursor')]:[]),'--tag',tag,resolve(recipe)])
     const id=JSON.parse(await this.run(this.runtime,['image','inspect',tag]))[0]?.Id

+ 3 - 3
src/runner/computers/localVm.ts

@@ -138,11 +138,11 @@ export class LocalVmImages {
         const ids=(await this.images.run(this.images.runtime,['image','ls','--no-trunc','--quiet','--filter','label=cn.samien.yaoyao.computer-image=1'])).trim().split(/\s+/).filter(Boolean)
         let image
         for(const value of [this.state.images?.[key],...ids].filter((id):id is string=>!!id&&id!==UNCONFIGURED_COMPUTER_IMAGE)) { try{const candidate=await this.images.inspect(value);if((candidate.imageKey??'standard')===key){image=candidate;break}}catch{} }
-        if(image)await verify(image)
+        const recipe=[join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(p=>existsSync(join(p,'Dockerfile')))
+        if(!recipe)throw new Error('缺少本地虚拟机资源,请重新安装 App')
+        if(image){progress('正在准备虚拟机网络网关');await this.images.prepareNetwork(recipe);await check();await verify(image)}
         else {
           progress('正在准备托管桌面镜像,首次下载可能需要数分钟')
-          const recipe=[join(dirname(this.runtime.script),'computer-image'),join(dirname(this.runtime.script),'../computers/computer-image'),resolve('deploy/computer')].find(p=>existsSync(join(p,'Dockerfile')))
-          if(!recipe)throw new Error('缺少本地虚拟机资源,请重新安装 App')
           image=await this.images.prepare(recipe,key)
         }
         await check();const previous=this.state.images![key];this.state.images![key]=image.imageId

+ 45 - 0
src/runner/network/managedProxy.ts

@@ -0,0 +1,45 @@
+import type {ChildProcessWithoutNullStreams} from 'node:child_process'
+import type {VmProxySettings} from '../../shared/executionEnvironment.js'
+import type {ContainerComputerProvider,ComputerSpecification} from '../computers/container.js'
+import {ComposeComputerProvider} from '../computers/compose.js'
+import {PublicSocketBroker,type NetworkFrame} from './socketBroker.js'
+import {publicDestination} from './publicDestination.js'
+import {proxyLookup,proxySocket} from './upstreamProxy.js'
+
+export class ManagedProxy {
+  private stopped=false
+  private child?:ChildProcessWithoutNullStreams
+  private token?:string
+  private outgoing:NetworkFrame[]=[]
+  private loop?:Promise<void>
+  private broker:PublicSocketBroker
+  get closed(){return this.stopped}
+  private constructor(private provider:ContainerComputerProvider,private spec:ComputerSpecification,proxy:VmProxySettings,authorize:()=>void,private onClose:()=>void){
+    this.broker=new PublicSocketBroker(async frame=>{
+      if(this.stopped)throw new Error('closed')
+      if(this.child){if(this.child.stdin.writableLength>4*1024*1024)throw new Error('queue limit');await new Promise<void>((resolve,reject)=>this.child!.stdin.write(JSON.stringify(frame)+'\n',error=>error?reject(error):resolve()))}
+      else{if(this.outgoing.length>=128)throw new Error('queue limit');this.outgoing.push(frame)}
+    },authorize,(host,port)=>publicDestination(host,port,proxyLookup(proxy)),target=>proxySocket(proxy,target.address,target.port))
+  }
+  static async start(provider:ContainerComputerProvider,spec:ComputerSpecification,proxy:VmProxySettings,authorize:()=>void,onClose:()=>void=()=>{}){
+    const result=new ManagedProxy(provider,spec,proxy,authorize,onClose)
+    try{
+      if(provider instanceof ComposeComputerProvider){
+        const opened=await provider.relay(spec.id,'network-open',{ownerKey:spec.ownerKey})
+        authorize();result.token=opened.token
+        result.loop=(async()=>{while(!result.stopped){authorize();const value=await provider.relay(spec.id,'network-exchange',{ownerKey:spec.ownerKey,token:result.token,frames:result.outgoing.splice(0,32)});await Promise.all(value.frames.map((frame:NetworkFrame)=>result.broker.receive(frame)))}})().catch(()=>result.close())
+      }else{
+        const child=await provider.openNetworkPipe(spec,authorize);result.child=child
+        child.stderr.resume();child.stdin.on('error',()=>{void result.close()});child.once('exit',()=>{void result.close()});child.once('error',()=>{void result.close()})
+        await new Promise<void>((resolve,reject)=>{
+          const timer=setTimeout(()=>{reject(new Error('统一网络出口启动超时'));void result.close()},10000)
+          let buffer='',ready=false
+          child.once('exit',()=>{clearTimeout(timer);if(!ready)reject(new Error('统一网络出口已退出'))})
+          child.stdout.on('data',chunk=>{buffer+=chunk;if(buffer.length>1024*1024){void result.close();return}for(;;){const at=buffer.indexOf('\n');if(at<0)break;const line=buffer.slice(0,at);buffer=buffer.slice(at+1);try{const frame=JSON.parse(line);if(frame.op==='ready'){ready=true;clearTimeout(timer);resolve()}else void result.broker.receive(frame).catch(()=>result.close())}catch{void result.close()}}})
+        })
+      }
+      authorize();return result
+    }catch(error){await result.close();throw error}
+  }
+  async close(){if(this.stopped)return;this.stopped=true;this.onClose();this.broker.close();this.outgoing=[];this.child?.stdin.end();this.child?.kill('SIGTERM');if(this.token&&this.provider instanceof ComposeComputerProvider)await this.provider.relay(this.spec.id,'network-close',{ownerKey:this.spec.ownerKey,token:this.token}).catch(()=>{})}
+}

+ 6 - 3
src/runner/network/socketBroker.ts

@@ -16,7 +16,7 @@ export class PublicSocketBroker {
   private closed=false
   private transferred=0
   private timer:ReturnType<typeof setInterval>
-  constructor(readonly send:(frame:NetworkFrame)=>Promise<void>,readonly authorize:()=>void,readonly resolve=publicDestination,readonly dial:(destination:Destination)=>Socket=target=>connect({host:target.address,family:target.family,port:target.port}),readonly limits={connections:16,bytes:256*1024*1024},readonly authorizeOpen:()=>Promise<void>=async()=>authorize()){
+  constructor(readonly send:(frame:NetworkFrame)=>Promise<void>,readonly authorize:()=>void,readonly resolve=publicDestination,readonly dial:(destination:Destination)=>Socket|Promise<Socket>=target=>connect({host:target.address,family:target.family,port:target.port}),readonly limits={connections:16,bytes:256*1024*1024},readonly authorizeOpen:()=>Promise<void>=async()=>authorize()){
     this.timer=setInterval(()=>{try{authorize()}catch{this.close()}},1000);this.timer.unref()
   }
   get activeConnections(){return this.streams.size}
@@ -32,9 +32,11 @@ export class PublicSocketBroker {
         await this.authorizeOpen()
         const target=await this.resolve(frame.host,frame.port)
         await this.authorizeOpen();this.authorize();if(this.closed||this.streams.get(frame.id)!==state)return
-        const socket=this.dial(target);state.socket=socket
+        const socket=await this.dial(target);state.socket=socket
+        this.authorize();if(this.closed||this.streams.get(frame.id)!==state){socket.destroy();return}
         socket.setTimeout(30000,()=>socket.destroy())
-        socket.once('connect',()=>{try{this.authorize();if(this.closed)throw new Error('closed');state.opened=true;void this.send({op:'opened',id:frame.id}).catch(()=>this.close())}catch{socket.destroy()}})
+        const connected=()=>{try{this.authorize();if(this.closed)throw new Error('closed');state.opened=true;void this.send({op:'opened',id:frame.id}).then(()=>socket.resume(),()=>this.close())}catch{socket.destroy()}}
+        if(socket.readyState==='open')queueMicrotask(connected);else socket.once('connect',connected)
         socket.on('data',chunk=>{
           socket.pause()
           try{this.authorize();this.count(chunk.length);state.received+=chunk.length}
@@ -45,6 +47,7 @@ export class PublicSocketBroker {
         socket.once('error',()=>{void this.send({op:'error',id:frame.id,message:'公网连接失败'}).catch(()=>this.close())})
         socket.once('close',()=>{if(this.streams.get(frame.id)===state)this.streams.delete(frame.id);void this.send({op:'close',id:frame.id}).catch(()=>this.close())})
       }catch{
+        state.socket?.destroy()
         this.streams.delete(frame.id)
         await this.send({op:'error',id:frame.id,message:'联网目标被拒绝或不可达'})
       }

+ 59 - 0
src/runner/network/upstreamProxy.ts

@@ -0,0 +1,59 @@
+import {connect,type Socket,isIP} from 'node:net'
+import {Agent,request} from 'node:https'
+import {connect as tlsConnect} from 'node:tls'
+import type {VmProxySettings} from '../../shared/executionEnvironment.js'
+import {publicDestination,type AddressLookup} from './publicDestination.js'
+
+/** A bounded handshake reader that does not consume the first application bytes. */
+class Reader {
+  private buffer=Buffer.alloc(0)
+  private wake?:()=>void
+  private error?:Error
+  private data=(b:Buffer)=>{this.buffer=Buffer.concat([this.buffer,b]);if(this.buffer.length>65536)this.fail(new Error('proxy header limit'));this.wake?.()}
+  private fail=(e:Error)=>{this.error=e;this.wake?.()}
+  private end=()=>this.fail(new Error('proxy closed'))
+  constructor(private socket:Socket){socket.on('data',this.data);socket.on('error',this.fail);socket.on('end',this.end)}
+  async bytes(n:number){while(this.buffer.length<n){if(this.error)throw this.error;await new Promise<void>(r=>{this.wake=r})}const b=this.buffer.subarray(0,n);this.buffer=this.buffer.subarray(n);return b}
+  async header(){const bytes:Buffer[]=[];for(let n=0;n<32768;n++){bytes.push(await this.bytes(1));if(n>=3&&Buffer.concat(bytes.slice(-4)).equals(Buffer.from('\r\n\r\n')))return Buffer.concat(bytes).toString('latin1')}throw new Error('proxy header limit')}
+  close(){this.socket.pause();this.socket.off('data',this.data);this.socket.off('error',this.fail);this.socket.off('end',this.end);if(this.buffer.length)this.socket.unshift(this.buffer)}
+}
+export async function proxySocket(proxy:VmProxySettings,address:string,port:number):Promise<Socket>{
+  if(!proxy.enabled)throw new Error('proxy disabled')
+  const socket=connect({host:proxy.host.replace(/^\[|\]$/g,''),port:proxy.port})
+  const timer=setTimeout(()=>socket.destroy(new Error('proxy timeout')),10000)
+  const reader=new Reader(socket)
+  try{
+    await new Promise<void>((resolve,reject)=>{socket.once('connect',resolve);socket.once('error',reject)})
+    if(proxy.protocol==='http'){
+      const authority=`${isIP(address)===6?'['+address+']':address}:${port}`
+      const auth=proxy.username||proxy.password?`Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password??''}`).toString('base64')}\r\n`:''
+      socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`)
+      if(!/^HTTP\/1\.[01] 200(?: |\r)/.test(await reader.header()))throw new Error('proxy refused')
+    }else{
+      const authenticated=!!(proxy.username||proxy.password)
+      socket.write(Buffer.from([5,1,authenticated?2:0]));const method=await reader.bytes(2)
+      if(method[0]!==5||method[1]!== (authenticated?2:0))throw new Error('proxy auth unavailable')
+      if(authenticated){const user=Buffer.from(proxy.username),password=Buffer.from(proxy.password??'');if(user.length>255||password.length>255)throw new Error('proxy auth length');socket.write(Buffer.concat([Buffer.from([1,user.length]),user,Buffer.from([password.length]),password]));const auth=await reader.bytes(2);if(auth[0]!==1||auth[1]!==0)throw new Error('proxy auth failed')}
+      // A numeric destination pins the address already checked by the broker.
+      const host=Buffer.from(address),destination=isIP(address)===4?Buffer.from([1,...address.split('.').map(Number)]):Buffer.concat([Buffer.from([3,host.length]),host])
+      const p=Buffer.alloc(2);p.writeUInt16BE(port);socket.write(Buffer.concat([Buffer.from([5,1,0]),destination,p]))
+      const reply=await reader.bytes(4);if(reply[0]!==5||reply[1]!==0)throw new Error('proxy refused')
+      const length=reply[3]===1?4:reply[3]===4?16:reply[3]===3?(await reader.bytes(1))[0]!:0;if(!length)throw new Error('proxy response');await reader.bytes(length+2)
+    }
+    reader.close();return socket
+  }catch{reader.close();socket.destroy();throw new Error('代理连接或认证失败')}
+  finally{clearTimeout(timer)}
+}
+
+/** DNS also traverses the configured proxy. No VM hostname is sent to local DNS. */
+export const proxyLookup=(proxy:VmProxySettings):AddressLookup=>async host=>{
+  const resolve=async(type:number)=>{
+    const socket=await proxySocket(proxy,'8.8.8.8',443),agent=new Agent({keepAlive:false})
+    agent.createConnection=()=>tlsConnect({socket,servername:'dns.google'})
+    try{return await new Promise<Array<{address:string;family:number}>>((done,fail)=>{
+      const req=request({hostname:'dns.google',path:`/resolve?name=${encodeURIComponent(host)}&type=${type}`,agent,timeout:10000},res=>{let text='';res.on('data',b=>{text+=b;if(text.length>65536)req.destroy(new Error('DNS response limit'))});res.on('error',fail);res.on('end',()=>{try{if(res.statusCode!==200)throw new Error();const data=JSON.parse(text);done((data.Answer??[]).filter((a:any)=>a.type===type&&isIP(a.data)).map((a:any)=>({address:a.data,family:isIP(a.data)})))}catch{fail(new Error('DNS failed'))}})});req.on('timeout',()=>req.destroy(new Error('DNS timeout')));req.on('error',fail);req.end()
+    })}finally{agent.destroy();socket.destroy()}
+  }
+  return (await Promise.all([resolve(1),resolve(28)])).flat()
+}
+export async function checkProxy(proxy:VmProxySettings){const target=await publicDestination('example.com',443,proxyLookup(proxy));const socket=await proxySocket(proxy,target.address,target.port);socket.destroy();return {ok:true}}

+ 5 - 3
src/runner/worker/control.ts

@@ -57,6 +57,7 @@ export class ComputerControls {
     this.captures.set(meta.environmentId,pending);return pending
   }
   async take(meta:ComputerTarget,profile:string,id:string,check:()=>Promise<void>):Promise<ComputerControlStatus>{
+    this.runtime.assertNetworkReady()
     this.runtime.assertTarget(meta);await check()
     const old=this.state(meta)
     if(old){if(old.id===id)return this.status(meta);throw new HttpError(409,'电脑已由另一控制会话接管','computer_control_busy')}
@@ -71,12 +72,12 @@ export class ComputerControls {
       let spec=this.runtime.pool.definition(meta.ownerKey,meta.environmentId)
       if(!spec||resource?.status!=='active'){
         const resolved=await this.runtime.resolveWorkspace(profile);await check();this.guard(state)
-        spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.runtime.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.runtime.config.network??'none'}
+        spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.runtime.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.runtime.network}
         await this.runtime.pool.configure(spec,()=>this.guard(state))
       }
       state.spec=spec
       state.lease=await this.runtime.pool.acquire(spec,`human:${id}`,()=>this.guard(state))
-      if(this.runtime.config.network==='public-proxy')state.proxyHandle=await this.runtime.acquireProxy(spec,()=>{if(!this.runtime.pool.hasHolders(meta.ownerKey,meta.environmentId))throw new HttpError(410,'电脑控制权已结束','computer_control_expired')})
+      if(this.runtime.network!=='none')state.proxyHandle=await this.runtime.acquireProxy(spec,()=>{if(!this.runtime.pool.hasHolders(meta.ownerKey,meta.environmentId))throw new HttpError(410,'电脑控制权已结束','computer_control_expired')})
       await check();this.guard(state);state.mode='human'
     })().catch(async()=>{if(!state.valid)return;state.mode='error';state.error='接管未完成,请等待当前操作结束后重试';await this.stop(state).catch(()=>{})})
     return this.status(meta)
@@ -136,5 +137,6 @@ export class ComputerControls {
     })().catch(error=>{state.mode='error';state.error='电脑停止状态待确认';state.stopping=undefined;throw error})
     return state.stopping
   }
-  async close(){clearInterval(this.timer);await Promise.allSettled([...this.manual.values()].map(state=>this.stop(state)))}
+  async stopAll(){await Promise.all([...this.manual.values()].map(state=>this.stop(state)))}
+  async close(){clearInterval(this.timer);await this.stopAll().catch(()=>{})}
 }

+ 47 - 9
src/runner/worker/gateway.ts

@@ -1,3 +1,6 @@
+import {ManagedProxy} from '../network/managedProxy.js'
+import type {VmProxySettings} from '../../shared/executionEnvironment.js'
+import {TASK_COMMAND,redactTaskOutput} from './taskEnvironment.js'
 import {randomUUID,createHash} from 'node:crypto'
 import {join,dirname,posix} from 'node:path'
 import {fileURLToPath} from 'node:url'
@@ -50,8 +53,36 @@ const PROFILE_COMPUTER_RULES='当前使用本机协作模式:沿用基础 Prof
 const HERMES_COMPUTER_RULES='本轮由 Hermes 管理模型、配置、识图、技能和授权资源。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能;遵循当前 Profile 的技能规则。技能依赖本机登录态、环境变量或授权文件时,使用 Hermes 原生工具完成相应步骤;适合 Linux 的脚本与输入文件可经 computer_copy_file 传入虚拟机,再用 computer_shell 执行。路径、软件和登录态在本机与虚拟机之间不通用。不要自行复制 Profile 配置或凭据到虚拟机。'
 const ISOLATED_COMPUTER_RULES='当前环境是虚拟环境,也就是服务端上的隔离虚拟机。先看 computer_desktop_state,再按截图像素用 computer_action 点击、输入、按键或滚动。命令和文件也通过 computer_* 在这台 Linux 电脑内部完成。不要使用服务器或已连接电脑的终端、文件或桌面,也不要在节点离线时改去服务器或电脑。电脑和云虚拟机是另外勾选的环境,不从这台虚拟机里打开。人工接管期间停止操作,交还后先检查当前状态。'
 const ISOLATED_HERMES_RULES='本轮由 Hermes 管理模型、配置和这次会话。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能。技能和 computer_* 都留在这台虚拟机里;不要把 Profile 的本机登录、文件或凭据复制进来。'
-export interface ProxyHandle {readonly proxy:ComputerPublicProxy;release():Promise<void>}
+export interface ProxyHandle {readonly proxy:ComputerPublicProxy|ManagedProxy;release():Promise<void>}
 export class ComputerRuntime {
+  private proxyPreparedRevision=-1
+  private proxyUpdate:Promise<void>=Promise.resolve()
+  private proxyUpdating=false
+  proxySettings?:VmProxySettings
+  updateProxy(value:VmProxySettings|undefined):Promise<void>{
+    const next=value?{...value}:undefined
+    const update=this.proxyUpdate.then(async()=>{
+      const revision=next?.revision??0,current=this.proxySettings?.revision??0
+      if(revision<current||revision===current&&this.proxyPreparedRevision===revision)return
+      this.proxyUpdating=true;this.proxyPreparedRevision=-1
+      try{
+        // Close with the old specification: cleanup must still address the old
+        // container namespace. Concurrent updates and new leases cannot race it.
+        const proxies=await Promise.allSettled([...this.proxyShares.values()].map(async share=>(await share.started.catch(()=>undefined))?.close()))
+        this.proxyShares.clear()
+        const controls=await Promise.allSettled([this.controls.stopAll()])
+        const gateways=await Promise.allSettled([...this.gateways.values()].flatMap(group=>[...group].map(gateway=>gateway.close())))
+        this.proxySettings=next
+        if([...proxies,...controls,...gateways].some(result=>result.status==='rejected'))throw new HttpError(409,'旧网络任务未完成清理,请重试代理配置','computer_network_cleanup_failed')
+        if(next?.enabled)await this.provider.verifyNetwork()
+        this.proxyPreparedRevision=revision
+      }finally{this.proxyUpdating=false}
+    })
+    this.proxyUpdate=update.catch(()=>{})
+    return update
+  }
+  assertNetworkReady(){if(this.proxyUpdating||this.proxySettings&&this.proxyPreparedRevision!==this.proxySettings.revision)throw new HttpError(409,'虚拟机代理正在更新或尚未就绪,请重试','computer_network_unavailable')}
+  get network(){return this.proxySettings?.enabled?'managed-proxy' as const:this.config.network??'none'}
   retainDesktops=false
   readonly controls:ComputerControls
   readonly provider:ContainerComputerProvider
@@ -62,7 +93,7 @@ export class ComputerRuntime {
   readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
   readonly script:string
   readonly proxyScript:string
-  private proxyShares=new Map<string,{started:Promise<ComputerPublicProxy>;users:number}>()
+  private proxyShares=new Map<string,{started:Promise<ComputerPublicProxy|ManagedProxy>;users:number}>()
   constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string,relay?:DesktopRelay,readonly hermesTarget?:GatewayTarget){
     if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
     this.config=config.computers
@@ -86,9 +117,11 @@ export class ComputerRuntime {
   /** One guest proxy per environment, shared by every holder; the guest
    * reserves port 3128, so concurrent holders must never start their own. */
   acquireProxy(spec:ComputerSpecification,authorize:()=>void):Promise<ProxyHandle>{
+    this.assertNetworkReady()
     let share=this.proxyShares.get(spec.id)
     if(!share){
-      const started=ComputerPublicProxy.start(this.provider,spec,this.proxyScript,authorize,async()=>authorize(),()=>{if(this.proxyShares.get(spec.id)===share)this.proxyShares.delete(spec.id)})
+      const forget=()=>{if(this.proxyShares.get(spec.id)===share)this.proxyShares.delete(spec.id)}
+      const started=spec.network==='managed-proxy'?ManagedProxy.start(this.provider,spec,this.proxySettings!,authorize,forget):ComputerPublicProxy.start(this.provider,spec,this.proxyScript,authorize,async()=>authorize(),forget)
       share={started,users:0};this.proxyShares.set(spec.id,share)
       void started.catch(()=>{if(this.proxyShares.get(spec.id)===share)this.proxyShares.delete(spec.id)})
     }
@@ -186,8 +219,9 @@ export class ComputerRuntime {
       if(spec)await this.pool.desktop(spec,action,authorize)
       return
     }
-    const resolved=await this.resolveWorkspace(profile);authorize()
-    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.config.network??'none'},action,authorize)
+    this.assertNetworkReady()
+    const resolved=await this.resolveWorkspace(profile);authorize();this.assertNetworkReady()
+    await this.pool.desktop({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.network},action,authorize)
   }
 }
 export class ComputerGateway {
@@ -220,6 +254,7 @@ export class ComputerGateway {
       return JSON.parse(result.stdout)
     })
   }
+  taskEnvironment?:()=>Promise<import('../../shared/executionEnvironment.js').TaskEnvironment|undefined>
   private get hermesManaged(){return this.meta.hermesRuntime===true}
   private get rules(){const sharing=this.meta.environmentId!==this.meta.agentId?'多个机器人共用这台虚拟机:命令和文件操作并行执行,桌面操作交替进行;动手前注意其他机器人可能正在使用同一桌面,避免覆盖彼此的工作。':'';if(this.meta.profileSession)return `${sharing}${PROFILE_COMPUTER_RULES}\n${this.hermesManaged?HERMES_COMPUTER_RULES:SKILL_RULES}`;return `${sharing}${ISOLATED_COMPUTER_RULES}\n${this.hermesManaged?ISOLATED_HERMES_RULES:SKILL_RULES}`}
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>;workspaceMemory:boolean}
@@ -228,7 +263,7 @@ export class ComputerGateway {
   private async authorized(){try{await this.check();this.guard()}catch(error){const stopping=this.interrupt().catch(()=>{});if(!this.profileSession)await stopping;if(!this.closed)this.onDisconnect();throw error}}
   private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
   private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
-  private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.config.network??'none'}}
+  private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.network}}
   async rpc(method:string,params:Record<string,any>):Promise<any>{
     if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.live?.session.id){await this.interrupt();return {status:'interrupted'}}
     if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {status:'interrupted'}}
@@ -352,7 +387,7 @@ export class ComputerGateway {
       live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
       await mkdir(directory,{recursive:true,mode:0o700})
-      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,contextConfig:live.contextConfig,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.config.network??'none',tools:[...TOOLS,...SKILL_TOOLS,...(this.meta.hostAccess?HOST_TOOLS.map(tool=>tool.name==='computer_copy_file'?{...tool,description:tool.description.replace('25 MiB',`${(this.meta.fileTransferMaxBytes??25*1024*1024)/1024/1024} MiB`)}:tool):[]),...(this.team?.catalog??[])],skillInstructions:SKILL_RULES,prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
+      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,contextConfig:live.contextConfig,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.network,tools:[...TOOLS,...SKILL_TOOLS,...(this.meta.hostAccess?HOST_TOOLS.map(tool=>tool.name==='computer_copy_file'?{...tool,description:tool.description.replace('25 MiB',`${(this.meta.fileTransferMaxBytes??25*1024*1024)/1024/1024} MiB`)}:tool):[]),...(this.team?.catalog??[])],skillInstructions:SKILL_RULES,prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
       live.worker=worker
       this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
       worker.onTool=(name,args,id,callId)=>{
@@ -374,6 +409,8 @@ export class ComputerGateway {
   installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>,workspaceMemory=false){this.team={id,catalog,call,workspaceMemory}}
   removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
   private ensureLease(live:Live):Promise<ComputerLease>{
+    this.runtime.assertNetworkReady()
+    if(live.proxyHandle?.proxy instanceof ManagedProxy&&live.proxyHandle.proxy.closed)throw new HttpError(409,'虚拟机代理连接已断开,请开始新一轮任务','computer_network_disconnected')
     if(live.lease)return Promise.resolve(live.lease)
     live.acquiring??=(async()=>{
       if(this.runtime.imageFor(this.meta)===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
@@ -382,7 +419,7 @@ export class ComputerGateway {
       const lease=await this.runtime.pool.acquire(this.spec(live.session),this.workId,()=>{this.guard();if(live.controller.signal.aborted)throw new Error('cancelled')},live.controller.signal)
       if(this.closed){await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
       live.lease=lease
-      if(this.runtime.config.network==='public-proxy'){
+      if(this.runtime.network!=='none'){
         try{live.proxyHandle=await this.runtime.acquireProxy(this.spec(live.session),()=>{if(!this.runtime.pool.hasHolders(this.meta.ownerKey,this.meta.environmentId))throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')})}
         catch(error){await this.runtime.pool.release(lease).catch(()=>{});live.lease=undefined;throw error}
       }
@@ -458,7 +495,8 @@ export class ComputerGateway {
         }
         if(name==='computer_shell'){
           const body=z.object({command:z.string().min(1).max(65536),user:z.enum(['cua','root']).default('cua')}).strict().parse(args)
-          try{return {exitCode:0,...await execute(['/bin/bash','-lc',body.command],undefined,undefined,body.user)}}catch(error){if(typeof(error as any).code==='number')return {exitCode:(error as any).code,stdout:String((error as any).stdout??''),stderr:String((error as any).stderr??'')};throw error}
+          const environment=await this.taskEnvironment?.()
+          try{const result=environment?await execute(['python3','-c',TASK_COMMAND],Buffer.from(JSON.stringify({command:body.command,environment})),undefined,body.user):await execute(['/bin/bash','-lc',body.command],undefined,undefined,body.user);await this.authorized();return redactTaskOutput({exitCode:0,...result},environment)}catch(error){if(typeof(error as any).code==='number')return redactTaskOutput({exitCode:(error as any).code,stdout:String((error as any).stdout??''),stderr:String((error as any).stderr??'')},environment);throw error}
         }
         if(name==='computer_read_file'){
           const body=z.object({path:pathField}).strict().parse(args)

+ 31 - 0
src/runner/worker/taskEnvironment.ts

@@ -0,0 +1,31 @@
+import type {TaskEnvironment} from '../../shared/executionEnvironment.js'
+
+// Credentials travel over stdin, never argv, process-global environment, or a
+// workspace file. Temporary files belong to this command and are removed even
+// when its subprocess fails. The command remains in the provider's process group.
+export const TASK_COMMAND = `import json,os,sys,tempfile,subprocess,pathlib,signal
+p=json.load(sys.stdin)
+def stop(signum,frame):
+    raise SystemExit(128+signum)
+signal.signal(signal.SIGTERM,stop)
+with tempfile.TemporaryDirectory(prefix='yaoyao-task-',dir='/tmp') as directory:
+    env=dict(os.environ)
+    env.update(p['environment']['variables'])
+    for item in p['environment']['files']:
+        root=pathlib.Path(directory)/item['envKey']
+        root.mkdir(mode=0o700)
+        target=root/item['name']
+        target.write_text(item['content'])
+        target.chmod(0o600)
+        env[item['envKey']]=str(root)
+    result=subprocess.run(['/bin/bash','-lc',p['command']],env=env,stdin=subprocess.DEVNULL)
+    sys.exit(result.returncode)
+`
+export function redactTaskOutput<T>(value:T,environment:TaskEnvironment|undefined):T {
+  if(!environment)return value
+  const secrets=[...Object.values(environment.variables),...environment.files.flatMap(f=>{
+    try{return [f.content,...JSON.stringify(JSON.parse(f.content)).matchAll(/"([^"\\]{8,})"/g)].map(v=>typeof v==='string'?v:v[1]!)}catch{return [f.content]}
+  })].filter(Boolean).sort((a,b)=>b.length-a.length)
+  const visit=(v:unknown):unknown=>typeof v==='string'?secrets.reduce((s,secret)=>s.split(secret).join('[已隐藏授权]'),v):Array.isArray(v)?v.map(visit):v&&typeof v==='object'?Object.fromEntries(Object.entries(v).map(([k,item])=>[k,visit(item)])):v
+  return visit(value) as T
+}

+ 9 - 1
src/server/app.ts

@@ -1,3 +1,4 @@
+import {ExecutionSettings} from './executionSettings.js'
 import {ManagedBrowsers} from './managedBrowsers.js'
 import { isWorkspaceAccountPath } from './subaccountAccess.js'
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
@@ -257,7 +258,14 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.localVmAllowed=(id,runnerId)=>localVm.allowed(id,runnerId)
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
+  const executionSettings=new ExecutionSettings(workspace,workspaceNodes,auth)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0, openVikingService)
+  workspaceRuntime.executionSettings=executionSettings
+  runners.executionSettings=executionSettings
+  executionSettings.onProxyChange=()=>runners.configureNetworks()
+  executionSettings.testProxy=()=>runners.testNetworks()
+  executionSettings.nodeStatus=()=>runners.networkStatus()
+  executionSettings.activeTurns=()=>workspaceRuntime.activeExecutionTurns()
   let pendingDashboardMutations=0
   const hermesBridge=options.hermesBridge??new HermesBridgeManager(config,upstreamSession,{
     dashboard:options.dashboardSupervisor,
@@ -483,7 +491,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
-  for(const router of [managedBrowsers.router(),desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router(),workspacePlugins.router()]){app.use(router.routes());app.use(router.allowedMethods())}
+  for(const router of [executionSettings.router(),managedBrowsers.router(),desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router(),workspacePlugins.router()]){app.use(router.routes());app.use(router.allowedMethods())}
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())

+ 2 - 1
src/server/botPlugins/workspacePlugins.ts

@@ -123,7 +123,7 @@ export class WorkspacePlugins {
     if (agent.remoteAgentId || agent.temporaryGoalId) return false
     return this.store.list<StoredPlugin>(owner, 'bot-mcp-plugin').some(p => p.enabled && p.agentIds.includes(agent.id)) || (this.apps.status(owner).configured && this.store.list<{ agentIds: string[] }>(owner, 'bot-app-grant').some(g => g.agentIds.includes(agent.id)))
   }
-  async open(owner: string, agent: WorkspaceAgent, target: GatewayTarget, signal: AbortSignal, authorize: () => void): Promise<OpenBotPlugins> {
+  async open(owner: string, agent: WorkspaceAgent, target: GatewayTarget, signal: AbortSignal, authorize: () => void, execution?:import('../../shared/executionEnvironment.js').ExecutionSelection): Promise<OpenBotPlugins> {
     const assertActive = () => {
       authorize(); this.assertOwner(owner); this.agents(owner, [agent.id])
       if (signal.aborted) throw new HttpError(403, '本轮插件授权已结束', 'plugin_grant_revoked')
@@ -173,6 +173,7 @@ export class WorkspacePlugins {
           if (!current?.enabled || current.revision !== plugin.revision || !current.agentIds.includes(agent.id)) throw new HttpError(403, '该插件的本轮授权已结束', 'plugin_grant_revoked')
         }
         check(); const value = this.value(owner, plugin)
+        if(execution&&value.transport==='stdio'){warnings.push({summary:{name:value.name,code:'plugin_environment_unavailable',message:`${value.name}需要在服务器启动程序,当前环境未挂载该插件;HTTP 服务仍可使用。`},check});continue}
         await add(plugin.id, value.name, value.transport, () => this.client(owner, value), check, plugin.tools)
       }
       const grants = this.store.db.prepare("SELECT id,data FROM workspace_entities WHERE owner=? AND kind='bot-app-grant'").all(owner)

+ 5 - 3
src/server/composeDesktops.ts

@@ -8,7 +8,7 @@ export function parseComposeDesktops(value:string|undefined):ComposeDesktop[]{
   if(!value?.trim())return []
   let input:unknown
   try{input=JSON.parse(value)}catch{throw new Error('HERMES_YAOYAO_COMPOSE_DESKTOPS 必须为有效 JSON')}
-  const parsed=z.array(z.object({id:z.string().uuid(),name:z.string().trim().min(1).max(100),socketPath:z.string().regex(/^\/run\/yaoyao-desktops\/[a-z0-9_-]+\/desktop\.sock$/),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional()}).strict()).min(1).max(32).parse(input)
+  const parsed=z.array(z.object({id:z.string().uuid(),name:z.string().trim().min(1).max(100),socketPath:z.string().regex(/^\/run\/yaoyao-desktops\/[a-z0-9_-]+\/desktop\.sock$/),networkSocketPath:z.string().regex(/^\/run\/yaoyao-networks\/[a-z0-9_-]+\/gateway\.sock$/).optional(),imageKey:z.enum(LOCAL_VM_IMAGE_KEYS).optional()}).strict()).min(1).max(32).parse(input)
   if(new Set(parsed.map(x=>x.id)).size!==parsed.length||new Set(parsed.map(x=>x.socketPath)).size!==parsed.length)throw new Error('Compose 桌面 ID 和连接路径不能重复')
   return parsed
 }
@@ -19,9 +19,11 @@ export class ComposeDesktops {
   async call(id:string,operation:string,body:Record<string,unknown>={}):Promise<any>{
     const desktop=this.desktops.find(x=>x.id===id)
     if(!desktop)throw new HttpError(404,'该桌面不在 Compose 配置中','compose_desktop_missing')
-    if(!['health','frame','acquire','renew','release','execute','cancel','skills-install'].includes(operation))throw new HttpError(409,'Compose 管理桌面数量和生命周期','compose_desktop_managed')
+    if(!['health','frame','acquire','renew','release','execute','cancel','skills-install','network-open','network-exchange','network-close','network-health'].includes(operation))throw new HttpError(409,'Compose 管理桌面数量和生命周期','compose_desktop_managed')
+    const socketPath=operation.startsWith('network-')?desktop.networkSocketPath:desktop.socketPath
+    if(!socketPath)throw new HttpError(409,'请更新 Compose 配置以启用统一网络出口','computer_network_upgrade_required')
     return new Promise((resolve,reject)=>{
-      const data=JSON.stringify({...body,desktopId:id}),req=request({socketPath:desktop.socketPath,path:`/${operation}`,method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)},timeout:70000},res=>{
+      const data=JSON.stringify({...body,desktopId:id}),req=request({socketPath,path:`/${operation}`,method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)},timeout:70000},res=>{
         const chunks:Buffer[]=[];let size=0
         res.on('data',chunk=>{size+=chunk.length;if(size>40*1024*1024)req.destroy(new Error('桌面回应过大'));else chunks.push(chunk)})
         res.on('end',()=>{try{if(res.headers['x-yaoyao-desktop-id']!==id)throw new HttpError(502,'Compose 桌面连接身份不匹配','compose_desktop_mismatch');const value=JSON.parse(Buffer.concat(chunks).toString());if(res.statusCode!==200)throw new HttpError(res.statusCode??502,value.error||'桌面操作失败',value.code||'compose_desktop_error');resolve(value)}catch(error){reject(error)}})

+ 92 - 0
src/server/executionSettings.ts

@@ -0,0 +1,92 @@
+import Router from '@koa/router'
+import {randomUUID} from 'node:crypto'
+import {existsSync} from 'node:fs'
+import {join} from 'node:path'
+import {z} from 'zod'
+import type {WorkspaceStore} from './workspaceStore.js'
+import {parse} from './workspaceStore.js'
+import type {WorkspaceNodes} from './workspaceGateway.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {WorkspaceAgent} from '../shared/workspace.js'
+import type {ExecutionSelection,VmProxySettings,TaskEnvironment,SystemResourceSummary} from '../shared/executionEnvironment.js'
+import {HttpError} from './errors.js'
+
+const SYSTEM='_system', KIND='execution-settings'
+const key=z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/).refine(k=>! /^(?:YAOYAO_|HERMES_YAOYAO_|NODE_OPTIONS$|LD_|DYLD_|PYTHONPATH$|PYTHONHOME$|BASH_ENV$|ENV$|HOME$|PATH$|CODEX_HOME$|HTTPS?_PROXY$|ALL_PROXY$|NO_PROXY$)/i.test(k),'该变量由运行环境管理')
+const variables=z.record(key,z.string().max(32000).refine(s=>!s.includes('\0'))).refine(v=>Object.keys(v).length<=100)
+const resourceInput=z.object({name:z.string().trim().min(1).max(80),kind:z.enum(['variables','api','codex']),agentIds:z.array(z.string().uuid()).max(500),
+  variables:variables.optional(),baseUrl:z.string().url().max(2000).optional(),token:z.string().min(1).max(32000).optional(),
+  tokenEnv:z.union([key,z.literal('')]).optional(),authJson:z.string().max(64000).optional(),revision:z.number().int().nonnegative().default(0)}).strict()
+type Resource=z.infer<typeof resourceInput>&{id:string}
+interface StoredResource {id:string;sealed:string;revision:number}
+export const SERVICE_TOOLS=[{id:'yaoyao_service_request',name:'yaoyao_service_request',description:'调用本轮获准的系统服务。serviceId 必须来自系统服务目录;凭据由系统附加。模型、图片等请求格式遵循对应服务和 Skill。',inputSchema:{type:'object',properties:{serviceId:{type:'string'},path:{type:'string'},method:{enum:['GET','POST']},body:{type:'object'}},required:['serviceId','path','method'],additionalProperties:false}}]
+
+/** One encrypted system store; grants reference globally unique Bot IDs. */
+export class ExecutionSettings {
+  testProxy:()=>Promise<unknown>=async()=>{throw new HttpError(409,'未连接虚拟环境执行节点','proxy_node_unavailable')}
+  onProxyChange:()=>Promise<void>=async()=>{}
+  nodeStatus:()=>Array<{id:string;name:string;online:boolean;status:string}>=()=>[]
+  activeTurns:()=>Array<{agentId:string;mode:ExecutionSelection['mode'];revision:number}>=()=>[]
+  constructor(readonly store:WorkspaceStore,readonly nodes:WorkspaceNodes,readonly auth:LocalAuthStore){
+    if(!store.get(SYSTEM,KIND,'selection')){
+      const existing=existsSync(join(store.home,'host-tools.json'))||!!store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind='agent' LIMIT 1").get()
+      store.put(SYSTEM,KIND,'selection',{mode:existing?'server':'none',revision:1})
+    }
+  }
+  selection():ExecutionSelection {const value=this.store.require<{mode:ExecutionSelection['mode'];revision:number}>(SYSTEM,KIND,'selection');return {...value,credentialSource:value.mode==='server'?'native':'yaoyao'}}
+  proxy():VmProxySettings {const value=this.store.get<{sealed:string}>(SYSTEM,KIND,'proxy');return value?this.nodes.open<VmProxySettings>(value.sealed):{enabled:false,protocol:'http',host:'',port:8080,username:'',revision:0}}
+  private entries(){return this.store.list<StoredResource>(SYSTEM,'execution-resource').map(row=>this.nodes.open<Resource>(row.sealed))}
+  private summary(v:Resource):SystemResourceSummary{return {id:v.id,name:v.name,kind:v.kind,revision:v.revision,agentIds:v.agentIds,envKeys:[...Object.keys(v.variables??{}),...(v.tokenEnv?[v.tokenEnv]:[]),...(v.kind==='codex'?['CODEX_HOME']:[])],baseUrl:v.baseUrl,tokenEnv:v.tokenEnv,configured:true}}
+  agents(){return (this.store.db.prepare("SELECT owner,data FROM workspace_entities WHERE kind='agent'").all() as {owner:string;data:string}[]).flatMap(row=>{const a=JSON.parse(row.data) as WorkspaceAgent;return a.archived||a.remoteAgentId?[]:[{id:a.id,name:a.name,owner:row.owner}]})}
+  view(){const {password,...proxy}=this.proxy();return {execution:this.selection(),proxy:{...proxy,hasPassword:!!password},resources:this.entries().map(v=>this.summary(v)),agents:this.agents(),nodes:this.nodeStatus(),activeTurns:this.activeTurns()}}
+  saveSelection(value:unknown){const v=parse(z.object({mode:z.enum(['none','server','virtual']),revision:z.number().int().positive()}).strict(),value);if(v.revision!==this.selection().revision)throw new HttpError(409,'配置已变化,请刷新','execution_revision_conflict');this.store.put(SYSTEM,KIND,'selection',{mode:v.mode,revision:v.revision+1});return this.selection()}
+  async saveProxy(value:unknown){
+    const v=parse(z.object({enabled:z.boolean(),protocol:z.enum(['http','socks5']),host:z.string().trim().max(253).refine(s=>!s||/^[A-Za-z0-9.:[\]_-]+$/.test(s)),port:z.number().int().min(1).max(65535),username:z.string().max(255),password:z.string().max(255).optional(),revision:z.number().int().nonnegative()}).strict(),value)
+    const old=this.proxy();if(old.revision!==v.revision)throw new HttpError(409,'代理配置已变化,请刷新','execution_revision_conflict')
+    if(v.enabled&&!v.host)throw new HttpError(400,'请输入代理服务器地址','proxy_host_required')
+    this.store.put(SYSTEM,KIND,'proxy',{sealed:this.nodes.seal({...v,password:v.password??old.password,revision:v.revision+1})})
+    await this.onProxyChange();return this.view().proxy
+  }
+  saveResource(value:unknown,id:string=randomUUID()){
+    const v=parse(resourceInput,value),old=this.entries().find(row=>row.id===id)
+    if((old?.revision??0)!==v.revision)throw new HttpError(409,'授权已变化,请刷新','execution_revision_conflict')
+    const known=new Set(this.agents().map(a=>a.id));if(v.agentIds.some(a=>!known.has(a)))throw new HttpError(400,'授权列表包含不可用的 Bot','execution_agent_invalid')
+    if(old&&old.kind!==v.kind)throw new HttpError(400,'请新建不同类型的授权','execution_resource_invalid')
+    const next={...old,...v,id,revision:v.revision+1}
+    if(v.kind==='api'){
+      const url=new URL(next.baseUrl??'https://invalid.invalid');if(!next.baseUrl||url.protocol!=='https:'||url.username||url.password||url.search||url.hash||!next.token)throw new HttpError(400,'服务需要 HTTPS 地址和授权令牌','execution_resource_invalid')
+    }
+    if(v.kind==='codex'){
+      try{const data=JSON.parse(next.authJson??'');if(!data||typeof data!=='object'||Array.isArray(data)||!(data.OPENAI_API_KEY||data.tokens?.access_token))throw new Error()}catch{throw new HttpError(400,'需要有效的 Codex auth.json;不会将它用作通用 API 密钥','execution_resource_invalid')}
+    }
+    if(next.tokenEnv&&Object.hasOwn(next.variables??{},next.tokenEnv))throw new HttpError(409,'授权令牌与环境变量名称重复','execution_variable_conflict')
+    const other=this.entries().filter(r=>r.id!==id)
+    const ownKeys=this.summary(next).envKeys
+    if(other.some(r=>r.agentIds.some(a=>next.agentIds.includes(a))&&this.summary(r).envKeys.some(k=>ownKeys.includes(k))))throw new HttpError(409,'同一 Bot 的变量名称重复,请合并或移除重复授权','execution_variable_conflict')
+    this.store.put(SYSTEM,'execution-resource',id,{id,revision:next.revision,sealed:this.nodes.seal(next)});return this.summary(next)
+  }
+  removeResource(id:string){this.store.remove(SYSTEM,'execution-resource',id)}
+  private granted(owner:string,agentId:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',agentId);if(!this.auth.isUserActive(owner)||agent.archived)throw new HttpError(403,'Bot 授权已结束','execution_grant_revoked');return this.entries().filter(r=>r.agentIds.includes(agentId))}
+  openTurn(owner:string,agentId:string,selection:ExecutionSelection){
+    const initial=selection.mode==='server'?[]:this.granted(owner,agentId)
+    const check=(id?:string)=>{const current=this.granted(owner,agentId);for(const r of initial.filter(v=>!id||id===v.id))if(!current.some(v=>v.id===r.id&&v.revision===r.revision))throw new HttpError(403,'系统变量或服务授权已变化,请开始新一轮任务','execution_grant_revoked')}
+    const taskEnvironment=():TaskEnvironment=>{check();const agent=this.store.require<WorkspaceAgent>(owner,'agent',agentId);if(agent.computerEnvironmentId){const peers=(this.store.db.prepare("SELECT data FROM workspace_entities WHERE kind='agent'").all() as {data:string}[]).map(row=>JSON.parse(row.data) as WorkspaceAgent).filter(a=>!a.archived&&a.computerEnvironmentId===agent.computerEnvironmentId);if(initial.some(r=>this.summary(r).envKeys.length&&peers.some(a=>!r.agentIds.includes(a.id))))throw new HttpError(403,'共享虚拟环境内仍有未获授权的 Bot;请为成员统一授权,或改用独立环境和服务工具','execution_shared_credentials_forbidden')}const result:TaskEnvironment={variables:{},files:[]};for(const r of initial){Object.assign(result.variables,r.variables);if(r.kind==='api'&&r.tokenEnv)result.variables[r.tokenEnv]=r.token!;if(r.kind==='codex')result.files.push({name:'auth.json',content:r.authJson!,envKey:'CODEX_HOME'})}return result}
+    return {check,taskEnvironment,catalog:()=>initial.some(r=>r.kind==='api')?SERVICE_TOOLS:[],description:initial.map(r=>({id:r.id,name:r.name,kind:r.kind,envKeys:this.summary(r).envKeys,baseUrl:r.baseUrl})),
+      call:async(args:unknown,signal:AbortSignal)=>{
+        const body=parse(z.object({serviceId:z.string(),path:z.string().max(2000),method:z.enum(['GET','POST']),body:z.record(z.string(),z.unknown()).optional()}).strict(),args)
+        check(body.serviceId);const r=initial.find(r=>r.id===body.serviceId&&r.kind==='api');if(!r)throw new HttpError(403,'当前环境未授权该系统服务','execution_service_forbidden')
+        const base=new URL(r.baseUrl!.replace(/\/?$/,'/')),url=new URL(body.path.replace(/^\//,''),base)
+        if(url.origin!==base.origin||!url.pathname.startsWith(base.pathname)||url.username||url.password||/%(?:2f|5c|25)/i.test(url.pathname))throw new HttpError(400,'请求路径超出已授权服务','execution_service_path')
+        try{const response=await fetch(url,{method:body.method,headers:{authorization:`Bearer ${r.token}`,'content-type':'application/json'},body:body.method==='POST'?JSON.stringify(body.body??{}):undefined,signal:AbortSignal.any([signal,AbortSignal.timeout(60000)]),redirect:'error'})
+          const chunks:Buffer[]=[];let size=0;for await(const part of response.body??[]){const b=Buffer.from(part);size+=b.length;if(size>8*1024*1024)throw new Error('limit');chunks.push(b)}check(body.serviceId)
+          const data=Buffer.concat(chunks).toString().split(r.token!).join('[已隐藏授权]');return {status:response.status,body:data}
+        }catch(error){check(body.serviceId);throw new HttpError(502,'服务调用失败,请检查连接、授权和请求格式','execution_service_failed')}
+      }}
+  }
+  router(){const router=new Router({prefix:'/api/app/admin/execution'});router.use(async(ctx,next)=>{this.auth.requireAdmin(ctx);ctx.set('Cache-Control','no-store');await next()});const body=(ctx:any)=>ctx.request.body
+    router.get('/',ctx=>{ctx.body=this.view()});router.put('/environment',ctx=>{ctx.body=this.saveSelection(body(ctx))});router.put('/proxy',async ctx=>{ctx.body=await this.saveProxy(body(ctx))})
+    router.post('/proxy/test',async ctx=>{ctx.body=await this.testProxy()})
+    router.post('/resources',ctx=>{ctx.body=this.saveResource(body(ctx));ctx.status=201});router.put('/resources/:id',ctx=>{ctx.body=this.saveResource(body(ctx),ctx.params.id)});router.delete('/resources/:id',ctx=>{this.removeResource(ctx.params.id);ctx.body={ok:true}})
+    return router
+  }
+}

+ 32 - 10
src/server/runnerHub.ts

@@ -20,11 +20,24 @@ const runnerBundle=()=>[resolve(dirname(fileURLToPath(import.meta.url)),'runner-
 const input = z.object({ name:z.string().trim().min(1).max(100),sourceNodeId:z.string().min(1).max(100).default('local'),
   allowedProfiles:z.array(z.string().min(1).max(100)).min(1).max(256) }).strict()
 interface Pending { command:RunnerCommand; resolve(value:any):void; reject(error:Error):void; timer:ReturnType<typeof setTimeout>; nextDelivery:number; valid():boolean }
-interface GatewayConnection { 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 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>}> }
 
 /** 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?'已同步,启动时校验出口':'待更新'}})}
+  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=>{
+    if(!this.summary(r).features.includes('vm-egress-v1')){
+      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:'网络配置未生效'})}
+  }))}
+  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}}}))}}
   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 }>()
@@ -103,6 +116,7 @@ export class RunnerHub {
   }
   private request(id:string,kind:RunnerCommand['kind'],payload:Record<string,unknown>,valid:()=>boolean=()=>true):Promise<any> {
     if(!this.online.has(id))return Promise.reject(new HttpError(503,'执行节点未连接','runner_offline'))
+    if(!this.networkCommandAllowed(id,kind,payload))return Promise.reject(new HttpError(409,'全局代理需要新版 Runner,请更新后操作虚拟机','proxy_runner_required'))
     const queue=this.pending.get(id)??new Map<string,Pending>();this.pending.set(id,queue)
     if(queue.size>=64)return Promise.reject(new HttpError(429,'执行节点请求过多','runner_busy'))
     // Allow Hermes' 60-second binding budget plus transport overhead.
@@ -154,11 +168,14 @@ export class RunnerHub {
       runner:{id:record.id,computer:!!computer,hermesComputer:!!computer&&this.online.get(record.id)?.features.includes('hermes-computer-v2')===true,helperRetirement:this.online.get(record.id)?.features.includes('helper-retirement-v1')===true,fileTransfer:this.online.get(record.id)?.features.includes('file-transfer-v1')===true,
         open:async(onEvent,onDisconnect,scope)=>{
           requireComputer()
+          if(computer&&!scope?.cleanupOnly&&!this.networkSupported(record.id))throw new HttpError(409,'全局代理需要新版 Runner,请更新后使用虚拟机','proxy_runner_required')
+          if(scope?.taskEnvironment&&!this.online.get(record.id)?.features.includes('execution-context-v1'))throw new HttpError(409,'请更新 Runner 以提供系统环境与授权','execution_runner_required')
           if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
           const version=this.auth.pushAuthorizationVersion(owner)
           const valid=()=>{
             try{
               scope?.authorize()
+              if(computer&&!scope?.cleanupOnly&&!this.networkSupported(record.id))return false
               if(computer&&!scope?.cleanupOnly){
                 const agent=this.store.get<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',computer.agentId)
                 if(!agent)return false
@@ -170,7 +187,7 @@ export class RunnerHub {
               return (scope?.cleanupOnly||(this.auth.isUserActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version))&&this.store.get<RunnerRecord>('_system','runner',record.id)?.enabled===true
             }catch{return false}
           }
-          const id=randomUUID();this.connections.set(id,{runnerId:record.id,computer,valid,onEvent,onDisconnect,publishArtifact:scope?.publishArtifact,cleanupOnly:scope?.cleanupOnly})
+          const id=randomUUID();this.connections.set(id,{runnerId:record.id,computer,valid,onEvent,onDisconnect,taskEnvironment:scope?.taskEnvironment,publishArtifact:scope?.publishArtifact,cleanupOnly:scope?.cleanupOnly})
           try {await this.request(record.id,'gateway.open',{connectionId:id,computer,workId:scope?.workId,cleanupOnly:scope?.cleanupOnly})} catch(error){this.connections.delete(id);void this.request(record.id,'gateway.close',{connectionId:id}).catch(()=>{});throw error}
           let closed=false
           return {rpc:async(method,params)=>{
@@ -188,14 +205,16 @@ export class RunnerHub {
           }}
         },
         lease:async(options)=>{
-          requireComputer();requireProfile(options.profile);options.assertActive()
+          requireComputer()
+          requireProfile(options.profile);options.assertActive()
+          if(options.computerPolicy&&['none','virtual'].includes(options.computerPolicy.mode)&&!this.online.get(record.id)?.features.includes('execution-context-v1'))throw new HttpError(409,'请更新 Runner 以执行环境限制','execution_runner_required')
           if(options.workspaceMemory&&!this.online.get(record.id)?.features.includes('workspace-memory-bind-v1'))throw new HttpError(409,'请更新配套 Runner,以支持工具桥会话的独立记忆。','workspace_memory_runner_required')
           const id=randomUUID();this.leases.set(id,{runnerId:record.id,input:options,calls:new Map()})
           let disposed=false
           const dispose=async()=>{if(disposed)return;disposed=true;this.leases.delete(id);options.signal.removeEventListener('abort',abort);await this.request(record.id,'lease.close',{leaseId:id}).catch(()=>{})}
           const abort=()=>{void dispose()};options.signal.addEventListener('abort',abort,{once:true})
           try {
-            await this.request(record.id,'lease.create',{leaseId:id,profile:options.profile,workId:options.workId,session:options.session(),catalog:options.catalog(),workspaceMemory:options.workspaceMemory===true})
+            await this.request(record.id,'lease.create',{leaseId:id,profile:options.profile,workId:options.workId,session:options.session(),catalog:options.catalog(),workspaceMemory:options.workspaceMemory===true,computerPolicy:options.computerPolicy})
             options.assertActive()
             if(options.signal.aborted)throw new Error('执行已停止')
             return {bind:async()=>{options.assertActive();await this.request(record.id,'lease.bind',{leaseId:id,session:options.session()});options.assertActive()},dispose} satisfies WorkspaceToolLease
@@ -263,7 +282,7 @@ export class RunnerHub {
   }
   middleware():Koa.Middleware {
     return async(ctx,next)=>{
-      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|local-vm-check|browser-check|desktop)$/.exec(ctx.path)
+      const match=/^\/api\/runner\/v1\/([0-9a-f-]{36})\/(poll|admit|result|event|tool|check|artifact|control-check|local-vm-check|browser-check|desktop|execution-context|network-settings)$/.exec(ctx.path)
       if(!match)return next()
       const record=this.store.get<RunnerRecord>('_system','runner',match[1]!),token=ctx.get('authorization').replace(/^Bearer /,'')
       if(ctx.get('origin')||!record?.enabled||!ctx.get('authorization').startsWith('Bearer ')||!timingSafeEqual(Buffer.from(record.tokenHash),Buffer.from(hash(token))))
@@ -280,7 +299,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['execution-context-v1','vm-egress-v1','workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -301,22 +320,25 @@ export class RunnerHub {
       let body:any
       try {body=JSON.parse(Buffer.concat(chunks).toString())}catch{throw new HttpError(400,'JSON 无效','invalid_json')}
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
+      if(match[2]==='network-settings'){if(Number.isInteger(body.appliedRevision)&&body.appliedRevision===this.executionSettings?.proxy().revision)this.networkApplied.set(record.id,{revision:body.appliedRevision});ctx.set('Cache-Control','no-store');ctx.body={proxy:this.executionSettings?.proxy()};return}
+      if(match[2]==='execution-context'){const connection=this.connections.get(String(body.connectionId));if(!connection?.computer||connection.runnerId!==record.id||!connection.valid())throw new HttpError(403,'任务环境授权已结束','execution_grant_revoked');ctx.set('Cache-Control','no-store');ctx.body={environment:connection.taskEnvironment?.()};return}
       if(match[2]==='desktop'){
         if(record.sourceNodeId!=='local'||record.sourceOwner!=='_system'||!state.features.includes('compose-desktops-v1'))throw new HttpError(403,'该节点不能访问 Compose 桌面','compose_desktop_forbidden')
-        const {id,operation,ownerKey,...payload}=parse(z.object({id:z.string(),operation:z.enum(['list','health','frame','acquire','renew','release','execute','cancel','skills-install']),ownerKey:z.string().optional()}).passthrough(),body)
+        const {id,operation,ownerKey,...payload}=parse(z.object({id:z.string(),operation:z.enum(['list','health','frame','acquire','renew','release','execute','cancel','skills-install','network-open','network-exchange','network-close','network-health']),ownerKey:z.string().optional()}).passthrough(),body)
         if(operation==='list'){ctx.body=await this.composeDesktops.status();return}
+        if(operation==='network-health'){ctx.body=await this.composeDesktops.call(id,operation);return}
         const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',id)
         if(!claim||claim.runnerId!==record.id||hash(claim.owner)!==ownerKey)throw new HttpError(403,'没有该共享桌面的使用权限','compose_desktop_forbidden')
         const valid=()=>this.auth.isUserActive(claim.owner)&&this.store.list<import('../shared/workspace.js').WorkspaceAgent>(claim.owner,'agent').some(a=>!a.archived&&a.computerEnvironmentId===id&&this.auth.canUseSource(claim.owner,a.nodeId,a.profile))
         const active=()=>[...this.connections.values()].some(c=>c.runnerId===record.id&&c.computer?.environmentId===id&&c.computer.ownerKey===ownerKey&&c.valid())||this.store.list<any>('_system','computer-control').some(g=>g.owner===claim.owner&&g.runnerId===record.id&&g.environmentId===id&&this.controlAllowed(g.id,record.id))
-        if(!['release','cancel'].includes(operation)&&(!valid()||(!['health','frame'].includes(operation)&&!active())))throw new HttpError(403,'桌面操作缺少当前任务或接管授权','compose_desktop_forbidden')
+        if(!['release','cancel','network-close'].includes(operation)&&(!valid()||(!['health','frame'].includes(operation)&&!active())))throw new HttpError(403,'桌面操作缺少当前任务或接管授权','compose_desktop_forbidden')
         const result=await this.composeDesktops.call(id,operation,{...payload,owner:ownerKey})
         if(operation==='acquire'&&(!valid()||!active())){await this.composeDesktops.call(id,'release',{...result,cancel:true}).catch(()=>{});throw new HttpError(410,'桌面操作已取消','computer_control_expired')}
         ctx.body=result;return
       }
       if(match[2]==='browser-check'){ctx.body={allowed:state.features.some(feature=>['managed-browser-v1','managed-browser-setup-v1'].includes(feature))&&this.browserAllowed(record.id,body.scope,typeof body.grantId==='string'?body.grantId:undefined)};return}
       if(match[2]==='local-vm-check'){ctx.body={allowed:typeof body.id==='string'&&this.localVmAllowed(body.id,record.id)};return}
-      if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
+      if(match[2]==='control-check'){ctx.body={allowed:this.networkSupported(record.id)&&typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='artifact'){
         const value=parse(z.object({connectionId:z.string().uuid(),id:z.string().uuid(),phase:z.enum(['begin','append','finish']),name:z.string().min(1).max(240).optional(),size:z.number().int().min(0).max(25*1024*1024).optional(),digest:z.string().regex(/^[a-f0-9]{64}$/).optional(),index:z.number().int().min(0).optional(),data:z.string().max(1024*1024).optional()}).strict(),body)
         const connection=this.connections.get(value.connectionId)
@@ -349,7 +371,7 @@ export class RunnerHub {
       if(match[2]==='check'){const connection=this.connections.get(body.connectionId);ctx.body={allowed:connection?.runnerId===record.id&&connection.valid(),fileTransferMaxBytes:readHostTools(this.store.home).fileTransferMaxMiB*1024*1024};return}
       if(match[2]==='admit') {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
-        let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()
+        let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()&&this.networkCommandAllowed(record.id,command.kind,command.payload)
         if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close'].includes(String(command.payload.method))||connection.valid())}
         if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
           const lease=this.leases.get(String(command.payload.leaseId))

+ 2 - 2
src/server/vmComputer.ts

@@ -19,11 +19,11 @@ export class VmToolSession {
   private gateway?: WorkspaceGateway
   private sessionId=''
   private opening?:Promise<void>
-  constructor(private readonly target:GatewayTarget,private readonly profile:string,private readonly workId:string,private readonly authorize:()=>void,private readonly publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>){}
+  constructor(private readonly target:GatewayTarget,private readonly profile:string,private readonly workId:string,private readonly authorize:()=>void,private readonly publishArtifact?:(name:string,bytes:Buffer)=>Promise<unknown>,private readonly taskEnvironment?:()=>import('../shared/executionEnvironment.js').TaskEnvironment){}
   private async open(){
     if(this.opening)return this.opening
     if(!this.gateway){
-      const gateway=new WorkspaceGateway(this.target,{workId:this.workId,authorize:this.authorize,...(this.publishArtifact?{publishArtifact:this.publishArtifact}:{})})
+      const gateway=new WorkspaceGateway(this.target,{workId:this.workId,authorize:this.authorize,taskEnvironment:this.taskEnvironment,...(this.publishArtifact?{publishArtifact:this.publishArtifact}:{})})
       this.gateway=gateway
       this.opening=(async()=>{
         this.authorize()

+ 15 - 9
src/server/workspaceEnvironment.ts

@@ -14,25 +14,31 @@ export function buildWorkspaceEnvironment(input: {
   bridge: boolean
   cloud: boolean
   plugins: boolean
+  execution?:import('../shared/executionEnvironment.js').ExecutionSelection
+  serverDesktop?:boolean
   managedBrowser?: boolean
 }): WorkspaceEnvironment {
   const { agent, globals, desktop } = input
+  const mode=input.execution?.mode
+  // A remote Hermes node must not gain access to the Yaoyao host's desktop.
+  const hosts=!mode?desktop.hosts:mode==='server'&&input.serverDesktop?desktop.hosts.filter(h=>h.kind==='server'):[]
   const eligible = !agent.archived && !agent.remoteAgentId
-  const vm = !!eligible && globals.vm && input.bridge
-  const cloud = !!eligible && globals.cloud && input.cloud
+  const vm = !!eligible && globals.vm && input.bridge && (!mode||mode==='virtual')
+  const cloud = !!eligible && globals.cloud && input.cloud && !mode
   return {
+    ...(input.execution?{selection:input.execution}:{}),
     version: 1, capturedAt: desktop.capturedAt, execution: { nodeId: agent.nodeId, profile: agent.profile },
     open: { computer: globals.scriptMachine, server: globals.serverComputer, vm: globals.vm, cloud: globals.cloud },
-    desktop,
+    desktop:mode?{...desktop,hosts}:desktop,
     tools: {
-      desktopView: !!eligible && desktop.hosts.some(host => host.capabilities.view.enabled),
-      desktopFile: !!eligible && desktop.hosts.some(host => host.capabilities.fileRead.enabled),
-      vm, cloud, plugins: input.plugins,
-      ...(eligible && input.bridge && globals.managedBrowser && input.managedBrowser ? {managedBrowser:true} : {}),
+      desktopView: !!eligible && hosts.some(host => host.capabilities.view.enabled),
+      desktopFile: !!eligible && hosts.some(host => host.capabilities.fileRead.enabled),
+      vm, cloud, plugins: input.plugins && mode!=='server',
+      ...(eligible && !mode && input.bridge && globals.managedBrowser && input.managedBrowser ? {managedBrowser:true} : {}),
     },
     virtual: {
-      vm: { status: !globals.vm ? 'disabled' : vm ? 'on_demand' : 'bridge_unavailable', environmentId: agent.computerEnvironmentId ?? agent.id },
-      cloud: { status: !globals.cloud ? 'disabled' : cloud ? 'on_demand' : 'not_configured', ...(cloud ? { cwd: '/workspace' } : {}) },
+      vm: { status: !globals.vm||(mode&&mode!=='virtual') ? 'disabled' : vm ? 'on_demand' : 'bridge_unavailable', environmentId: agent.computerEnvironmentId ?? agent.id },
+      cloud: { status: !globals.cloud||mode ? 'disabled' : cloud ? 'on_demand' : 'not_configured', ...(cloud ? { cwd: '/workspace' } : {}) },
     },
     fileTransferMaxMiB: globals.fileTransferMaxMiB,
   }

+ 1 - 1
src/server/workspaceGateway.ts

@@ -21,7 +21,7 @@ export interface WorkspaceNode {
   fingerprint?: string
   deviceId?: string
 }
-export interface GatewayExecutionScope {workId:string;cleanupOnly?:boolean;sessionId?:string;authorize():void;publishArtifact?(name:string,bytes:Buffer):Promise<unknown>}
+export interface GatewayExecutionScope {taskEnvironment?:()=>import('../shared/executionEnvironment.js').TaskEnvironment;workId:string;cleanupOnly?:boolean;sessionId?:string;authorize():void;publishArtifact?(name:string,bytes:Buffer):Promise<unknown>}
 export interface GatewayTarget {
   url: URL
   client: UpstreamClient

+ 3 - 2
src/server/workspacePrompt.ts

@@ -41,10 +41,11 @@ export function workspaceEnvironmentPrompt(env: WorkspacePromptEnvironment): str
   const open = [env.open.computer && '电脑', env.open.server && '服务器桌面', env.open.vm && '虚拟环境', env.open.cloud && '云虚拟机'].filter(Boolean)
   const desktop = env.tools.desktopView || env.tools.desktopFile
   return section('本轮环境与设备', [
+    env.selection?`系统统一运行环境:${env.selection.mode};配置版本 ${env.selection.revision};变量和服务授权来源:${env.selection.credentialSource}。只使用所选环境,本轮无权限的操作不得切换到其他机器执行。`:undefined,
     '模型会话运行在服务端 Hermes。Hermes 原生终端和文件工具操作它所在的服务器运行环境,不代表用户所说的「本机」;若运行在容器中,也不能假定其路径与服务器桌面共享。网页、浏览器、终端和文件能力以本轮实际工具目录为准。',
     env.cwd ? `Hermes 本轮工作目录(已确认):${JSON.stringify(env.cwd)}。该路径仅属于 Hermes 运行环境。` : undefined,
-    `全局开放的额外电脑环境:${open.join('、') || '无'}。开放不等于在线或已授权;具体操作仍需目标设备就绪。全局电脑开关不代表 Hermes 原生工具权限,环境可按任务同时使用。`,
-    '先确定操作目标,再选择工具。用户明确点名的设备优先;「本机」只指本轮消息来源电脑,历史消息中的来源不适用于本轮。来源未知、名称重名或目标不明确时先澄清;目标离线、未开放或未授权时说明原因,不得切换其他机器代做。',
+    `全局开放的额外电脑环境:${open.join('、') || '无'}。开放不等于在线或已授权;具体操作仍需目标设备就绪。实际执行环境以本轮系统选择与工具目录为准。`,
+    env.selection?'执行位置已由系统固定。用户提到其他设备时先说明当前环境限制,不能跨环境代做;服务器桌面工具参数中的 local 与 server 均固定指服务器,不指发消息的设备。':'先确定操作目标,再选择工具。用户明确点名的设备优先;「本机」只指本轮消息来源电脑,历史消息中的来源不适用于本轮。来源未知、名称重名或目标不明确时先澄清;目标离线、未开放或未授权时说明原因,不得切换其他机器代做。',
     deviceSourceText(env.desktop),
     deviceInventoryText(env.desktop),
     desktop && env.open.computer ? DESKTOP_ENVIRONMENT_RULES : undefined,

+ 28 - 9
src/server/workspaceRuntime.ts

@@ -1,3 +1,5 @@
+import type {ExecutionSettings} from './executionSettings.js'
+import {isLocalAuthorizationTarget} from './loopbackAuthorization.js'
 import {type ManagedBrowsers,type BrowserTurn} from './managedBrowsers.js'
 import {ToolOperationJournal} from './toolOperationJournal.js'
 import {browserOwnedFile,browserVmFile} from './browserFiles.js'
@@ -55,6 +57,7 @@ export { mentionedAgents } from './workspaceMentions.js'
 type Run = WorkspaceRun
 
 export interface WorkspaceBinding {
+  executionRevision?:number
   memoryVersion?: string
   memoryState?: MemoryContextState
   memoryPending?: boolean
@@ -113,6 +116,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   readonly collaboration: WorkspaceCollaboration
   readonly knowledgeTools: WorkspaceKnowledgeTools
   onMemoryCandidate: (owner: string, run: Run) => void = () => {}
+  private executionTurns=new Map<string,{agentId:string;mode:import('../shared/executionEnvironment.js').ExecutionMode;revision:number}>()
+  activeExecutionTurns(){return [...this.executionTurns.values()]}
+  executionSettings?:ExecutionSettings
   plugins?: WorkspacePlugins
   managedBrowsers?:ManagedBrowsers
   private operationJournal:ToolOperationJournal
@@ -315,6 +321,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
       chatExecution = 'profile',
       target = this.nodes.target(owner, agent.nodeId)
+    const execution=this.executionSettings?.selection()
+    const systemTurn=execution?this.executionSettings!.openTurn(owner,agent.id,execution):undefined
     let vmTools: VmToolSession | undefined
     let browserTurn:BrowserTurn|undefined
     const gateway = new WorkspaceGateway(target,{workId:run.id,publishArtifact:async(name,bytes)=>{
@@ -330,6 +338,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }})
     gateway.onTrace=entry=>this.inspector?.record(owner,c.id,{...entry,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
     const botCapabilities = await this.botCapabilities(owner, agent, target)
+    if(execution&&execution.mode!=='server'&&!botCapabilities.tools)throw new HttpError(409,'当前环境需要新版工具桥,未提交任务,请更新 Hermes 工具桥','execution_bridge_required')
     const root = this.store.require<Run>(owner, 'run', run.runId)
     if (root.peerMessageId && !recovering) {
       const peer = this.store.require<import('../shared/workspaceKnowledge.js').WorkspacePeerMessage>(owner, 'peer-message', root.peerMessageId)
@@ -350,7 +359,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       : undefined
     const sourceReset = !!this.store.get(owner,'binding-reset',key)
     const movedRunner=sourceReset||!!binding&&(binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==chatExecution||!!binding.hermesComputer!==!!target.runner?.hermesComputer)
-    const resetReason = memoryReset ?? (sourceReset ? 'source_changed' : movedRunner ? 'runner_changed' : undefined)
+    const resetReason = (execution&&binding?.executionRevision!==execution.revision?'execution_environment_changed':undefined) ?? memoryReset ?? (sourceReset ? 'source_changed' : movedRunner ? 'runner_changed' : undefined)
     if(resetReason) {
       if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
       this.inspector?.record(owner, c.id, { method: 'bot.session-reset', direction: 'event', agentId: agent.id, runId: run.runId, taskId: run.conversationTaskId,
@@ -423,6 +432,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       clearTimeout(flushTimer)
       flushTimer = undefined
       this.live.delete(key)
+      this.executionTurns.delete(run.id)
       try {
         const current = this.getWork(owner, run.id)
         if (current.cancelRequested && current.status === 'interrupted') {
@@ -779,10 +789,12 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         runId: run.runId,
         conversationTaskId: run.conversationTaskId,
         taskId: run.id,
+        executionRevision:execution?.revision,
         contextSeq: binding?.contextSeq ?? 0,
         messageId: resultMessage.id,
       }
       this.store.put(owner, 'binding', key, binding)
+      if(execution)this.executionTurns.set(run.id,{agentId:agent.id,mode:execution.mode,revision:execution.revision})
       this.live.set(key, { gateway, runtimeId, runId: run.runId, conversationTaskId: run.conversationTaskId, taskId: run.id, agentId: agent.id, done: finish })
       if (this.closing || this.getWork(owner, run.id).cancelRequested || this.getWork(owner, run.id).status === 'interrupted')
         throw new Error('运行已停止')
@@ -802,7 +814,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const globals=globalComputers(this.store.home)
         const desktopSnapshot=this.desktopEnvironments?.snapshot(owner,agent,root.deviceHost,{script:globals.scriptMachine,server:globals.serverComputer,maxMiB:globals.fileTransferMaxMiB})
           ??{capturedAt:Date.now(),sourceHost:root.deviceHost,hosts:[]}
-        const environment=buildWorkspaceEnvironment({agent,globals,desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent),managedBrowser:this.managedBrowsers?.available(owner,agent)})
+        const environment=buildWorkspaceEnvironment({agent,globals,execution,serverDesktop:agent.nodeId==='local'&&isLocalAuthorizationTarget(target.url),desktop:desktopSnapshot,bridge:botCapabilities.tools,cloud:!!this.cloud?.selected(owner,agent),plugins:!!this.plugins?.selected(owner,agent),managedBrowser:this.managedBrowsers?.available(owner,agent)})
         const {cloud,plugins,vm:dispatchedVm}=environment.tools
         const desktop=environment.tools.desktopView||environment.tools.desktopFile
         const desktopEpochs=Object.fromEntries(desktopSnapshot.hosts.map(host=>[host.id,host.epoch??'']))
@@ -816,7 +828,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           if(!vmHolder.session){
             const vmTarget=this.nodes.targetForAgent(owner,{...agent,execution:'computer'})
             vmHolder.session=new VmToolSession(vmTarget,agent.profile,run.id,()=>{
-              requireVm();this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
+              systemTurn?.check();requireVm();this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
               const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
               if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||latest.computerEnvironmentId!==agent.computerEnvironmentId)throw new HttpError(403,'本轮机器人或任务授权已结束','run_authorization_revoked')
             },async(name,bytes)=>{
@@ -825,33 +837,40 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
               if(!resultMessage.attachments.some(item=>item.id===file.id))resultMessage.attachments.push(file)
               this.store.saveMessage(owner,resultMessage)
               return {...file,url:`/api/app/files/${file.id}/download`}
-            })
+            },systemTurn?()=>systemTurn.taskEnvironment():undefined)
             vmTools=vmHolder.session
           }
           return name==='__file_transfer'?vmHolder.session.transfer(args as Record<string,unknown>):vmHolder.session.call(name,args,callId)
         }
-        if(team||cloud||desktop||plugins||knowledge||routines||dispatchedVm||environment.tools.managedBrowser){
+        if(team||cloud||desktop||plugins||knowledge||routines||dispatchedVm||environment.tools.managedBrowser||(execution&&botCapabilities.tools)){
           if(team){const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)}
           if(desktop)await this.desktopEnvironments!.requireAvailable(owner,agent,target)
           if(cloud)await this.cloud!.requireAvailable(owner,agent,target)
           const assertActive=()=>{
             if(settled||this.closing||toolController.signal.aborted)throw new HttpError(410,'本轮运行已结束,请发起新一轮对话后重试。','run_finished')
-            this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,this.store.require<Agent>(owner,'agent',agent.id))
+            systemTurn?.check();this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,this.store.require<Agent>(owner,'agent',agent.id))
             if(team)this.teamTools.assertTurn(owner,run.id)
             if(plugins){const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id);if(current.cancelRequested||this.store.require<Run>(owner,'run',run.runId).stopRequested||latest.archived||!['running','waiting'].includes(current.status))throw new HttpError(403,'本轮插件授权已结束','plugin_grant_revoked')}
           }
           if(plugins){
-            pluginLease=await this.plugins!.open(owner,agent,target,toolController.signal,assertActive)
+            pluginLease=await this.plugins!.open(owner,agent,target,toolController.signal,assertActive,execution)
             const warnings=pluginLease.warnings().map(({name,code,message})=>({service:name,code,message}))
             if(warnings.length){resultMessage.serviceWarnings=warnings;resultMessage.visible=true;this.store.saveMessage(owner,resultMessage)}
           }
           toolLease=await createWorkspaceToolLease({
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             workspaceMemory:botCapabilities.memory,
+            ...(execution?{computerPolicy:{mode:execution.mode==='server'?'profile':execution.mode,hostAccess:execution.mode==='server'}}:{}),
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(routines?this.routineTools!.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...environmentCatalog,...(pluginLease?.catalog()??[])],
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(routines?this.routineTools!.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...environmentCatalog,...(systemTurn?.catalog()??[]),...(pluginLease?.catalog()??[])],
             call:async(toolId,args,callId)=>{
               assertActive()
+              if(toolId==='yaoyao_service_request'&&systemTurn)return systemTurn.call(args,toolController.signal)
+              if(toolId.startsWith('desktop_')&&execution){
+                const value=args as Record<string,unknown>
+                if(execution.mode!=='server'||(toolId==='desktop_file_copy'?[value?.sourceHost,value?.targetHost]:[value?.host??'server']).some(host=>!['server','local'].includes(String(host))))throw new HttpError(403,'系统未选择该执行环境','execution_target_forbidden')
+                args=toolId==='desktop_file_copy'?{...value,sourceHost:'server',targetHost:'server'}:{...value,host:'server'}
+              }
               if(this.routineTools?.handles(toolId)) return this.routineTools.call(owner,run.id,toolId,args)
               if(toolId.startsWith('managed_browser_')&&environment.tools.managedBrowser&&this.managedBrowsers){
                 browserTurn??=this.managedBrowsers.openTurn(owner,agent,{workId:run.id,signal:toolController.signal,authorize:assertActive,
@@ -949,7 +968,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             knowledgeRules: knowledge ? BOT_KNOWLEDGE_RULES : undefined,
             memory: memoryDelta(memory, binding.memoryState), noReply: NO_REPLY,
             marker: `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
-            content: text, contentKind: c.kind === 'group' || !!resetReason ? 'history' : 'user', attachmentRefs,
+            content: text+(systemTurn?.description.length?'\n[本轮系统服务目录,仅含元数据]\n'+JSON.stringify(systemTurn.description):''), contentKind: c.kind === 'group' || !!resetReason ? 'history' : 'user', attachmentRefs,
           })
           this.inspector?.record(owner,c.id,{method:'bot.environment',direction:'request',agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId,
             data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id),pluginServices,pluginWarnings:resultMessage.serviceWarnings}})

+ 3 - 1
src/server/workspaceToolLease.ts

@@ -65,7 +65,7 @@ export interface LeaseInput {
   catalog(): Catalog
   call(toolId: string, args: unknown, callId?:string): Promise<unknown>
   onFailure(error: Error): void
-  computerPolicy?: { mode: 'isolated' | 'profile'; hostAccess: boolean }
+  computerPolicy?: { mode: 'isolated' | 'profile' | 'none' | 'virtual'; hostAccess: boolean }
   workspaceMemory?: boolean
 }
 
@@ -191,6 +191,8 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
           ...(input.workspaceMemory ? {workspace_memory: true} : {}) })
         if(input.workspaceMemory&&result.workspace_memory!==true)
           throw new HttpError(409,'请更新工具桥并重启 Hermes Dashboard 服务,以启用 Bot 会话的独立记忆。','workspace_memory_bridge_required')
+        if (input.computerPolicy && ['none','virtual'].includes(input.computerPolicy.mode) && result.execution_policy_version !== 1)
+          throw new HttpError(409,'请更新工具桥以启用强制环境隔离','execution_bridge_required')
         if (input.computerPolicy && result.computer_runtime_version !== 2)
           throw new HttpError(409, '请更新 Hermes 的夭夭工具桥,当前版本尚未接管隔离电脑会话。', 'computer_bridge_upgrade_required')
         if (!bound && result.native_tools !== true)

+ 1 - 0
src/shared/botEnvironment.ts

@@ -37,6 +37,7 @@ export interface DesktopEnvironmentSnapshot {
   hosts: BotDeviceSnapshot[]
 }
 export interface WorkspaceEnvironment {
+  selection?:import('./executionEnvironment.js').ExecutionSelection
   version: 1
   capturedAt: number
   execution: { nodeId: string; profile: string }

+ 1 - 1
src/shared/composeDesktops.ts

@@ -1,5 +1,5 @@
 import type {LocalVmImageKey} from './localVm.js'
-export interface ComposeDesktop {id:string;name:string;socketPath:string;imageKey?:LocalVmImageKey}
+export interface ComposeDesktop {id:string;name:string;socketPath:string;networkSocketPath?:string;imageKey?:LocalVmImageKey}
 export interface ComposeDesktopState {id:string;name:string;online:boolean;ready:boolean;imageKey?:LocalVmImageKey}
 export interface DesktopRelay {
   (id:string,operation:string,body:Record<string,unknown>):Promise<any>

+ 17 - 0
src/shared/executionEnvironment.ts

@@ -0,0 +1,17 @@
+/** These settings select tool execution; they never select the Hermes model. */
+export type ExecutionMode = 'none' | 'server' | 'virtual'
+export interface ExecutionSelection { mode: ExecutionMode; revision: number; credentialSource: 'native' | 'yaoyao' }
+export interface VmProxySettings { enabled: boolean; protocol: 'http' | 'socks5'; host: string; port: number; username: string; password?: string; revision: number }
+export interface TaskEnvironment { variables: Record<string,string>; files: Array<{name:string;content:string;envKey:string}> }
+export interface SystemResourceSummary {
+  id:string; name:string; kind:'variables'|'api'|'codex'; revision:number; agentIds:string[]
+  envKeys:string[]; tokenEnv?:string; baseUrl?:string; configured:boolean
+}
+export interface ExecutionSettingsView {
+  execution:ExecutionSelection
+  proxy:Omit<VmProxySettings,'password'> & {hasPassword:boolean}
+  resources:SystemResourceSummary[]
+  agents:Array<{id:string;name:string;owner:string}>
+  nodes:Array<{id:string;name:string;online:boolean;status:string}>
+  activeTurns:Array<{agentId:string;mode:ExecutionMode;revision:number}>
+}

+ 1 - 1
src/shared/runner.ts

@@ -11,7 +11,7 @@ export interface RunnerRecord {
 }
 export interface RunnerCommand {
   id: string
-  kind: 'browser.invoke' | 'local-vm.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  kind: 'network.configure' | 'network.test' | 'browser.invoke' | 'local-vm.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
   payload: Record<string, unknown>
   expiresAt: number
 }

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

@@ -0,0 +1,29 @@
+import {flushPromises,mount} from '@vue/test-utils'
+import {beforeEach,expect,it,vi} from 'vitest'
+import ExecutionSettingsPanel from '@/components/app/ExecutionSettingsPanel.vue'
+import {apiRequest} from '@/api/client'
+vi.mock('@/api/client',()=>({apiRequest:vi.fn()}))
+const request=vi.mocked(apiRequest)
+const initial=()=>({execution:{mode:'none',revision:1,credentialSource:'yaoyao'},proxy:{enabled:true,protocol:'http',host:'proxy.test',port:8080,username:'',revision:2,hasPassword:true},resources:[],agents:[{id:'fixture-bot',name:'夭夭',owner:'admin'}],nodes:[],activeTurns:[]})
+beforeEach(()=>{request.mockReset();request.mockImplementation(async()=>initial() as never)})
+it('saves an explicit system environment without changing model settings',async()=>{
+  const wrapper=mount(ExecutionSettingsPanel);await flushPromises()
+  await wrapper.findAll('select')[0]!.setValue('virtual');await wrapper.findAll('form')[0]!.trigger('submit');await flushPromises()
+  expect(request).toHaveBeenCalledWith('/api/app/admin/execution/environment',{method:'PUT',body:{mode:'virtual',revision:1}})
+  expect(wrapper.text()).toContain('下一轮任务生效');wrapper.unmount()
+})
+it('keeps proxy credentials write-only and allows explicitly clearing the saved password',async()=>{
+  const wrapper=mount(ExecutionSettingsPanel);await flushPromises()
+  const clear=wrapper.findAll('label').find(l=>l.text().includes('清除已保存的代理密码'))!.get('input')
+  await clear.setValue(true);await wrapper.findAll('form').at(-1)!.trigger('submit');await flushPromises()
+  expect(request).toHaveBeenCalledWith('/api/app/admin/execution/proxy',expect.objectContaining({method:'PUT',body:expect.objectContaining({password:'',revision:2})}))
+  expect(JSON.stringify(request.mock.calls)).not.toContain('hasPassword');wrapper.unmount()
+})
+it('does not grant new resources to every Bot and reports invalid variable JSON without sending it',async()=>{
+  const wrapper=mount(ExecutionSettingsPanel);await flushPromises()
+  await wrapper.findAll('button').find(b=>b.text()==='添加变量或服务授权')!.trigger('click')
+  expect(wrapper.findAll('fieldset input').every(i=>!(i.element as HTMLInputElement).checked)).toBe(true)
+  await wrapper.get('textarea').setValue('{broken')
+  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()
+})

+ 66 - 0
tests/live/managedNetwork.live.test.ts

@@ -0,0 +1,66 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {execFile,spawn} from 'node:child_process'
+import {promisify} from 'node:util'
+import {createServer} from 'node:net'
+import {randomUUID} from 'node:crypto'
+import {ManagedProxy} from '../../src/runner/network/managedProxy'
+import {ContainerComputerProvider} from '../../src/runner/computers/container'
+import {ComposeComputerProvider} from '../../src/runner/computers/compose'
+import {mkdtemp,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+const exec=promisify(execFile)
+const enabled=process.env.HERMES_YAOYAO_LIVE_TEST==='1'
+it.skipIf(!enabled).each(['pipe','compose'] as const)('routes all programs through %s transport and cannot connect directly after proxy failure',async transport=>{
+  const desktopId=randomUUID(),name='yaoyao-network-test-'+desktopId,connections:string[]=[]
+  let reject=false,proxy:ManagedProxy|undefined
+  const upstream=createServer(socket=>{
+    let buffer='',connected=false
+    socket.on('data',chunk=>{buffer+=chunk.toString();if(!buffer.includes('\r\n\r\n'))return
+      if(!connected){connections.push(buffer.split('\r\n')[0]!);buffer='';if(reject){socket.end('HTTP/1.1 503 Unavailable\r\n\r\n');return}connected=true;socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')}
+      else{socket.end('HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\nthrough-proxy');buffer=''}
+    })
+  })
+  await new Promise<void>(r=>upstream.listen(0,'127.0.0.1',r))
+  const port=(upstream.address() as {port:number}).port
+  const guest=(code:string)=>exec('docker',['run','--rm','--network','container:'+name,'--cap-drop','ALL','--entrypoint','python3','localhost/yaoyao/network:1','-c',code],{timeout:15000})
+  const request="import socket; s=socket.create_connection(('1.1.1.1',80),5); s.settimeout(5); s.sendall(b'GET / HTTP/1.1\\r\\nHost: fixture\\r\\n\\r\\n'); print(s.recv(4096).decode())"
+  try{
+    await exec('docker',['run','-d','--name',name,'--network','none','--dns','198.18.0.1','--cap-drop','ALL','--cap-add','NET_ADMIN','--device','/dev/net/tun','--env','YAOYAO_COMPOSE_DESKTOP_ID='+desktopId,'localhost/yaoyao/network:1','--init',...(transport==='compose'?['--socket']:[])])
+    await exec('docker',['exec',name,'sh','-c','i=0; until ip link show tun0 >/dev/null 2>&1; do i=$((i+1)); test "$i" -lt 50 || exit 1; sleep .1; done'])
+    const relay=async(_id:string,operation:string,body:Record<string,unknown>)=>{
+      const code="import socket,http.client,json,sys\np=json.load(sys.stdin)\nc=http.client.HTTPConnection('localhost',timeout=5)\ns=socket.socket(socket.AF_UNIX);s.connect('/run/yaoyao-network/gateway.sock');c.sock=s\nc.request('POST','/'+p['operation'],body=json.dumps(p['body']),headers={'Content-Type':'application/json'})\nr=c.getresponse();data=r.read();assert r.status==200,data;print(data.decode())"
+      return await new Promise<any>((resolve,reject)=>{const child=spawn('docker',['exec','-i',name,'python3','-c',code],{stdio:'pipe'});let out='',err='';child.stdout.on('data',b=>out+=b);child.stderr.on('data',b=>err+=b);child.once('error',reject);child.once('close',status=>{try{if(status)throw new Error(err);resolve(JSON.parse(out))}catch(error){reject(error)}});child.stdin.end(JSON.stringify({operation,body:{...body,desktopId}}))})
+    }
+    if(transport==='compose')await exec('docker',['exec',name,'sh','-c','i=0; until test -S /run/yaoyao-network/gateway.sock; do i=$((i+1)); test "$i" -lt 50 || exit 1; sleep .1; done'])
+    const provider=transport==='compose'?new ComposeComputerProvider(randomUUID(),'/tmp',relay):{openNetworkPipe:async()=>spawn('docker',['exec','-i',name,'python3','/usr/local/libexec/yaoyao/network_gateway.py'],{stdio:'pipe'})} as any
+    proxy=await ManagedProxy.start(provider,{id:randomUUID(),ownerKey:'fixture',imageId:'sha256:'+'a'.repeat(64)}, {enabled:true,protocol:'http',host:'127.0.0.1',port,username:'',revision:1},()=>{})
+    expect((await guest(request)).stdout).toContain('through-proxy')
+    expect(connections).toEqual(['CONNECT 1.1.1.1:80 HTTP/1.1'])
+    expect((await guest("import socket; print(socket.gethostbyname('example.com'))")).stdout.trim()).toMatch(/^198\.19\./)
+    await expect(guest("import socket; socket.create_connection(('169.254.169.254',80),2).sendall(b'GET / HTTP/1.0\\r\\n\\r\\n'); raise SystemExit(1)")).rejects.toThrow()
+    reject=true
+    const failed=await guest(request).catch(()=>({stdout:''}))
+    expect(failed.stdout).not.toContain('through-proxy')
+    expect(connections.length).toBe(2)
+    const interfaces=await exec('docker',['exec',name,'ip','-j','link'])
+    expect(JSON.parse(interfaces.stdout).map((i:any)=>i.ifname).sort()).toEqual(['lo','tun0'])
+  }finally{await proxy?.close();await exec('docker',['rm','-f',name]).catch(()=>{});await new Promise<void>(r=>upstream.close(()=>r()))}
+},60000)
+
+it.skipIf(!enabled||!process.env.YAOYAO_NETWORK_TEST_DESKTOP_IMAGE)('prepares a real managed desktop with a separate gateway and removes only its own resources',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-network-provider-')),provider=new ContainerComputerProvider('docker',randomUUID(),home)
+  const spec={id:randomUUID(),ownerKey:'network-provider-fixture',imageId:process.env.YAOYAO_NETWORK_TEST_DESKTOP_IMAGE!,network:'managed-proxy' as const}
+  try{
+    const state=await provider.ensure(spec,()=>{})
+    expect(state.running).toBe(true)
+    const result=await provider.execute(spec,['python3','-c','import os,socket; print(os.getuid()); print(open("/etc/resolv.conf").read())'],{authorize:()=>{}})
+    expect(result.stdout).toContain('1000');expect(result.stdout).toContain('198.18.0.1')
+    await expect(provider.execute(spec,['sh','-c','ip link add bypass type dummy'],{authorize:()=>{},user:'root'})).rejects.toThrow()
+    const namespace=JSON.parse((await exec('docker',['inspect',state.containerId])).stdout)[0].HostConfig.NetworkMode
+    expect(namespace).toMatch(/^container:/)
+    await provider.remove(spec)
+    await expect(exec('docker',['inspect',namespace.slice('container:'.length)])).rejects.toThrow()
+  }finally{await provider.remove(spec).catch(()=>{});await rm(home,{recursive:true,force:true})}
+},60000)

+ 84 - 0
tests/server/executionProxyRuntime.test.ts

@@ -0,0 +1,84 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerRuntime,type ComputerGateway} from '../../src/runner/worker/gateway'
+import {ManagedProxy} from '../../src/runner/network/managedProxy'
+import type {VmProxySettings} from '../../src/shared/executionEnvironment'
+
+let home:string,db:DatabaseSync,runtime:ComputerRuntime
+const proxy=(revision:number):VmProxySettings=>({enabled:true,protocol:'http',host:'proxy.invalid',port:3128,username:'',revision})
+beforeEach(async()=>{
+  home=mkdtempSync(join(tmpdir(),'yaoyao-proxy-runtime-'));db=new DatabaseSync(':memory:')
+  runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:[],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'python3',hermesSource:home,network:'none'}},home)
+  await runtime.ready
+})
+afterEach(async()=>{await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();rmSync(home,{recursive:true,force:true})})
+
+it('serializes updates, cleans up with the old network and rejects new leases while switching',async()=>{
+  let unblock!:()=>void
+  const blocked=new Promise<void>(resolve=>{unblock=resolve})
+  const close=vi.fn(async()=>{expect(runtime.network).toBe('none');await blocked;runtime.gateways.clear()})
+  runtime.gateways.set('fixture',new Set([{close} as unknown as ComputerGateway]))
+  const verify=vi.spyOn(runtime.provider,'verifyNetwork').mockResolvedValue()
+  const first=runtime.updateProxy(proxy(1)),second=runtime.updateProxy(proxy(1))
+  await vi.waitFor(()=>expect(close).toHaveBeenCalledTimes(1))
+  expect(()=>runtime.assertNetworkReady()).toThrow('尚未就绪')
+  expect(verify).not.toHaveBeenCalled()
+  unblock();await Promise.all([first,second])
+  expect(verify).toHaveBeenCalledTimes(1)
+  expect(runtime.network).toBe('managed-proxy')
+  expect(()=>runtime.assertNetworkReady()).not.toThrow()
+  await runtime.updateProxy(proxy(0))
+  expect(runtime.proxySettings?.revision).toBe(1)
+})
+
+it('keeps a failed proxy preparation closed and allows retry of the same revision',async()=>{
+  const verify=vi.spyOn(runtime.provider,'verifyNetwork').mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue()
+  await expect(runtime.updateProxy(proxy(1))).rejects.toThrow('unavailable')
+  expect(runtime.network).toBe('managed-proxy')
+  expect(()=>runtime.assertNetworkReady()).toThrow()
+  await runtime.updateProxy(proxy(1))
+  expect(verify).toHaveBeenCalledTimes(2)
+  expect(()=>runtime.assertNetworkReady()).not.toThrow()
+})
+
+it('does not share a failed network channel with the next task',async()=>{
+  vi.spyOn(runtime.provider,'verifyNetwork').mockResolvedValue()
+  await runtime.updateProxy(proxy(1))
+  let stopped!:()=>void
+  const old={close:vi.fn(async()=>{})},next={close:vi.fn(async()=>{})}
+  const start=vi.spyOn(ManagedProxy,'start').mockImplementationOnce(async(_provider,_spec,_proxy,_authorize,onClose)=>{stopped=onClose!;return old as unknown as ManagedProxy}).mockResolvedValueOnce(next as unknown as ManagedProxy)
+  const spec={id:randomUUID(),ownerKey:'fixture',imageId:'sha256:'+'a'.repeat(64),cwd:'/workspace',network:'managed-proxy' as const}
+  const first=await runtime.acquireProxy(spec,()=>{})
+  stopped()
+  const second=await runtime.acquireProxy(spec,()=>{})
+  expect(start).toHaveBeenCalledTimes(2)
+  await first.release()
+  expect(next.close).not.toHaveBeenCalled()
+  await second.release()
+  expect(next.close).toHaveBeenCalledTimes(1)
+})
+
+it('uses the same proxy for human takeover and ends old control leases when the proxy changes',async()=>{
+  vi.spyOn(runtime.provider,'verifyNetwork').mockResolvedValue()
+  vi.spyOn(runtime,'resolveWorkspace').mockResolvedValue({type:'resolved',cwd:'/workspace',configuredCwd:'/workspace'})
+  const ensure=vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',running:true,workspace:home,isolation:'container'})
+  vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
+  const close=vi.fn(async()=>{})
+  vi.spyOn(ManagedProxy,'start').mockResolvedValue({close} as unknown as ManagedProxy)
+  await runtime.updateProxy(proxy(1))
+  const meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'fixture'},controlId=randomUUID()
+  await runtime.controls.take(meta,'default',controlId,async()=>{})
+  await vi.waitFor(async()=>expect((await runtime.controls.status(meta)).mode).toBe('human'))
+  expect(ensure.mock.calls[0]?.[0].network).toBe('managed-proxy')
+  await runtime.updateProxy(proxy(2))
+  expect(close).toHaveBeenCalled()
+  expect((await runtime.controls.status(meta)).controlId).toBeUndefined()
+  expect(runtime.pool.hasHolders(meta.ownerKey,meta.environmentId)).toBe(false)
+  await runtime.controls.take(meta,'default',randomUUID(),async()=>{})
+  await vi.waitFor(async()=>expect((await runtime.controls.status(meta)).mode).toBe('human'))
+})

+ 109 - 0
tests/server/executionSettings.test.ts

@@ -0,0 +1,109 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync,readFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {loadServerConfig} from '../../src/server/config'
+import {ExecutionSettings} from '../../src/server/executionSettings'
+import {buildWorkspaceEnvironment,workspaceEnvironmentTools} from '../../src/server/workspaceEnvironment'
+import {parseHostTools} from '../../src/server/hostToolSettings'
+import {TASK_COMMAND,redactTaskOutput} from '../../src/runner/worker/taskEnvironment'
+import {spawnSync} from 'node:child_process'
+import Koa from 'koa'
+import request from 'supertest'
+import {LocalAuthStore} from '../../src/server/localAuth'
+let home:string,store:WorkspaceStore,settings:ExecutionSettings
+const id=randomUUID(),other=randomUUID()
+beforeEach(()=>{
+  home=mkdtempSync(join(tmpdir(),'yaoyao-execution-'));store=new WorkspaceStore(home)
+  const config=loadServerConfig({HERMES_YAOYAO_HOME:home,HERMES_YAOYAO_UPSTREAM:'http://127.0.0.1:9119'})
+  const nodes=new WorkspaceNodes(store,config,{} as any,randomUUID())
+  settings=new ExecutionSettings(store,nodes,{isUserActive:()=>true} as any)
+  for(const [owner,agentId] of [['alice',id],['bob',other]])store.put(owner!,'agent',agentId!,{id:agentId,name:owner,nodeId:'local',profile:'default',archived:false})
+})
+afterEach(()=>{vi.restoreAllMocks();store.db.close();rmSync(home,{recursive:true,force:true})})
+it('defaults a new installation to no environment; shared resources require explicit Bot grants',()=>{
+  expect(settings.selection()).toMatchObject({mode:'none',credentialSource:'yaoyao'})
+  const resource=settings.saveResource({name:'API',kind:'api',baseUrl:'https://api.example.test/v1/',token:'fixture-sensitive-token',tokenEnv:'XAI_API_KEY',agentIds:[id]})
+  expect(settings.openTurn('alice',id,settings.selection()).taskEnvironment().variables.XAI_API_KEY).toBe('fixture-sensitive-token')
+  expect(settings.openTurn('bob',other,settings.selection()).taskEnvironment().variables).toEqual({})
+  expect(JSON.stringify(settings.view())).not.toContain('fixture-sensitive-token')
+  expect(JSON.stringify(store.list('_system','execution-resource'))).not.toContain('fixture-sensitive-token')
+  expect(()=>settings.saveResource({...resource,envKeys:undefined,configured:undefined,revision:resource.revision} as any)).toThrow() // public summaries cannot overwrite secrets
+})
+it('never supplies Yaoyao credentials to the native environment; revokes existing turns on changes',()=>{
+  const resource=settings.saveResource({name:'Variables',kind:'variables',variables:{SERVICE_SECRET:'fixture-secret'},agentIds:[id]})
+  const turn=settings.openTurn('alice',id,settings.selection())
+  settings.saveSelection({mode:'server',revision:1})
+  expect(settings.openTurn('alice',id,settings.selection()).taskEnvironment()).toEqual({variables:{},files:[]})
+  expect(turn.taskEnvironment().variables.SERVICE_SECRET).toBe('fixture-secret') // selection is a turn snapshot
+  settings.removeResource(resource.id)
+  expect(()=>turn.taskEnvironment()).toThrow('授权已变化')
+})
+it('preserves write-only secrets, rejects conflicting variables and internal environment overrides',()=>{
+  const r=settings.saveResource({name:'Variables',kind:'variables',variables:{EXTERNAL_KEY:'saved'},agentIds:[id]})
+  settings.saveResource({name:'Renamed',kind:'variables',agentIds:[id],revision:r.revision},r.id)
+  expect(settings.openTurn('alice',id,settings.selection()).taskEnvironment().variables.EXTERNAL_KEY).toBe('saved')
+  expect(()=>settings.saveResource({name:'Conflict',kind:'variables',variables:{EXTERNAL_KEY:'new'},agentIds:[id]})).toThrow('重复')
+  for(const key of ['YAOYAO_ADMIN_TOKEN','NODE_OPTIONS','LD_PRELOAD','HTTPS_PROXY','CODEX_HOME'])expect(()=>settings.saveResource({name:'unsafe',kind:'variables',variables:{[key]:'bad'},agentIds:[id]})).toThrow()
+})
+it('binds API requests to one configured origin/path and redacts a credential echoed in a response',async()=>{
+  const r=settings.saveResource({name:'Grok API',kind:'api',baseUrl:'https://api.example.test/v1/',token:'secret-fixture',agentIds:[id]})
+  const fetcher=vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response('secret-fixture'))
+  const turn=settings.openTurn('alice',id,settings.selection())
+  expect(await turn.call({serviceId:r.id,path:'images/generations',method:'POST',body:{prompt:'test'}},new AbortController().signal)).toEqual({status:200,body:'[已隐藏授权]'})
+  expect(fetcher.mock.calls[0]?.[1]).toMatchObject({headers:{authorization:'Bearer secret-fixture'},redirect:'error'})
+  for(const path of ['https://evil.test','../admin','/%2e%2e/admin','..%2fadmin','..%5cadmin','%252e%252e/admin'])await expect(turn.call({serviceId:r.id,path,method:'GET'},new AbortController().signal)).rejects.toThrow()
+})
+it('requires an administrator on every settings endpoint and never returns credential values',async()=>{
+  const auth=Object.create(LocalAuthStore.prototype)
+  auth.current=(ctx:Koa.Context)=>ctx.get('x-role')?{id:'fixture',role:ctx.get('x-role'),mustChangePassword:false}:undefined
+  settings.auth.requireAdmin=ctx=>auth.requireAdmin(ctx)
+  settings.saveResource({name:'Private',kind:'variables',variables:{API_KEY:'private-fixture-key'},agentIds:[id]})
+  const app=new Koa(),router=settings.router()
+  app.use(async(ctx,next)=>{try{await next()}catch(error){ctx.status=(error as any).status??500;ctx.body={error:'rejected'}}})
+  app.use(router.routes())
+  for(const role of ['', 'member']){
+    const status=role?403:401
+    for(const [method,path] of [['get',''],['put','/environment'],['put','/proxy'],['post','/proxy/test'],['post','/resources'],['put','/resources/'+randomUUID()],['delete','/resources/'+randomUUID()]]){
+      await (request(app.callback()) as any)[method!]('/api/app/admin/execution'+path).set('x-role',role).expect(status)
+    }
+  }
+  const response=await request(app.callback()).get('/api/app/admin/execution').set('x-role','admin').expect(200)
+  expect(response.headers['cache-control']).toBe('no-store')
+  expect(JSON.stringify(response.body)).not.toContain('private-fixture-key')
+})
+it('requires every co-resident Bot to be granted credentials before injecting them into a shared VM',()=>{
+  const environment=randomUUID()
+  for(const [owner,agentId] of [['alice',id],['bob',other]])store.put(owner!,'agent',agentId!,{...store.require<any>(owner!,'agent',agentId!),computerEnvironmentId:environment})
+  const resource=settings.saveResource({name:'Shared',kind:'variables',variables:{API_KEY:'fixture'},agentIds:[id]})
+  expect(()=>settings.openTurn('alice',id,settings.selection()).taskEnvironment()).toThrow('共享虚拟环境')
+  settings.saveResource({name:'Shared',kind:'variables',agentIds:[id,other],revision:resource.revision},resource.id)
+  expect(settings.openTurn('alice',id,settings.selection()).taskEnvironment().variables.API_KEY).toBe('fixture')
+})
+it('migrates existing installations to the server environment without promoting account credentials',()=>{
+  store.remove('_system','execution-settings','selection')
+  store.put('alice','account-authorization','fixture',{token:'account-private-fixture'})
+  const migrated=new ExecutionSettings(store,settings.nodes,settings.auth)
+  expect(migrated.selection()).toMatchObject({mode:'server',credentialSource:'native'})
+  expect(migrated.view().resources).toEqual([])
+  expect(store.get('alice','account-authorization','fixture')).toEqual({token:'account-private-fixture'})
+})
+it('isolates credential files per command and removes them on nonzero exits',()=>{
+  const environment={variables:{EXTERNAL_KEY:'fixture-secret'},files:[{name:'auth.json',content:'{"tokens":{"access_token":"codex-fixture-secret"}}',envKey:'CODEX_HOME'}]}
+  const result=spawnSync('python3',['-c',TASK_COMMAND],{input:JSON.stringify({environment,command:'printf "%s\\n" "$EXTERNAL_KEY" "$CODEX_HOME"; cat "$CODEX_HOME/auth.json"; exit 7'}),encoding:'utf8'})
+  expect(result.status).toBe(7);expect(result.stdout).toContain('fixture-secret')
+  const path=result.stdout.split('\n')[1]!
+  expect(()=>readFileSync(join(path,'auth.json'))).toThrow()
+  expect(redactTaskOutput({stdout:result.stdout},environment).stdout).not.toContain('fixture-secret')
+})
+it.each(['none','server','virtual'] as const)('limits the tool catalogue to %s',mode=>{
+  const agent=store.require<any>('alice','agent',id)
+  const env=buildWorkspaceEnvironment({agent,globals:parseHostTools({managedBrowser:true}),execution:{mode,revision:1,credentialSource:mode==='server'?'native':'yaoyao'},desktop:{capturedAt:0,hosts:[]},bridge:true,cloud:true,plugins:false,managedBrowser:true})
+  const names=workspaceEnvironmentTools(env).map(t=>t.id)
+  expect(names.includes('computer_shell')).toBe(mode==='virtual')
+  expect(names).not.toContain('cloud_computer_shell');expect(names).not.toContain('managed_browser_open')
+})

+ 1 - 0
tests/server/localVm.test.ts

@@ -47,6 +47,7 @@ it('prepares one managed image, verifies the desktop, applies it and persists it
   await manager.prepare(id,check)
   await vi.waitFor(async()=>expect((await manager.status()).job?.state).toBe('complete'))
   expect(f.runtime.config.imageId).toBe(imageId)
+  expect(manager.images.run).toHaveBeenCalledWith('docker',expect.arrayContaining(['build','--file',expect.stringContaining('Dockerfile.network'),'--tag','localhost/yaoyao/network:1']))
   expect(f.runtime.provider.capture).toHaveBeenCalledOnce()
   expect(f.running.size).toBe(0)
   await manager.prepare(id,check)

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

@@ -73,6 +73,13 @@ async function channel(){
   await gateway.connect();const session=await gateway.rpc('session.create',{profile:'default'})
   return {gateway,session}
 }
+it('blocks old Runner control paths when the global VM proxy is enabled',async()=>{
+  hub.executionSettings={proxy:()=>({enabled:true,revision:1})} as any
+  hub.controlAllowed=()=>true
+  for(const payload of [{op:'take'},{op:'input'},{op:'lifecycle',action:'start'}])await expect((hub as any).request(config.runnerId,'computer.control',payload)).rejects.toMatchObject({code:'proxy_runner_required'})
+  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('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

+ 65 - 0
tests/server/upstreamProxy.test.ts

@@ -0,0 +1,65 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {connect,createServer,type Server,type Socket} from 'node:net'
+import {createServer as createHttpsServer} from 'node:https'
+import {execFile,execFileSync} from 'node:child_process'
+import {mkdtempSync,readFileSync,writeFileSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {promisify} from 'node:util'
+import {proxySocket} from '../../src/runner/network/upstreamProxy'
+import type {VmProxySettings} from '../../src/shared/executionEnvironment'
+const listen=async(server:Server)=>{await new Promise<void>(r=>server.listen(0,'127.0.0.1',r));return (server.address() as {port:number}).port}
+it('authenticates HTTP CONNECT, pins the destination and preserves bytes received with the header',async()=>{
+  let header='';const server=createServer(socket=>socket.once('data',data=>{header=data.toString();socket.write('HTTP/1.1 200 Connection Established\r\n\r\nfirst-byte')}))
+  const port=await listen(server)
+  try{const socket=await proxySocket({enabled:true,protocol:'http',host:'127.0.0.1',port,username:'user',password:'password',revision:1},'1.1.1.1',443)
+    const data=await new Promise<string>(r=>{socket.once('data',d=>r(d.toString()));socket.resume()})
+    expect(data).toBe('first-byte');expect(header).toContain('CONNECT 1.1.1.1:443');expect(header).toContain(Buffer.from('user:password').toString('base64'));socket.destroy()
+  }finally{await new Promise<void>(r=>server.close(()=>r()))}
+})
+it('fails closed when the proxy refuses authentication',async()=>{
+  const server=createServer(socket=>socket.once('data',()=>socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'))),port=await listen(server)
+  try{await expect(proxySocket({enabled:true,protocol:'http',host:'127.0.0.1',port,username:'',revision:1},'1.1.1.1',443)).rejects.toThrow('代理连接或认证失败')}finally{await new Promise<void>(r=>server.close(()=>r()))}
+})
+it('handles authenticated SOCKS5 and never offers anonymous downgrade',async()=>{
+  const frames:Buffer[]=[]
+  const server=createServer(socket=>{let phase=0;socket.on('data',data=>{frames.push(data);if(phase++===0)socket.write(Buffer.from([5,2]));else if(phase===2)socket.write(Buffer.from([1,0]));else socket.write(Buffer.from([5,0,0,1,127,0,0,1,0,80]))})}),port=await listen(server)
+  try{const socket=await proxySocket({enabled:true,protocol:'socks5',host:'127.0.0.1',port,username:'u',password:'p',revision:1} satisfies VmProxySettings,'1.1.1.1',443);expect(frames[0]).toEqual(Buffer.from([5,1,2]));expect(frames[2]).toEqual(Buffer.from([5,1,0,1,1,1,1,1,1,187]));socket.destroy()}finally{await new Promise<void>(r=>server.close(()=>r()))}
+})
+
+it('resolves IPv4 and IPv6 through authenticated CONNECT with verified DNS-over-HTTPS',async()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-doh-')),key=join(home,'key.pem'),cert=join(home,'cert.pem'),config=join(home,'openssl.cnf')
+  writeFileSync(config,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=dns.google\n[ext]\nsubjectAltName=DNS:dns.google\nbasicConstraints=critical,CA:TRUE\n')
+  execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',config],{stdio:'ignore'})
+  const requests:string[]=[],headers:string[]=[],sockets=new Set<Socket>()
+  const https=createHttpsServer({key:readFileSync(key),cert:readFileSync(cert)},(req,res)=>{
+    requests.push(req.url!)
+    const type=Number(new URL(req.url!,'https://dns.google').searchParams.get('type'))
+    res.setHeader('content-type','application/json');res.end(JSON.stringify({Status:0,Answer:[{type,data:type===1?'93.184.216.34':'2606:4700:4700::1111'}]}))
+  })
+  const tlsPort=await listen(https)
+  const proxy=createServer(socket=>{
+    sockets.add(socket);socket.once('close',()=>sockets.delete(socket))
+    let header=''
+    const read=(chunk:Buffer)=>{
+      header+=chunk.toString();if(!header.endsWith('\r\n\r\n'))return
+      socket.off('data',read);headers.push(header)
+      const target=connect(tlsPort,'127.0.0.1',()=>{socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');socket.pipe(target);target.pipe(socket)})
+      sockets.add(target);target.once('close',()=>sockets.delete(target));target.on('error',()=>socket.destroy());socket.once('close',()=>target.destroy())
+    }
+    socket.on('data',read)
+  })
+  try{
+    const port=await listen(proxy)
+    const settings={enabled:true,protocol:'http',host:'127.0.0.1',port,username:'fixture',password:'proxy-fixture',revision:1}
+    const source=`import {proxyLookup} from ${JSON.stringify(new URL('../../src/runner/network/upstreamProxy.ts',import.meta.url).href)}; console.log(JSON.stringify(await proxyLookup(${JSON.stringify(settings)})('service.example.test')))`
+    // Trust only this fixture certificate in a separate process; production TLS
+    // verification and the parent test process's trust store remain unchanged.
+    const result=await promisify(execFile)(process.execPath,['--import','tsx','--input-type=module','-e',source],{env:{...process.env,NODE_EXTRA_CA_CERTS:cert},timeout:15000})
+    expect(JSON.parse(result.stdout)).toEqual([{address:'93.184.216.34',family:4},{address:'2606:4700:4700::1111',family:6}])
+    expect(requests.sort()).toEqual(['/resolve?name=service.example.test&type=1','/resolve?name=service.example.test&type=28'])
+    expect(headers).toHaveLength(2)
+    for(const header of headers){expect(header).toContain('CONNECT 8.8.8.8:443');expect(header).toContain('Proxy-Authorization: Basic '+Buffer.from('fixture:proxy-fixture').toString('base64'))}
+  }finally{for(const socket of sockets)socket.destroy();await Promise.all([new Promise<void>(r=>proxy.close(()=>r())),new Promise<void>(r=>https.close(()=>r()))]);rmSync(home,{recursive:true,force:true})}
+},20000)

+ 6 - 0
tests/server/workspaceEnvironment.test.ts

@@ -45,3 +45,9 @@ it('does not expose computer tools for an unavailable Bot or missing bridge',()=
  expect(workspaceEnvironmentTools(env)).toEqual([])
  expect(env.virtual.vm.status).toBe('bridge_unavailable')
 })
+it('does not substitute the Yaoyao desktop for a remote Hermes server',()=>{
+ const input={agent,globals:parseHostTools({}),desktop,bridge:true,cloud:false,plugins:false,execution:{mode:'server' as const,revision:1,credentialSource:'native' as const}}
+ expect(workspaceEnvironmentTools(buildWorkspaceEnvironment(input))).toEqual([])
+ expect(workspaceEnvironmentTools(buildWorkspaceEnvironment({...input,serverDesktop:true})).map(t=>t.id)).toContain('desktop_environment_view')
+ expect(buildWorkspaceEnvironment({...input,serverDesktop:true,execution:{mode:'virtual',revision:1,credentialSource:'yaoyao'}}).desktop.hosts).toEqual([])
+})

+ 2 - 0
tests/server/workspaceStopUi.test.ts

@@ -83,6 +83,8 @@ it('stops a live browser conversation immediately when the runner never confirms
       ctx.body = readFileSync(join(dist, 'index.html'))
     }
   })
+  // This fixture deliberately tests the native runtime without a tool bridge.
+  runtime.workspace.put('_system','execution-settings','selection',{mode:'server',revision:2})
   const owner = 'test-admin'
   const nodes = (runtime.workspaceRuntime as unknown as { nodes: { target: (owner: string, nodeId: string) => any; requireSource: () => void } }).nodes
   const target = nodes.target(owner, 'local')