瀏覽代碼

为隔离电脑接入受控公网代理

LSamien 1 月之前
父節點
當前提交
d0461e06e1

+ 3 - 0
docs/agent-platform-implementation.md

@@ -77,3 +77,6 @@
 - 全目标仍未完成:优先继续受控联网、镜像准备/升级、临时助手、屏幕查看与人工接管、共享环境、移动端入口及后台/更新交付项。不能将 Worker 链路完成视作完整平台交付。
 - 全目标仍未完成:优先继续受控联网、镜像准备/升级、临时助手、屏幕查看与人工接管、共享环境、移动端入口及后台/更新交付项。不能将 Worker 链路完成视作完整平台交付。
 - 本阶段最终检查:类型检查与 719 项全量测试通过,4 项真实环境测试默认按需跳过;打包 Runner 的真实 Worker 测试 2 项通过,包含原生宿主工具拒绝、分块二进制产物、下载、cwd 变更和取消延迟写入。Web 4 项入口测试及桌面 4 项生命周期测试通过;窄屏弹窗截图已核对。测试电脑容器全部清理。
 - 本阶段最终检查:类型检查与 719 项全量测试通过,4 项真实环境测试默认按需跳过;打包 Runner 的真实 Worker 测试 2 项通过,包含原生宿主工具拒绝、分块二进制产物、下载、cwd 变更和取消延迟写入。Web 4 项入口测试及桌面 4 项生命周期测试通过;窄屏弹窗截图已核对。测试电脑容器全部清理。
 - 停止与撤权分开处理:撤销执行授权不阻断已绑定任务的清理,只允许清理原会话;清理通道不能提交新工作。电脑恢复首先核对并停止原持有者,旧容器身份不会被新任务复用。
 - 停止与撤权分开处理:撤销执行授权不阻断已绑定任务的清理,只允许清理原会话;清理通道不能提交新工作。电脑恢复首先核对并停止原持有者,旧容器身份不会被新任务复用。
+
+- 公网代理阶段:保持电脑无外部网卡,通过专属进程管道建立公网 80/443 连接,拒绝保留地址、内网、宿主网卡地址及混合 DNS 结果。支持命令环境变量与浏览器代理策略。真实 Docker 的 curl/Firefox HTTPS、截图、宿主拒绝和关停测试通过;完整打包 Runner 的 Worker 公网访问测试通过。详见 `docs/computer-network.md`。
+- 联网阶段最终验证:类型检查和 756 项全量测试通过,5 项真实环境测试默认跳过;地址/代理与 BFF 定向 50 项通过,Web 4 项流程测试通过。真实 Firefox 公网页面截图已核对;公网 Worker 的打包 Runner 验收通过。电脑验收容器全部清理,完整目标仍未完成。

+ 35 - 0
docs/computer-network.md

@@ -0,0 +1,35 @@
+# 隔离电脑的公网代理
+
+Runner 可为隔离电脑启用 `public-proxy` 网络策略。现有配置未填写策略时仍为 `none`,不会自动扩大联网权限。
+
+电脑继续使用 `--network none`,不发布网络端口。guest 内的 HTTP 代理只监听 `127.0.0.1:3128`,通过该电脑专属的进程管道,把连接请求交给 Runner。公网 TCP 连接由 Runner 建立,HTTPS 内容保持端到端 TLS,代理不添加宿主 Cookie、模型密钥或账号凭据。
+
+## 地址与授权边界
+
+- 只允许公网 HTTP/HTTPS 的 80、443 端口。
+- 按 [IANA IPv4 特殊用途注册表](https://www.iana.org/assignments/iana-ipv4-special-registry/) 和 [IPv6 特殊用途注册表](https://www.iana.org/assignments/iana-ipv6-special-registry/) 保守拒绝私有、回环、链路本地、共享地址、文档和转换等特殊用途范围;IPv6 只接受普通全局单播范围。
+- 拒绝 Runner 本机网卡地址、含凭据的地址和不合法 authority;非常规 IPv4 写法先规范化再检查。
+- 每个新连接重新解析 DNS,任何一个解析结果不符合策略则拒绝。实际连接固定到已检查的 IP,后续 DNS 变化不能替换目标。
+- 建立连接前后、传输期间和周期检查都核验任务租约;创建连接时另外检查控制面授权。停止任务会关闭该电脑的代理和连接。
+- 每个代理最多 16 个外部连接、256 MiB 双向流量,有队列大小和空闲超时限制。拒绝一个目标不会为它打开任何宿主 socket。
+
+这是一项保守的公网网页能力,不是通用 VPN、SSH、内网代理或无限流量通道。关闭/移除代理设置只会失去外网连接,不会获得直接访问宿主或内网的能力。
+
+## 客户端配置
+
+电脑创建时设置 HTTP/HTTPS 代理环境变量,供 curl、Python 等命令使用。Runner 在 guest 内配置 Chromium/Chrome 和 Firefox 的代理策略,浏览器仍可访问自己电脑的 loopback 服务。直接连接外部地址仍受无网络命名空间限制。
+
+Web 的执行节点配置新增“联网范围”:
+
+- 公网网页(HTTP / HTTPS)
+- 不联网
+
+该选择保存在节点的私有配置中;Agent 的执行环境仍需选择“隔离电脑”。
+
+## 验收
+
+- `tests/server/publicNetwork.test.ts` 验证地址范围、非常规 IP 写法、DNS 混合结果和重绑定、本机地址、异步撤权、连接配额及真实 socket 字节传输。
+- `tests/live/network.live.test.ts` 在新建的真实 Docker 电脑中验证仅存在 loopback 网卡,经代理读取 HTTPS 页面、Firefox 打开页面并产生截图、拒绝宿主地址、关闭代理后外网不可达。截图保存在 `test-results/network/browser.png`。
+- 启用 `YAOYAO_WORKER_PUBLIC_NETWORK=1` 的 Worker 验收经过完整控制面与打包 Runner,再由真实 guest 访问公网。
+
+联网仍依赖执行节点自身能连接目标公网服务。网络代理不改变电脑工具、文件和任务的归属;本轮测试只使用隔离数据与公开示例页面。

+ 2 - 2
docs/computer-provider.md

@@ -9,7 +9,7 @@
 - 环境运行在容器隔离层。macOS 上 Docker/Podman 的底层 Linux 虚拟机由运行时提供,每个环境不是一台独占内核的 VM。
 - 环境运行在容器隔离层。macOS 上 Docker/Podman 的底层 Linux 虚拟机由运行时提供,每个环境不是一台独占内核的 VM。
 - 仅挂载该环境的持久工作目录;不挂载 Docker socket、宿主 HOME、Hermes 配置或凭据。命令固定以 guest UID/GID 1000 执行,工作目录为 `/home/cua/workspace`。
 - 仅挂载该环境的持久工作目录;不挂载 Docker socket、宿主 HOME、Hermes 配置或凭据。命令固定以 guest UID/GID 1000 执行,工作目录为 `/home/cua/workspace`。
 - 固定独立 IPC/cgroup 命名空间、禁止提权、收紧 capability,并设置 CPU、内存、swap 与进程数上限。每次执行前核对运行实例、镜像、归属、挂载与约束。
 - 固定独立 IPC/cgroup 命名空间、禁止提权、收紧 capability,并设置 CPU、内存、swap 与进程数上限。每次执行前核对运行实例、镜像、归属、挂载与约束。
-- 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。联网代理属于下一阶段。
+- 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。可通过专属进程通道启用受控公网代理,仍不增加外部网卡或端口。详见 `docs/computer-network.md`。
 - 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。后续查看/接管接口仍需账号鉴权和独立的控制租约。
 - 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。后续查看/接管接口仍需账号鉴权和独立的控制租约。
 - 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
 - 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
 - 删除容器保留工作目录,重建后可继续读取工作文件。工作区快照、恢复、显式删除及共享桌面尚未实现。
 - 删除容器保留工作目录,重建后可继续读取工作文件。工作区快照、恢复、显式删除及共享桌面尚未实现。
@@ -31,4 +31,4 @@ YAOYAO_COMPUTER_IMAGE=sha256:本机已验证镜像的完整ID \
   npx vitest run tests/live/computer.live.test.ts
   npx vitest run tests/live/computer.live.test.ts
 ```
 ```
 
 
-本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。独立镜像构建/交付、联网、跨端电脑面板和共享环境仍待完成,不能以本测试替代整个 VM 功能验收。
+本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。独立镜像构建/交付、跨端电脑面板和共享环境仍待完成,不能以本测试替代整个 VM 功能验收。

+ 1 - 1
docs/computer-worker.md

@@ -56,4 +56,4 @@ Worker 复用已安装 Hermes 的模型循环。它截获整个工具执行入
 
 
 ## 仍需完成
 ## 仍需完成
 
 
-电脑目前不联网。受控公网代理、镜像安装/更新、临时助手、屏幕查看/接管、可信团队共享环境,以及移动端执行方式设置等仍属于完整目标的后续工作。现有移动端已可使用服务端创建的隔离 Agent、接收结果和文件,但不能据此宣称完整电脑界面已交付。
+电脑可按节点配置使用受控公网代理,详见 `docs/computer-network.md`。镜像安装/更新、临时助手、屏幕查看/接管、可信团队共享环境,以及移动端执行方式设置等仍属于完整目标的后续工作。现有移动端已可使用服务端创建的隔离 Agent、接收结果和文件,但不能据此宣称完整电脑界面已交付。

+ 1 - 1
docs/runner.md

@@ -2,7 +2,7 @@
 
 
 Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于 Docker、另一台电脑或远程服务器时,将 Bot 会话和本轮组队工具交给 Hermes 所在电脑执行。Runner 源码、构建入口和管理页面均位于 hermes-yaoyao。
 Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于 Docker、另一台电脑或远程服务器时,将 Bot 会话和本轮组队工具交给 Hermes 所在电脑执行。Runner 源码、构建入口和管理页面均位于 hermes-yaoyao。
 
 
-普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。具体链路与验证边界见 `docs/computer-worker.md`;联网、屏幕接管与共享环境仍待完成。
+普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。具体链路与验证边界见 `docs/computer-worker.md`;公网代理已接入;屏幕接管与共享环境仍待完成。
 
 
 ## 启用
 ## 启用
 
 

+ 1 - 0
electron-builder.yml

@@ -16,6 +16,7 @@ extraResources:
       - runner-config.mjs
       - runner-config.mjs
       - runner-config.mjs.map
       - runner-config.mjs.map
       - hermes_worker.py
       - hermes_worker.py
+      - guest_proxy.py
       - release.json
       - release.json
       - ui/**/*
       - ui/**/*
 asar: true
 asar: true

+ 1 - 0
scripts/build-runner.mjs

@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
 export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
 export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
   await mkdir(directory, { recursive: true })
   await mkdir(directory, { recursive: true })
   await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
+  await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
   await build({
   await build({
     entryPoints: { runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
     entryPoints: { runner:resolve(import.meta.dirname, '../src/runner/index.ts'), 'runner-config':resolve(import.meta.dirname, '../src/runner/config.ts') },
     outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',
     outdir:directory, outExtension:{'.js':'.mjs'}, bundle: true, platform: 'node',

+ 2 - 0
scripts/copy-worker.mjs

@@ -3,3 +3,5 @@ import {resolve} from 'node:path'
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 await mkdir(directory,{recursive:true})
 await mkdir(directory,{recursive:true})
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
+
+await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))

+ 5 - 2
src/client/components/app/RunnerSettingsPanel.vue

@@ -8,6 +8,7 @@ const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>
 const busy = ref(false), error = ref(''), notice = ref('')
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
 const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
+const computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
 const computers=ref(false),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const computers=ref(false),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 
@@ -30,7 +31,7 @@ async function register() {
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     if(computers.value&&(!/^sha256:[a-f0-9]{64}$/.test(computerImage.value)||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>!path.startsWith('/'))))throw new Error('请填写镜像完整 ID 和执行电脑上的绝对路径')
     if(computers.value&&(!/^sha256:[a-f0-9]{64}$/.test(computerImage.value)||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>!path.startsWith('/'))))throw new Error('请填写镜像完整 ID 和执行电脑上的绝对路径')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
-    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{runtime:computerRuntime.value,imageId:computerImage.value,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
+    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{network:computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
     await refresh()
   })
   })
@@ -74,11 +75,13 @@ onMounted(()=>{void action(refresh)})
       <fieldset v-if="computers" class="runner-computer">
       <fieldset v-if="computers" class="runner-computer">
         <legend>执行电脑配置</legend>
         <legend>执行电脑配置</legend>
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
         <label>容器运行时<select v-model="computerRuntime"><option value="docker">Docker</option><option value="podman">Podman</option></select></label>
+        <label>联网范围<select v-model="computerNetwork"><option value="public-proxy">公网网页(HTTP / HTTPS)</option><option value="none">不联网</option></select></label>
+        <small>公网模式通过受控代理访问,不开放宿主和内网地址。</small>
         <label>兼容镜像 ID<input v-model.trim="computerImage" required placeholder="sha256:完整镜像 ID"></label>
         <label>兼容镜像 ID<input v-model.trim="computerImage" required placeholder="sha256:完整镜像 ID"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes Python 路径<input v-model.trim="computerPython" required placeholder="/完整路径/hermes-agent/venv/bin/python"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 源码目录<input v-model.trim="hermesSource" required placeholder="/完整路径/hermes-agent"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
         <label>Hermes 配置目录<input v-model.trim="hermesHome" required placeholder="/完整路径/.hermes"></label>
-        <small>在 Agent 设置中选择“隔离电脑”后使用。镜像准备和联网代理仍在完善,当前使用 CUA 0.20.0 layer 5 兼容镜像,电脑默认不联网。</small>
+        <small>在 Agent 设置中选择“隔离电脑”后使用。当前使用 CUA 0.20.0 layer 5 兼容镜像,镜像安装管理仍在完善。</small>
       </fieldset>
       </fieldset>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
       <button class="solid-button" :disabled="busy || !name.trim() || !profiles.trim()">注册节点</button>
     </form>
     </form>

+ 1 - 1
src/client/views/ConversationsView.vue

@@ -738,7 +738,7 @@ onBeforeUnmount(() => {
             <option value="profile">基础 Profile 环境</option>
             <option value="profile">基础 Profile 环境</option>
             <option value="computer">隔离电脑</option>
             <option value="computer">隔离电脑</option>
           </select>
           </select>
-          <small v-if="form.execution === 'computer'">文件、终端和桌面操作在独立电脑中执行。需先配置支持电脑的 Runner,并为基础 Profile 设置绝对工作目录。目前电脑不联网。</small>
+          <small v-if="form.execution === 'computer'">文件、终端和桌面操作在独立电脑中执行。需先配置支持电脑的 Runner,并为基础 Profile 设置绝对工作目录。联网范围由执行节点的配置决定。</small>
         </label>
         </label>
         <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
         <label v-if="isAgentDialog && !isRemoteAgent" class="team-management-permission">
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>
           <span><input v-model="form.canManageTeam" type="checkbox" aria-label="允许组建团队" aria-describedby="team-management-help" />允许组建团队</span>

+ 1 - 1
src/runner/agent.ts

@@ -92,7 +92,7 @@ export class RunnerAgent {
         this.requireProfile(search.get('profile')??'default')
         this.requireProfile(search.get('profile')??'default')
         if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
         if(p.method!=='GET'||!this.computers)throw new HttpError(409,'执行节点未配置隔离电脑','computer_unavailable')
         await Promise.all([access(this.computers.config.python),access(this.computers.config.hermesSource),access(this.computers.script)])
         await Promise.all([access(this.computers.config.python),access(this.computers.config.hermesSource),access(this.computers.script)])
-        return {status:200,headers:{},body:Buffer.from(JSON.stringify({version:1,ready:true,native_tools:true,in_process:true,network:'none'})).toString('base64')}
+        return {status:200,headers:{},body:Buffer.from(JSON.stringify({version:1,ready:true,native_tools:true,in_process:true,network:this.config.computers?.network??'none'})).toString('base64')}
       }
       }
       if(p.computer&&path==='/api/files/download')throw new HttpError(409,'隔离电脑产物需通过产物导出接口读取','computer_artifact_required')
       if(p.computer&&path==='/api/files/download')throw new HttpError(409,'隔离电脑产物需通过产物导出接口读取','computer_artifact_required')
       if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){
       if(p.computer&&/^\/api\/sessions\/[^/]+\/messages$/.test(path)){

+ 17 - 0
src/runner/computers/container.ts

@@ -22,6 +22,7 @@ const command:ContainerCommand=(runtime,args,options={})=>new Promise((resolveRe
   child.stdin.end(options.input)
   child.stdin.end(options.input)
 })
 })
 const schema=z.object({
 const schema=z.object({
+  network:z.enum(['none','public-proxy']).default('none'),
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
   imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
   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),
   cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
@@ -134,6 +135,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       '--shm-size','512m','--security-opt','no-new-privileges:true','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
       '--shm-size','512m','--security-opt','no-new-privileges:true','--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']:[]),
       ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
       ...[...new Set([COMPUTER_WORKSPACE,spec.cwd])].flatMap(destination=>['--mount',`type=bind,source=${workspace},target=${destination}${this.runtime==='podman'?',relabel=private':''}`]),
       ...[...new Set([COMPUTER_WORKSPACE,spec.cwd])].flatMap(destination=>['--mount',`type=bind,source=${workspace},target=${destination}${this.runtime==='podman'?',relabel=private':''}`]),
+      ...(spec.network==='public-proxy'?['--env','HTTP_PROXY=http://127.0.0.1:3128','--env','HTTPS_PROXY=http://127.0.0.1:3128','--env','http_proxy=http://127.0.0.1:3128','--env','https_proxy=http://127.0.0.1:3128','--env','NO_PROXY=localhost,127.0.0.1,::1','--env','no_proxy=localhost,127.0.0.1,::1']:[]),
       '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
       '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
   }
   }
   async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
   async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
@@ -203,6 +205,21 @@ export class ContainerComputerProvider implements ComputerProvider {
       }
       }
     })
     })
   }
   }
+  async openPipe(value:ComputerSpecification,argv:string[],authorize:()=>void){
+    const spec=this.validateSpecification(value)
+    authorize();const state=await this.inspect(spec);authorize()
+    if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+    return spawn(this.runtime,['exec','-i','--user','1000:1000','--workdir',spec.cwd,'--',state.containerId,...argv],{stdio:'pipe'})
+  }
+  async configureNetwork(value:ComputerSpecification,authorize:()=>void){
+    const spec=this.validateSpecification(value)
+    if(spec.network!=='public-proxy')return
+    authorize();const state=await this.inspect(spec);authorize()
+    if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+    const script='import pathlib,json; chrome={"ProxyMode":"fixed_servers","ProxyServer":"http://127.0.0.1:3128","ProxyBypassList":"localhost;127.0.0.1;[::1]","QuicAllowed":False}; firefox={"policies":{"Proxy":{"Mode":"manual","HTTPProxy":"127.0.0.1:3128","SSLProxy":"127.0.0.1:3128","Passthrough":"localhost,127.0.0.1,::1","Locked":True},"DNSOverHTTPS":{"Enabled":False,"Locked":True}}}; paths=[("/etc/chromium/policies/managed/yaoyao.json",chrome),("/etc/opt/chrome/policies/managed/yaoyao.json",chrome),("/usr/lib/firefox/distribution/policies.json",firefox),("/usr/lib/firefox-esr/distribution/policies.json",firefox),("/opt/firefox/distribution/policies.json",firefox)]; [(pathlib.Path(p).parent.mkdir(parents=True,exist_ok=True),pathlib.Path(p).write_text(json.dumps(v))) for p,v in paths]'
+    await this.run(this.runtime,['exec','--user','0','--',state.containerId,'python3','-c',script])
+    authorize()
+  }
   async health(value:ComputerSpecification,authorize:()=>void):Promise<unknown> {
   async health(value:ComputerSpecification,authorize:()=>void):Promise<unknown> {
     return JSON.parse((await this.execute(value,[CUA_DRIVER,'call','health_report','{}','--socket',CUA_SOCKET],{authorize})).stdout)
     return JSON.parse((await this.execute(value,[CUA_DRIVER,'call','health_report','{}','--socket',CUA_SOCKET],{authorize})).stdout)
   }
   }

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

@@ -83,6 +83,7 @@ export class ComputerPool {
       catch(error){entry.status='uncertain';this.save(entry);throw error}
       catch(error){entry.status='uncertain';this.save(entry);throw error}
     })
     })
   }
   }
+  authorize(lease:ComputerLease){this.assert(lease)}
   renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
   renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)}
   use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
   use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
     this.assert(lease)
     this.assert(lease)

+ 1 - 1
src/runner/config.ts

@@ -7,7 +7,7 @@ const schema=z.object({
   hermesURL:z.string().url().default('http://127.0.0.1:9119'),
   hermesURL:z.string().url().default('http://127.0.0.1:9119'),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
-  computers:z.object({runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:z.string().refine(isAbsolute),hermesSource:z.string().refine(isAbsolute),hermesHome:z.string().refine(isAbsolute),maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().optional(),
+  computers:z.object({network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:z.string().refine(isAbsolute),hermesSource:z.string().refine(isAbsolute),hermesHome:z.string().refine(isAbsolute),maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().optional(),
   allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
   allowInsecureLan:z.boolean().optional(),hermesCredentials:z.object({username:z.string().max(256),password:z.string().max(4096)}).strict().optional(),
 }).strict()
 }).strict()
 export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {
 export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {

+ 66 - 0
src/runner/network/computerProxy.ts

@@ -0,0 +1,66 @@
+import {readFile} from 'node:fs/promises'
+import type {ChildProcessWithoutNullStreams} from 'node:child_process'
+import {PublicSocketBroker,type NetworkFrame} from './socketBroker.js'
+import type {ContainerComputerProvider,ComputerSpecification} from '../computers/container.js'
+
+/** One private pipe per leased computer. This exposes no host listening port. */
+export class ComputerPublicProxy {
+  private broker:PublicSocketBroker
+  private stopping=false
+  private buffer=''
+  private exited:Promise<void>
+  private ready:Promise<void>
+  private resolveReady!:()=>void
+  private rejectReady!:(error:Error)=>void
+  private lifetimeTimer:ReturnType<typeof setTimeout>
+  private tasks=new Set<Promise<void>>()
+  private constructor(readonly child:ChildProcessWithoutNullStreams,authorize:()=>void,check:()=>Promise<void>,readonly onFailure:()=>void){
+    this.ready=new Promise((resolve,reject)=>{this.resolveReady=resolve;this.rejectReady=reject});void this.ready.catch(()=>{})
+    this.broker=new PublicSocketBroker(frame=>this.send(frame),authorize,undefined,undefined,undefined,check)
+    child.stderr.resume();child.stdin.on('error',()=>this.fail())
+    this.exited=new Promise(resolve=>{
+      const ended=()=>{resolve();if(!this.stopping)this.fail()}
+      child.once('exit',ended);child.once('error',ended)
+    })
+    child.stdout.on('data',chunk=>{
+      this.buffer+=String(chunk)
+      if(this.buffer.length>1024*1024){this.fail();return}
+      for(;;){const end=this.buffer.indexOf('\n');if(end<0)break;const line=this.buffer.slice(0,end);this.buffer=this.buffer.slice(end+1)
+        try{
+          const frame=JSON.parse(line)
+          if(frame.op==='ready'&&frame.port===3128){this.resolveReady();continue}
+          const task=this.broker.receive(frame).catch(()=>this.fail())
+          this.tasks.add(task);void task.finally(()=>this.tasks.delete(task))
+        }catch{this.fail()}
+      }
+    })
+    this.lifetimeTimer=setTimeout(()=>this.fail(),30*60*1000);this.lifetimeTimer.unref()
+  }
+  static async start(provider:ContainerComputerProvider,spec:ComputerSpecification,script:string,authorize:()=>void,check:()=>Promise<void>,onFailure:()=>void){
+    authorize()
+    const source=await readFile(script,'utf8')
+    authorize();await provider.configureNetwork(spec,authorize)
+    const child=await provider.openPipe(spec,['python3','-u','-c',source],authorize)
+    const proxy=new ComputerPublicProxy(child,authorize,check,onFailure)
+    let timer:ReturnType<typeof setTimeout>|undefined
+    try{await Promise.race([proxy.ready,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('电脑联网代理启动超时')),10000)})]);authorize();return proxy}
+    catch(error){await proxy.close();throw error}finally{clearTimeout(timer)}
+  }
+  private send(frame:NetworkFrame):Promise<void>{
+    if(this.stopping||this.child.stdin.destroyed)return Promise.reject(new Error('联网通道已关闭'))
+    const line=JSON.stringify(frame)+'\n'
+    if(this.child.stdin.writableLength+line.length>4*1024*1024)return Promise.reject(new Error('联网回传队列已满'))
+    return new Promise((resolve,reject)=>this.child.stdin.write(line,error=>error?reject(error):resolve()))
+  }
+  private fail(){if(this.stopping)return;this.rejectReady(new Error('电脑联网通道已断开'));void this.close();this.onFailure()}
+  async close(){
+    if(this.stopping)return this.exited
+    this.stopping=true;clearTimeout(this.lifetimeTimer);this.broker.close();this.child.stdin.end()
+    let timer:ReturnType<typeof setTimeout>|undefined
+    try{await Promise.race([this.exited,new Promise(resolve=>{timer=setTimeout(resolve,1500)})])}finally{clearTimeout(timer)}
+    if(this.child.exitCode===null&&this.child.signalCode===null)this.child.kill('SIGTERM')
+    try{await Promise.race([this.exited,new Promise(resolve=>{timer=setTimeout(resolve,1500)})])}finally{clearTimeout(timer)}
+    if(this.child.exitCode===null&&this.child.signalCode===null)this.child.kill('SIGKILL')
+    await this.exited
+  }
+}

+ 116 - 0
src/runner/network/guest_proxy.py

@@ -0,0 +1,116 @@
+"""Loopback HTTP proxy. All external sockets are owned by the parent broker.
+This process has no network interface except guest loopback and no credentials.
+"""
+import asyncio
+import base64
+import json
+import sys
+import uuid
+from urllib.parse import urlsplit
+
+PORT = 3128
+streams = {}
+wire = None
+wire_lock = None
+
+async def emit(frame):
+    async with wire_lock:
+        wire.write((json.dumps(frame, separators=(",", ":")) + "\n").encode())
+        await wire.drain()
+
+async def client(reader, writer):
+    if len(streams) >= 32:
+        writer.close()
+        return
+    identity = str(uuid.uuid4())
+    opened = asyncio.get_running_loop().create_future()
+    streams[identity] = (writer, opened)
+    connected = False
+    try:
+        header = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), 10)
+        if len(header) > 32768:
+            raise ValueError("header too large")
+        first, rest = header.split(b"\r\n", 1)
+        method, target, version = first.decode("ascii").split(" ")
+        if version not in ("HTTP/1.0", "HTTP/1.1"):
+            raise ValueError("invalid protocol")
+        parsed = urlsplit(("//" if method == "CONNECT" else "") + target)
+        if not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
+            raise ValueError("invalid authority")
+        if method != "CONNECT" and parsed.scheme != "http":
+            raise ValueError("use CONNECT for HTTPS")
+        port = parsed.port or (443 if method == "CONNECT" else 80)
+        await emit({"op": "open", "id": identity, "host": parsed.hostname, "port": port})
+        await asyncio.wait_for(opened, 15)
+        connected = True
+        if method == "CONNECT":
+            writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
+            await writer.drain()
+        else:
+            path = parsed.path or "/"
+            if parsed.query:
+                path += "?" + parsed.query
+            lines = [line for line in rest.split(b"\r\n") if line and line.split(b":", 1)[0].lower() not in (b"proxy-authorization", b"proxy-connection", b"connection", b"host")]
+            authority = ("[" + parsed.hostname + "]") if ":" in parsed.hostname else parsed.hostname
+            if port != 80:
+                authority += ":" + str(port)
+            outgoing = (method + " " + path + " " + version + "\r\nHost: " + authority + "\r\nConnection: close\r\n").encode() + b"\r\n".join(lines) + b"\r\n\r\n"
+            await emit({"op": "data", "id": identity, "data": base64.b64encode(outgoing).decode()})
+        while True:
+            data = await reader.read(32768)
+            if not data:
+                await emit({"op": "end", "id": identity})
+                break
+            await emit({"op": "data", "id": identity, "data": base64.b64encode(data).decode()})
+    except Exception:
+        if not connected:
+            writer.write(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
+            with __import__("contextlib").suppress(Exception):
+                await writer.drain()
+    finally:
+        streams.pop(identity, None)
+        writer.close()
+        with __import__("contextlib").suppress(Exception):
+            await emit({"op": "close", "id": identity})
+
+async def main():
+    global wire, wire_lock
+    loop = asyncio.get_running_loop()
+    transport, protocol = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout.buffer)
+    wire = asyncio.StreamWriter(transport, protocol, None, loop)
+    wire_lock = asyncio.Lock()
+    incoming = asyncio.StreamReader(limit=100000)
+    await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(incoming), sys.stdin.buffer)
+    server = await asyncio.start_server(client, "127.0.0.1", PORT, limit=32768)
+    await emit({"op": "ready", "port": PORT})
+    try:
+        while True:
+            line = await incoming.readline()
+            if not line:
+                break
+            frame = json.loads(line)
+            state = streams.get(frame.get("id"))
+            if not state:
+                continue
+            writer, opened = state
+            if frame["op"] == "opened":
+                if not opened.done():
+                    opened.set_result(True)
+            elif frame["op"] == "data":
+                writer.write(base64.b64decode(frame["data"], validate=True))
+                await writer.drain()
+            elif frame["op"] in ("close", "error", "end"):
+                if not opened.done():
+                    opened.set_exception(ConnectionError("network unavailable"))
+                elif not opened.cancelled() and opened.exception() is None:
+                    writer.close()
+    finally:
+        server.close()
+        await server.wait_closed()
+        for writer, opened in list(streams.values()):
+            if not opened.done():
+                opened.cancel()
+            writer.close()
+        wire.close()
+
+asyncio.run(main())

+ 37 - 0
src/runner/network/publicDestination.ts

@@ -0,0 +1,37 @@
+import {lookup} from 'node:dns/promises'
+import {BlockList,isIP} from 'node:net'
+import {networkInterfaces} from 'node:os'
+
+// Conservative public-web policy. Special-use registries:
+// https://www.iana.org/assignments/iana-ipv4-special-registry/
+// https://www.iana.org/assignments/iana-ipv6-special-registry/
+const denied4=new BlockList(),denied6=new BlockList(),global6=new BlockList()
+for(const [address,prefix] of [
+  ['0.0.0.0',8],['10.0.0.0',8],['100.64.0.0',10],['127.0.0.0',8],['169.254.0.0',16],
+  ['172.16.0.0',12],['192.0.0.0',24],['192.0.2.0',24],['192.88.99.0',24],['192.168.0.0',16],
+  ['198.18.0.0',15],['198.51.100.0',24],['203.0.113.0',24],['224.0.0.0',4],['240.0.0.0',4],
+] as const)denied4.addSubnet(address,prefix,'ipv4')
+global6.addSubnet('2000::',3,'ipv6')
+for(const [address,prefix] of [['2001::',23],['2001:db8::',32],['2002::',16],['3fff::',20]] as const)denied6.addSubnet(address,prefix,'ipv6')
+export function publicAddress(address:string):boolean {
+  const family=isIP(address)
+  if(family===4)return !denied4.check(address,'ipv4')
+  return family===6&&global6.check(address,'ipv6')&&!denied6.check(address,'ipv6')
+}
+export function normalizePublicHost(value:string):string {
+  if(!value||value.length>253||/[\s\u0000-\u001f/%\\@?#]/.test(value))throw new Error('网络目标无效')
+  const url=new URL(`http://${isIP(value)===6?`[${value}]`:value}`)
+  if(url.port||url.username||url.password||url.pathname!=='/')throw new Error('网络目标无效')
+  return url.hostname.replace(/^\[|\]$/g,'').replace(/\.$/,'').toLowerCase()
+}
+export type AddressLookup=(host:string)=>Promise<Array<{address:string;family:number}>>
+export async function publicDestination(host:string,port:number,resolve:AddressLookup=host=>lookup(host,{all:true,verbatim:true}),local=networkInterfaces()):Promise<{host:string;address:string;family:4|6;port:number}> {
+  if(port!==80&&port!==443)throw new Error('电脑联网仅允许公网 HTTP 和 HTTPS')
+  host=normalizePublicHost(host)
+  const addresses=isIP(host)?[{address:host,family:isIP(host)}]:await resolve(host)
+  const own=new BlockList()
+  for(const entries of Object.values(local))for(const entry of entries??[]){const family=isIP(entry.address);if(family)own.addAddress(entry.address,family===4?'ipv4':'ipv6')}
+  if(!addresses.length||addresses.some(item=>!publicAddress(item.address)||own.check(item.address,item.family===4?'ipv4':'ipv6')))throw new Error('电脑不能访问内网、宿主或保留地址')
+  const selected=addresses.find(item=>item.family===4)??addresses[0]!
+  return {host,address:selected.address,family:selected.family as 4|6,port}
+}

+ 65 - 0
src/runner/network/socketBroker.ts

@@ -0,0 +1,65 @@
+import {connect,type Socket} from 'node:net'
+import {z} from 'zod'
+import {publicDestination} from './publicDestination.js'
+
+export type NetworkFrame={op:string;id?:string;[key:string]:unknown}
+const input=z.discriminatedUnion('op',[
+  z.object({op:z.literal('open'),id:z.string().uuid(),host:z.string().max(253),port:z.number().int()}).strict(),
+  z.object({op:z.literal('data'),id:z.string().uuid(),data:z.string().max(65536)}).strict(),
+  z.object({op:z.literal('end'),id:z.string().uuid()}).strict(),
+  z.object({op:z.literal('close'),id:z.string().uuid()}).strict(),
+])
+type Destination=Awaited<ReturnType<typeof publicDestination>>
+interface Stream {socket?:Socket;received:number;sent:number;opened:boolean}
+export class PublicSocketBroker {
+  private streams=new Map<string,Stream>()
+  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()){
+    this.timer=setInterval(()=>{try{authorize()}catch{this.close()}},1000);this.timer.unref()
+  }
+  get activeConnections(){return this.streams.size}
+  async receive(value:unknown):Promise<void>{
+    const frame=input.parse(value)
+    if(this.closed)throw new Error('联网通道已关闭')
+    this.authorize()
+    if(frame.op==='open'){
+      if(this.streams.has(frame.id))throw new Error('连接编号已被使用')
+      if(this.streams.size>=this.limits.connections){await this.send({op:'error',id:frame.id,message:'联网连接已达上限'});return}
+      const state:Stream={received:0,sent:0,opened:false};this.streams.set(frame.id,state)
+      try{
+        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
+        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()}})
+        socket.on('data',chunk=>{
+          socket.pause()
+          try{this.authorize();this.count(chunk.length);state.received+=chunk.length}
+          catch{this.close();return}
+          void this.send({op:'data',id:frame.id,data:chunk.toString('base64')}).then(()=>socket.resume(),()=>this.close())
+        })
+        socket.once('end',()=>{void this.send({op:'end',id:frame.id}).catch(()=>this.close())})
+        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{
+        this.streams.delete(frame.id)
+        await this.send({op:'error',id:frame.id,message:'联网目标被拒绝或不可达'})
+      }
+      return
+    }
+    const stream=this.streams.get(frame.id)
+    if(!stream)return
+    if(frame.op==='close'){this.streams.delete(frame.id);stream.socket?.destroy();return}
+    if(!stream.opened||!stream.socket)throw new Error('连接尚未建立')
+    if(frame.op==='end'){stream.socket.end();return}
+    const bytes=Buffer.from(frame.data,'base64')
+    this.count(bytes.length);stream.sent+=bytes.length
+    if(stream.socket.writableLength+bytes.length>1024*1024)throw new Error('联网写入队列过长')
+    await new Promise<void>((resolve,reject)=>stream.socket!.write(bytes,error=>error?reject(error):resolve()))
+  }
+  private count(bytes:number){this.transferred+=bytes;if(this.transferred>this.limits.bytes)throw new Error('本轮联网流量已达上限')}
+  close(){if(this.closed)return;this.closed=true;clearInterval(this.timer);for(const stream of this.streams.values())stream.socket?.destroy();this.streams.clear()}
+}

+ 14 - 5
src/runner/worker/gateway.ts

@@ -2,6 +2,7 @@ import {randomUUID} from 'node:crypto'
 import {join,dirname,posix} from 'node:path'
 import {join,dirname,posix} from 'node:path'
 import {fileURLToPath} from 'node:url'
 import {fileURLToPath} from 'node:url'
 import {mkdir} from 'node:fs/promises'
 import {mkdir} from 'node:fs/promises'
+import {existsSync} from 'node:fs'
 import type {DatabaseSync} from 'node:sqlite'
 import type {DatabaseSync} from 'node:sqlite'
 import {z} from 'zod'
 import {z} from 'zod'
 import {HttpError} from '../../server/errors.js'
 import {HttpError} from '../../server/errors.js'
@@ -9,11 +10,12 @@ import type {GatewayFrame} from '../../server/workspaceGateway.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
+import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
 
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
 export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
 export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
-interface Live {session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
+interface Live {proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
 const TOOLS:WorkerTool[]=[
@@ -31,10 +33,12 @@ export class ComputerRuntime {
   readonly ready:Promise<void>
   readonly ready:Promise<void>
   readonly config:NonNullable<RunnerConfiguration['computers']>
   readonly config:NonNullable<RunnerConfiguration['computers']>
   readonly script:string
   readonly script:string
+  readonly proxyScript:string
   constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string){
   constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string){
     if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
     if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
     this.config=config.computers
     this.config=config.computers
     this.script=script??join(dirname(fileURLToPath(import.meta.url)),'hermes_worker.py')
     this.script=script??join(dirname(fileURLToPath(import.meta.url)),'hermes_worker.py')
+    this.proxyScript=existsSync(join(dirname(this.script),'guest_proxy.py'))?join(dirname(this.script),'guest_proxy.py'):join(dirname(this.script),'../network/guest_proxy.py')
     this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
     this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
     this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
     this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
     db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
     db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
@@ -68,7 +72,7 @@ export class ComputerGateway {
   private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
   private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
   private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
   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 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.config.imageId,cwd:session.cwd}}
+  private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.config.imageId,cwd:session.cwd,network:this.runtime.config.network??'none'}}
   async rpc(method:string,params:Record<string,any>):Promise<any>{
   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.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'}}
     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'}}
@@ -87,7 +91,12 @@ export class ComputerGateway {
       if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
       if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
       this.live={session,lease,controller,model:resolved.model as WorkerModel,images:[],running:false,received:false}
       this.live={session,lease,controller,model:resolved.model as WorkerModel,images:[],running:false,received:false}
       this.runtime.save(session)
       this.runtime.save(session)
-      this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(lease)).catch(()=>this.interrupt()).finally(()=>{this.checking=false})},5000);this.timer.unref()
+      if(this.runtime.config.network==='public-proxy'){
+        const live=this.live
+        try{live.proxy=await ComputerPublicProxy.start(this.runtime.provider,this.spec(session),this.runtime.proxyScript,()=>this.runtime.pool.authorize(lease),()=>this.authorized(),()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})})}
+        catch(error){await this.interrupt();throw error}
+      }
+      this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
     }
     }
     const live=this.live
     const live=this.live
@@ -121,7 +130,7 @@ export class ComputerGateway {
       live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       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)
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId)
       await mkdir(directory,{recursive:true,mode:0o700})
       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,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,tools:[...TOOLS,...(this.team?.catalog??[])],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,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
       live.worker=worker
       live.worker=worker
       worker.onTool=(name,args,id)=>this.tool(live,name,args,id)
       worker.onTool=(name,args,id)=>this.tool(live,name,args,id)
       worker.onEvent=frame=>{
       worker.onEvent=frame=>{
@@ -199,7 +208,7 @@ export class ComputerGateway {
     if(this.closed||live.controller.signal.aborted)return
     if(this.closed||live.controller.signal.aborted)return
     this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
     this.event('message.complete',{text:String(frame.text??''),status:success?'complete':'failed',...(!success?{error:'隔离 Worker 未完成本轮'}:{})})
   }
   }
-  private release(live:Live){clearInterval(this.timer);live.releasing??=this.runtime.pool.release(live.lease);return live.releasing}
+  private release(live:Live){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease)})();return live.releasing}
   private async lost(live:Live){
   private async lost(live:Live){
     if(!live.running||this.closed||live.controller.signal.aborted)return
     if(!live.running||this.closed||live.controller.signal.aborted)return
     live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
     live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()

+ 1 - 1
src/runner/worker/hermes_worker.py

@@ -154,7 +154,7 @@ def run_model():
     threading.Thread(target=receive, daemon=True).start()
     threading.Thread(target=receive, daemon=True).start()
     emit("ready", tools=sorted(allowed))
     emit("ready", tools=sorted(allowed))
     result = agent.run_conversation(BOOT["prompt"], conversation_history=BOOT.get("history", []), task_id=BOOT["taskId"],
     result = agent.run_conversation(BOOT["prompt"], conversation_history=BOOT.get("history", []), task_id=BOOT["taskId"],
-        system_message="You are a controlled Hermes worker. All tools operate on your assigned isolated Linux computer. Use Linux paths and shell syntax. Use only the supplied tools. The working directory is " + BOOT["cwd"] + ". Never claim host files or credentials are available. Follow the user's task and permission scope.")
+        system_message="You are a controlled Hermes worker. All tools operate on your assigned isolated Linux computer. Use Linux paths and shell syntax. Network access follows the configured computer policy. Use only the supplied tools. The working directory is " + BOOT["cwd"] + ". Never claim host files or credentials are available. Follow the user's task and permission scope.")
     emit("complete", text=str(result.get("final_response") or ""), messages=result.get("messages", []), interrupted=stopped.is_set(), completed=result.get("completed", True))
     emit("complete", text=str(result.get("final_response") or ""), messages=result.get("messages", []), interrupted=stopped.is_set(), completed=result.get("completed", True))
 
 
 try:
 try:

+ 1 - 1
src/shared/runner.ts

@@ -24,6 +24,6 @@ export interface RunnerConfiguration {
   allowedProfiles: string[]
   allowedProfiles: string[]
   artifactRoots: string[]
   artifactRoots: string[]
   allowInsecureLan?: boolean
   allowInsecureLan?: boolean
-  computers?: {runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
+  computers?: {network?:'none'|'public-proxy';runtime:'docker'|'podman';imageId:string;python:string;hermesSource:string;hermesHome:string;maxConcurrent?:number}
   hermesCredentials?: {username:string;password:string}
   hermesCredentials?: {username:string;password:string}
 }
 }

+ 2 - 2
tests/e2e/team-tools.spec.ts

@@ -95,7 +95,7 @@ test('registers an execution node, downloads its private config and disables it'
   const stream=await download.createReadStream();const chunks:Buffer[]=[]
   const stream=await download.createReadStream();const chunks:Buffer[]=[]
   for await(const chunk of stream!)chunks.push(Buffer.from(chunk))
   for await(const chunk of stream!)chunks.push(Buffer.from(chunk))
   const configuration=JSON.parse(Buffer.concat(chunks).toString())
   const configuration=JSON.parse(Buffer.concat(chunks).toString())
-  expect(configuration).toMatchObject({protocol:1,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',python:'/fixture/venv/bin/python',hermesHome:'/fixture/hermes'}})
+  expect(configuration).toMatchObject({protocol:1,allowedProfiles:['default'],artifactRoots:[],computers:{network:'public-proxy',runtime:'docker',python:'/fixture/venv/bin/python',hermesHome:'/fixture/hermes'}})
   expect(configuration.token.length).toBeGreaterThan(32)
   expect(configuration.token.length).toBeGreaterThan(32)
   const result=await (await page.request.get('/api/app/admin/runners')).json()
   const result=await (await page.request.get('/api/app/admin/runners')).json()
   expect(JSON.stringify(result)).not.toContain(configuration.token)
   expect(JSON.stringify(result)).not.toContain(configuration.token)
@@ -118,7 +118,7 @@ test('explains isolated execution and keeps the edit form intact when a computer
   const dialog=page.getByRole('dialog')
   const dialog=page.getByRole('dialog')
   await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('浏览器隔离成员')
   await dialog.getByRole('textbox',{name:'名称',exact:true}).fill('浏览器隔离成员')
   await dialog.getByRole('combobox',{name:'执行环境',exact:true}).selectOption('computer')
   await dialog.getByRole('combobox',{name:'执行环境',exact:true}).selectOption('computer')
-  await expect(dialog).toContainText('目前电脑不联网')
+  await expect(dialog).toContainText('联网范围由执行节点的配置决定')
   await dialog.getByRole('button',{name:'保存',exact:true}).click()
   await dialog.getByRole('button',{name:'保存',exact:true}).click()
   await expect(dialog.getByText(/隔离电脑需要连接执行节点/)).toBeVisible()
   await expect(dialog.getByText(/隔离电脑需要连接执行节点/)).toBeVisible()
   await expect(dialog).toBeVisible()
   await expect(dialog).toBeVisible()

+ 38 - 0
tests/live/network.live.test.ts

@@ -0,0 +1,38 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {mkdtemp,rm,mkdir,writeFile} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import {ContainerComputerProvider} from '../../src/runner/computers/container'
+import {ComputerPool} from '../../src/runner/computers/pool'
+import {ComputerPublicProxy} from '../../src/runner/network/computerProxy'
+
+it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('connects from a real networkless guest through the public proxy and denies host access',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-public-network-')),db=new DatabaseSync(':memory:')
+  const provider=new ContainerComputerProvider('docker',randomUUID(),home),pool=new ComputerPool(db,provider)
+  const spec={id:randomUUID(),ownerKey:randomUUID(),imageId:process.env.YAOYAO_COMPUTER_IMAGE!,network:'public-proxy' as const}
+  let proxy:ComputerPublicProxy|undefined,stage='ensure',heartbeat:ReturnType<typeof setInterval>|undefined
+  try{
+    await pool.recover();const lease=await pool.acquire(spec,'network-proof',()=>{});heartbeat=setInterval(()=>{try{pool.renew(lease)}catch{}},5000)
+    const guard=()=>pool.authorize(lease)
+    stage='start proxy';proxy=await ComputerPublicProxy.start(provider,spec,resolve('src/runner/network/guest_proxy.py'),guard,async()=>guard(),()=>{})
+    const command=(argv:string[])=>pool.use(lease,context=>provider.execute(spec,argv,context))
+    stage='inspect interfaces';expect((await command(['ls','/sys/class/net'])).stdout.trim()).toBe('lo')
+    stage='public HTTPS';const page=await command(['curl','--fail','--silent','--show-error','--max-time','20','https://example.com'])
+    expect(page.stdout).toContain('Example Domain')
+    stage='browser';const browser=(await command(['/bin/sh','-c','command -v firefox || command -v chromium || command -v google-chrome'])).stdout.trim()
+    if(browser.includes('firefox')){
+      await command(['mkdir','-p','.browser-profiles/network-proof'])
+      await command([browser,'--headless','--profile','/home/cua/workspace/.browser-profiles/network-proof','--screenshot','/tmp/network-browser.png','--window-size','1280,900','https://example.com'])
+      const png=Buffer.from((await command(['base64','-w0','/tmp/network-browser.png'])).stdout.trim(),'base64')
+      expect(png.subarray(1,4).toString()).toBe('PNG');await mkdir('test-results/network',{recursive:true});await writeFile('test-results/network/browser.png',png)
+    }else throw new Error(`No verified Firefox path: ${browser}`)
+    stage='reject host';const rejected=await command(['curl','--silent','--max-time','10','--noproxy','','--proxy','http://127.0.0.1:3128','--output','/dev/null','--write-out','%{http_code}','http://127.0.0.1:15300'])
+    expect(rejected.stdout).toBe('502')
+    stage='close proxy';await proxy.close();proxy=undefined
+    await expect(command(['curl','--fail','--silent','--max-time','2','https://example.com'])).rejects.toBeDefined()
+    stage='release';await pool.release(lease)
+  }catch(error){throw new Error(`${stage}: ${String(error)} code=${(error as any).code}\n${String((error as any).stdout??'')}\n${String((error as any).stderr??'')}`)}finally{clearInterval(heartbeat);await proxy?.close();await pool.close();db.close();await provider.remove(spec);await rm(home,{recursive:true,force:true})}
+},90000)

+ 3 - 2
tests/live/worker.live.test.ts

@@ -64,7 +64,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     const current=input.messages.slice(input.messages.findLastIndex((message:any)=>message.role==='user')+1)
     const current=input.messages.slice(input.messages.findLastIndex((message:any)=>message.role==='user')+1)
     const called=current.some((message:any)=>message.role==='tool')
     const called=current.some((message:any)=>message.role==='tool')
     const exported=current.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
     const exported=current.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
-    const message=called&&!team&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:slow?'sleep 30; printf forbidden > late-write.txt':'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
+    const message=called&&!team&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:slow?'sleep 30; printf forbidden > late-write.txt':(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'curl --fail --silent --show-error --max-time 20 https://example.com > public.html; ':'')+'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
     if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
     if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
     else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
     else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
   })
   })
@@ -78,7 +78,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     const bootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).expect(200)
     const bootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).expect(200)
     const setup=await request(app.app.callback()).post('/api/app/setup').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'worker-fixture',password:'worker-fixture-password'}).expect(200)
     const setup=await request(app.app.callback()).post('/api/app/setup').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'worker-fixture',password:'worker-fixture-password'}).expect(200)
     const owner=setup.body.user.id,registered=app.runners.enroll(owner,{name:'Worker 验收节点',allowedProfiles:['default']})
     const owner=setup.body.user.id,registered=app.runners.enroll(owner,{name:'Worker 验收节点',allowedProfiles:['default']})
-    const runnerConfig={protocol:1,serverURL:address,runnerId:registered.runner.id,token:registered.token,hermesURL:upstreamURL,allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome}}
+    const runnerConfig={protocol:1,serverURL:address,runnerId:registered.runner.id,token:registered.token,hermesURL:upstreamURL,allowedProfiles:['default'],artifactRoots:[],computers:{network:process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'public-proxy':'none',runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome}}
     if(process.env.YAOYAO_WORKER_RUNNER_BUNDLE){
     if(process.env.YAOYAO_WORKER_RUNNER_BUNDLE){
       const {spawn}=await import('node:child_process')
       const {spawn}=await import('node:child_process')
       const path=join(home,'runner-config.json');await writeFile(path,JSON.stringify(runnerConfig),{mode:0o600})
       const path=join(home,'runner-config.json');await writeFile(path,JSON.stringify(runnerConfig),{mode:0o600})
@@ -95,6 +95,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     await expect.poll(()=>app.workspace.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/complete|failed/)
     await expect.poll(()=>app.workspace.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/complete|failed/)
     expect(app.workspace.require<any>(owner,'run',run.id).status,JSON.stringify(app.workspace.messages(owner,direct.id))).toBe('complete')
     expect(app.workspace.require<any>(owner,'run',run.id).status,JSON.stringify(app.workspace.messages(owner,direct.id))).toBe('complete')
     expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'result.txt'),'utf8')).toBe('worker-proof')
     expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'result.txt'),'utf8')).toBe('worker-proof')
+    if(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1')expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'public.html'),'utf8')).toContain('Example Domain')
     const message=app.workspace.messages(owner,direct.id).find(message=>message.role==='assistant')!
     const message=app.workspace.messages(owner,direct.id).find(message=>message.role==='assistant')!
     expect(message.attachments).toHaveLength(2)
     expect(message.attachments).toHaveLength(2)
     const report=message.attachments.find(file=>file.name==='result.txt')!
     const report=message.attachments.find(file=>file.name==='result.txt')!

+ 54 - 0
tests/server/publicNetwork.test.ts

@@ -0,0 +1,54 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {createServer} from 'node:net'
+import {randomUUID} from 'node:crypto'
+import {publicAddress,publicDestination,normalizePublicHost} from '../../src/runner/network/publicDestination'
+import {PublicSocketBroker,type NetworkFrame} from '../../src/runner/network/socketBroker'
+
+it.each(['127.0.0.1','127.20.30.40','0.0.0.0','10.1.2.3','100.64.0.1','169.254.169.254','172.31.2.3','192.168.1.1','192.0.0.9','192.0.2.1','198.19.1.1','198.51.100.1','203.0.113.1','224.0.0.1','255.255.255.255','::1','::','::ffff:127.0.0.1','fe80::1','fd00::1','64:ff9b::a00:1','2001:db8::1','2002:7f00:1::1','3fff::1'])('blocks non-public destination %s',address=>expect(publicAddress(address)).toBe(false))
+it.each(['1.1.1.1','8.8.8.8','93.184.216.34','2606:4700:4700::1111'])('recognizes ordinary public unicast %s',address=>expect(publicAddress(address)).toBe(true))
+it.each(['127.1','2130706433','0x7f000001'])('normalizes alternate IP notation before applying the policy (%s)',async host=>{
+  expect(normalizePublicHost(host)).toBe('127.0.0.1');await expect(publicDestination(host,443)).rejects.toThrow()
+})
+it('rejects mixed DNS, host addresses and unsafe authority syntax',async()=>{
+  await expect(publicDestination('fixture.example',443,async()=>[{address:'1.1.1.1',family:4},{address:'127.0.0.1',family:4}],{})).rejects.toThrow()
+  await expect(publicDestination('fixture.example',443,async()=>[{address:'1.1.1.1',family:4}],{eth0:[{address:'1.1.1.1',family:'IPv4'} as any]})).rejects.toThrow()
+  for(const host of ['user:secret@example.com','example.com/path','example.com:8443','host%2elocal','[::1%eth0]'])expect(()=>normalizePublicHost(host)).toThrow()
+  await expect(publicDestination('1.1.1.1',22)).rejects.toThrow()
+})
+it('pins the resolved address and revalidates each new connection',async()=>{
+  let calls=0
+  const lookup=async()=>[{address:calls++===0?'1.1.1.1':'127.0.0.1',family:4}]
+  expect(await publicDestination('fixture.example',443,lookup,{})).toMatchObject({address:'1.1.1.1',port:443})
+  await expect(publicDestination('fixture.example',443,lookup,{})).rejects.toThrow()
+})
+it('does not dial after authority changes while DNS is pending',async()=>{
+  let release!:(value:any)=>void,allowed=true
+  const frames:NetworkFrame[]=[],dial=vi.fn()
+  const broker=new PublicSocketBroker(async frame=>{frames.push(frame)},()=>{if(!allowed)throw new Error('revoked')},()=>new Promise(resolve=>{release=resolve}),dial)
+  const pending=broker.receive({op:'open',id:randomUUID(),host:'example.com',port:443})
+  await vi.waitFor(()=>expect(release).toBeDefined());allowed=false;release({address:'1.1.1.1',family:4,port:443,host:'example.com'})
+  await pending;expect(dial).not.toHaveBeenCalled();expect(frames.at(-1)?.op).toBe('error');broker.close()
+})
+it('bounds pending connections before asynchronous resolution',async()=>{
+  let release!:(value:any)=>void
+  const wait=new Promise<any>(resolve=>{release=resolve}),frames:NetworkFrame[]=[],dial=vi.fn()
+  const broker=new PublicSocketBroker(async frame=>{frames.push(frame)},()=>{},()=>wait,dial,{connections:1,bytes:1024})
+  const first=broker.receive({op:'open',id:randomUUID(),host:'example.com',port:443})
+  await broker.receive({op:'open',id:randomUUID(),host:'example.com',port:443})
+  expect(broker.activeConnections).toBe(1);expect(frames.at(-1)?.op).toBe('error')
+  broker.close();release({address:'1.1.1.1',family:4,port:443,host:'example.com'});await first;expect(dial).not.toHaveBeenCalled()
+})
+it('transfers bytes through a real socket and rejects further writes after revocation',async()=>{
+  const server=createServer(socket=>socket.on('data',bytes=>socket.write(bytes)))
+  await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
+  const frames:NetworkFrame[]=[],id=randomUUID();let active=true
+  // The loopback fixture is injected only here; production resolution is tested above.
+  const broker=new PublicSocketBroker(async frame=>{frames.push(frame)},()=>{if(!active)throw new Error('revoked')},async()=>({address:'127.0.0.1',family:4,port:(server.address() as {port:number}).port,host:'fixture'}))
+  try{
+    await broker.receive({op:'open',id,host:'fixture.example',port:443});await vi.waitFor(()=>expect(frames.some(frame=>frame.op==='opened')).toBe(true))
+    await broker.receive({op:'data',id,data:Buffer.from('wire-proof').toString('base64')})
+    await vi.waitFor(()=>expect(frames.some(frame=>frame.op==='data'&&Buffer.from(String(frame.data),'base64').toString()==='wire-proof')).toBe(true))
+    active=false;await expect(broker.receive({op:'data',id,data:'eA=='})).rejects.toThrow('revoked')
+  }finally{broker.close();await new Promise<void>(resolve=>server.close(()=>resolve()))}
+})

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

@@ -20,3 +20,10 @@ it.each([
 it('accepts explicit LAN opt-in and keeps service credentials in the private configuration',()=>{
 it('accepts explicit LAN opt-in and keeps service credentials in the private configuration',()=>{
   expect(parseRunnerConfiguration({...config,serverURL:'http://192.168.1.2:15300',allowInsecureLan:true,hermesCredentials:{username:'fixture',password:'private'}})).toMatchObject({allowInsecureLan:true,hermesCredentials:{username:'fixture',password:'private'}})
   expect(parseRunnerConfiguration({...config,serverURL:'http://192.168.1.2:15300',allowInsecureLan:true,hermesCredentials:{username:'fixture',password:'private'}})).toMatchObject({allowInsecureLan:true,hermesCredentials:{username:'fixture',password:'private'}})
 })
 })
+
+it('keeps existing computer configurations offline unless public access is explicitly enabled',()=>{
+  const computers={runtime:'docker',imageId:'sha256:'+ 'a'.repeat(64),python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}
+  expect(parseRunnerConfiguration({...config,computers}).computers?.network).toBe('none')
+  expect(parseRunnerConfiguration({...config,computers:{...computers,network:'public-proxy'}}).computers?.network).toBe('public-proxy')
+  expect(()=>parseRunnerConfiguration({...config,computers:{...computers,network:'host'}})).toThrow()
+})