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

修复模型 Worker 自动继承基础 Profile 代理

Samien 1 месяц назад
Родитель
Сommit
45f2cf3e27

+ 4 - 0
docs/computer-worker.md

@@ -28,6 +28,10 @@ Worker 复用已安装 Hermes 的模型循环。它截获整个工具执行入
 
 
 当前 Worker 模型连接支持 Hermes 的 `chat_completions`、`anthropic_messages`、`codex_responses` API 模式;不启动宿主 CLI 模型后端。模型设置和凭据来自允许列表内的 Profile。
 当前 Worker 模型连接支持 Hermes 的 `chat_completions`、`anthropic_messages`、`codex_responses` API 模式;不启动宿主 CLI 模型后端。模型设置和凭据来自允许列表内的 Profile。
 
 
+模型 Worker 自动继承所选基础 Profile `.env` 中的 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY` 及其小写形式,无需单独配置 Worker。变量大小写和显式空值原样保留,由 Hermes SDK 决定代理选择与绕过规则;Profile 未配置时保持直连,不回退到 Runner 的全局代理。新建或恢复会话时重新读取,人工接管后交还控制时沿用本轮快照。代理仅在 Runner 内存和模型子进程环境中使用,不写入会话数据库或发送到客户端。
+
+模型连接代理与隔离电脑的 `none` / `public-proxy` 联网策略相互独立。模型进程运行在 Runner 侧,因此代理地址中的 `127.0.0.1` 指向 Runner 所在运行环境,不会自动改写成虚拟电脑或 Docker 宿主地址。网络探测收到 HTTP 403 只能证明可达;验收模型连接需要收到完整模型回复。
+
 ## 权限和生命周期
 ## 权限和生命周期
 
 
 - 控制面在执行节点、账号、基础 Profile、Agent、聊天成员关系和任务状态上共同检查授权。检查发生在命令准入、电脑操作、续期和产物传输时。
 - 控制面在执行节点、账号、基础 Profile、Agent、聊天成员关系和任务状态上共同检查授权。检查发生在命令准入、电脑操作、续期和产物传输时。

+ 4 - 4
src/runner/worker/gateway.ts

@@ -15,11 +15,11 @@ import {ComposeComputerProvider} from '../computers/compose.js'
 import {COMPOSE_DESKTOP_IMAGE,type DesktopRelay} from '../../shared/composeDesktops.js'
 import {COMPOSE_DESKTOP_IMAGE,type DesktopRelay} from '../../shared/composeDesktops.js'
 import {ComputerControls} from './control.js'
 import {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
-import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
+import {HermesWorkerProcess,type WorkerModel,type WorkerProxyEnvironment,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 {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
 export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
-interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;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}
+interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;proxyEnv?:WorkerProxyEnvironment;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[]=[
@@ -141,7 +141,7 @@ export class ComputerGateway {
       await this.authorized()
       await this.authorized()
       const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
       const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
       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,toolCalls:new Set(),journal:new Map()}
+      this.live={session,lease,controller,model:resolved.model as WorkerModel,proxyEnv:resolved.proxyEnv,images:[],running:false,received:false,toolCalls:new Set(),journal:new Map()}
       this.runtime.save(session);this.runtime.gateways.set(this.meta.environmentId,this)
       this.runtime.save(session);this.runtime.gateways.set(this.meta.environmentId,this)
       if(this.runtime.config.network==='public-proxy'){
       if(this.runtime.config.network==='public-proxy'){
         const live=this.live
         const live=this.live
@@ -188,7 +188,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)
       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))
       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})
       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,network:this.runtime.config.network??'none',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,proxyEnv:live.proxyEnv,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
       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)})
       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)=>{
       worker.onTool=(name,args,id,callId)=>{

+ 13 - 5
src/runner/worker/hermes_worker.py

@@ -18,6 +18,8 @@ WIRE = sys.stdout
 WRITE_LOCK = threading.Lock()
 WRITE_LOCK = threading.Lock()
 BOOT = json.loads(sys.stdin.readline())
 BOOT = json.loads(sys.stdin.readline())
 NONCE = BOOT["nonce"]
 NONCE = BOOT["nonce"]
+PROXY_ENV_KEYS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY",
+                  "http_proxy", "https_proxy", "all_proxy", "no_proxy")
 
 
 def emit(kind, **values):
 def emit(kind, **values):
     with WRITE_LOCK:
     with WRITE_LOCK:
@@ -42,14 +44,19 @@ def load_profile():
     os.environ["HERMES_HOME"] = str(home)
     os.environ["HERMES_HOME"] = str(home)
     sys.path.insert(0, str(source))
     sys.path.insert(0, str(source))
     from dotenv import dotenv_values
     from dotenv import dotenv_values
-    for key, value in dotenv_values(home / ".env").items():
+    profile_env = dotenv_values(home / ".env")
+    for key, value in profile_env.items():
         if value is not None:
         if value is not None:
             os.environ[key] = value
             os.environ[key] = value
+    # Capture only this Profile's values, before Hermes imports can load any
+    # fallback environment. Preserve case and explicit empty values for its SDK.
+    proxy_env = {key: profile_env[key] for key in PROXY_ENV_KEYS
+                 if isinstance(profile_env.get(key), str)}
     from hermes_constants import set_hermes_home_override
     from hermes_constants import set_hermes_home_override
     set_hermes_home_override(str(home))
     set_hermes_home_override(str(home))
     from hermes_cli.config import load_config_readonly
     from hermes_cli.config import load_config_readonly
     try:
     try:
-        return load_config_readonly()
+        return load_config_readonly(), proxy_env
     except Exception:
     except Exception:
         raise ConfigurationError("computer_profile_config_invalid") from None
         raise ConfigurationError("computer_profile_config_invalid") from None
 
 
@@ -75,7 +82,7 @@ def resolve_workspace(config):
 
 
 
 
 def resolve_model():
 def resolve_model():
-    config = load_profile()
+    config, proxy_env = load_profile()
     workspace = resolve_workspace(config)
     workspace = resolve_workspace(config)
     model = config.get("model", {})
     model = config.get("model", {})
     if not isinstance(model, dict) or not model.get("default"):
     if not isinstance(model, dict) or not model.get("default"):
@@ -87,7 +94,8 @@ def resolve_model():
         raise ConfigurationError("computer_model_unavailable") from None
         raise ConfigurationError("computer_model_unavailable") from None
     if runtime.get("api_mode") not in ("chat_completions", "anthropic_messages", "codex_responses"):
     if runtime.get("api_mode") not in ("chat_completions", "anthropic_messages", "codex_responses"):
         raise ConfigurationError("computer_model_unsupported")
         raise ConfigurationError("computer_model_unsupported")
-    emit("resolved", model={key: runtime[key] for key in ("provider", "api_mode", "base_url", "api_key") if key in runtime} | {"model": model["default"]}, **workspace)
+    emit("resolved", model={key: runtime[key] for key in ("provider", "api_mode", "base_url", "api_key") if key in runtime} | {"model": model["default"]}, **workspace,
+         **({"proxyEnv": proxy_env} if proxy_env else {}))
 
 
 
 
 def run_model():
 def run_model():
@@ -193,7 +201,7 @@ def run_model():
 try:
 try:
     with contextlib.redirect_stdout(sys.stderr):
     with contextlib.redirect_stdout(sys.stderr):
         if BOOT.get("mode") == "resolve-workspace":
         if BOOT.get("mode") == "resolve-workspace":
-            emit("resolved", **resolve_workspace(load_profile()))
+            emit("resolved", **resolve_workspace(load_profile()[0]))
         elif BOOT.get("mode") == "resolve":
         elif BOOT.get("mode") == "resolve":
             resolve_model()
             resolve_model()
         else:
         else:

+ 12 - 2
src/runner/worker/process.ts

@@ -5,6 +5,8 @@ import {HttpError} from '../../server/errors.js'
 
 
 export interface WorkerTool {id?:string;name:string;description:string;inputSchema:Record<string,unknown>}
 export interface WorkerTool {id?:string;name:string;description:string;inputSchema:Record<string,unknown>}
 export interface WorkerModel {provider:string;api_mode:string;base_url?:string;api_key?:string;model:string}
 export interface WorkerModel {provider:string;api_mode:string;base_url?:string;api_key?:string;model:string}
+const proxyEnvKeys=['HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','NO_PROXY','http_proxy','https_proxy','all_proxy','no_proxy'] as const
+export type WorkerProxyEnvironment=Partial<Record<typeof proxyEnvKeys[number],string>>
 export interface WorkerFrame {type:string;[key:string]:any}
 export interface WorkerFrame {type:string;[key:string]:any}
 const configurationErrors:Record<string,string>={
 const configurationErrors:Record<string,string>={
   computer_profile_invalid:'基础 Profile 名称无效',
   computer_profile_invalid:'基础 Profile 名称无效',
@@ -26,7 +28,15 @@ export class HermesWorkerProcess {
   onEvent:(frame:WorkerFrame)=>void=()=>{}
   onEvent:(frame:WorkerFrame)=>void=()=>{}
   onTool:(name:string,args:unknown,id:string,callId?:string)=>Promise<unknown>=async()=>{throw new Error('工具未授权')}
   onTool:(name:string,args:unknown,id:string,callId?:string)=>Promise<unknown>=async()=>{throw new Error('工具未授权')}
   constructor(python:string,script:string,input:Record<string,unknown>,onDiagnostic?:(text:string)=>void){
   constructor(python:string,script:string,input:Record<string,unknown>,onDiagnostic?:(text:string)=>void){
-    this.child=spawn(python,['-u',script],{stdio:'pipe',env:{PATH:process.env.PATH??'/usr/bin:/bin',HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}})
+    const {proxyEnv,...boot}=input
+    const env:NodeJS.ProcessEnv={PATH:process.env.PATH??'/usr/bin:/bin',HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}
+    // Only the selected Profile's proxy snapshot crosses this boundary. Never
+    // inherit the Runner's proxy settings, credentials or Python overrides.
+    if(proxyEnv&&typeof proxyEnv==='object'&&!Array.isArray(proxyEnv))for(const key of proxyEnvKeys){
+      const value=(proxyEnv as Record<string,unknown>)[key]
+      if(typeof value==='string')env[key]=value
+    }
+    this.child=spawn(python,['-u',script],{stdio:'pipe',env})
     if(onDiagnostic)this.child.stderr.on('data',chunk=>onDiagnostic(String(chunk)));else this.child.stderr.resume()
     if(onDiagnostic)this.child.stderr.on('data',chunk=>onDiagnostic(String(chunk)));else this.child.stderr.resume()
     this.exited=new Promise(resolve=>{
     this.exited=new Promise(resolve=>{
       const done=()=>{if(this.finished)return;this.finished=true;this.fail(new Error('Hermes Worker 已退出'));resolve()}
       const done=()=>{if(this.finished)return;this.finished=true;this.fail(new Error('Hermes Worker 已退出'));resolve()}
@@ -38,7 +48,7 @@ export class HermesWorkerProcess {
       for(;;){const end=this.output.indexOf('\n');if(end<0)break;const line=this.output.slice(0,end);this.output=this.output.slice(end+1);try{const frame=JSON.parse(line);if(frame.nonce===this.nonce)this.receive(frame)}catch{/* non-protocol output has no authority */}}
       for(;;){const end=this.output.indexOf('\n');if(end<0)break;const line=this.output.slice(0,end);this.output=this.output.slice(end+1);try{const frame=JSON.parse(line);if(frame.nonce===this.nonce)this.receive(frame)}catch{/* non-protocol output has no authority */}}
     })
     })
     this.child.stdin.on('error',()=>{})
     this.child.stdin.on('error',()=>{})
-    this.child.stdin.write(JSON.stringify({...input,nonce:this.nonce})+'\n')
+    this.child.stdin.write(JSON.stringify({...boot,nonce:this.nonce})+'\n')
   }
   }
   private receive(frame:WorkerFrame){
   private receive(frame:WorkerFrame){
     const waiting=this.waiters.get(frame.type)
     const waiting=this.waiters.get(frame.type)

+ 85 - 0
tests/live/workerProxy.live.test.ts

@@ -0,0 +1,85 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {createServer,type IncomingMessage,type ServerResponse,type Server} from 'node:http'
+import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {HermesWorkerProcess} from '../../src/runner/worker/process'
+
+afterEach(()=>vi.unstubAllEnvs())
+const enabled=!!process.env.YAOYAO_HERMES_PYTHON&&!!process.env.YAOYAO_HERMES_SOURCE
+const listen=(server:Server)=>new Promise<number>(resolve=>server.listen(0,'127.0.0.1',()=>resolve((server.address() as {port:number}).port)))
+const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
+
+it.skipIf(!enabled).each(['uppercase','lowercase','no-proxy','unconfigured'] as const)('uses the actual Hermes loop with Profile proxy policy: %s',async mode=>{
+ const root=await mkdtemp(join(tmpdir(),'yaoyao-worker-proxy-live-')),hermesHome=join(root,'hermes'),workerHome=join(root,'worker')
+ const profile=mode==='uppercase'?'default':'writer',profileHome=profile==='default'?hermesHome:join(hermesHome,'profiles',profile)
+ await mkdir(profileHome,{recursive:true})
+ const proxyRequests:Array<{url:string;auth?:string}>=[],directRequests:string[]=[],modelRequests:any[]=[]
+ const reply=async(req:IncomingMessage,res:ServerResponse)=>{
+  const url=new URL(req.url!,'http://fixture')
+  if(req.method==='GET'&&url.pathname==='/v1/models'){
+   res.setHeader('Content-Type','application/json');res.end(JSON.stringify({data:[{id:'fixture-model',object:'model',context_length:65536}]}));return
+  }
+  if(req.method!=='POST'||url.pathname!=='/v1/chat/completions'){res.statusCode=404;res.end('{}');return}
+  let body='';for await(const chunk of req)body+=chunk
+  const input=JSON.parse(body);modelRequests.push(input)
+  if(req.headers.authorization!=='Bearer fixture-model-key'){res.statusCode=401;res.end('{}');return}
+  const message={role:'assistant',content:'代理链路已完成完整回复。'}
+  if(input.stream){
+   res.setHeader('Content-Type','text/event-stream')
+   res.end('data: '+JSON.stringify({id:'fixture-response',object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:message,finish_reason:'stop'}]})+'\n\ndata: [DONE]\n\n')
+  }else{
+   res.setHeader('Content-Type','application/json')
+   res.end(JSON.stringify({id:'fixture-response',object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
+  }
+ }
+ // The HTTP proxy terminates a fixture-only origin. The .invalid name cannot
+ // resolve directly, so a successful model response proves proxy traversal.
+ const proxy=createServer((req,res)=>{
+  proxyRequests.push({url:req.url!,auth:req.headers['proxy-authorization']})
+  if(!req.url?.startsWith('http://worker-proxy.invalid/')){res.statusCode=502;res.end('{}');return}
+  void reply(req,res).catch(()=>{res.statusCode=500;res.end('{}')})
+ })
+ const direct=createServer((req,res)=>{directRequests.push(req.url!);void reply(req,res).catch(()=>{res.statusCode=500;res.end('{}')})})
+ const workers:HermesWorkerProcess[]=[]
+ let diagnostics=''
+ try{
+  const [proxyPort,directPort]=await Promise.all([listen(proxy),listen(direct)])
+  const proxyURL=`http://fixture-user:fixture-proxy-password@127.0.0.1:${proxyPort}`
+  const proxied=mode==='uppercase'||mode==='lowercase'
+  const baseURL=proxied?'http://worker-proxy.invalid/v1':`http://127.0.0.1:${directPort}/v1`
+  // A Runner-level proxy must neither override a Profile nor become a fallback.
+  for(const key of ['HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy'])vi.stubEnv(key,proxyURL)
+  for(const key of ['NO_PROXY','no_proxy'])vi.stubEnv(key,'')
+  await writeFile(join(profileHome,'config.yaml'),`model:\n  provider: custom\n  default: fixture-model\n  base_url: ${baseURL}\n  api_key: \${WORKER_FIXTURE_KEY}\n  api_mode: chat_completions\nterminal:\n  cwd: /home/cua/workspace\n`,{mode:0o600})
+  const proxyVars=mode==='uppercase'?`HTTP_PROXY=\${FIXTURE_PROXY_URL}\nHTTPS_PROXY=\${FIXTURE_PROXY_URL}\nNO_PROXY=\n`:mode==='lowercase'?`http_proxy=\${FIXTURE_PROXY_URL}\nhttps_proxy=\${FIXTURE_PROXY_URL}\nno_proxy=\n`:mode==='no-proxy'?`HTTP_PROXY=\${FIXTURE_PROXY_URL}\nHTTPS_PROXY=\${FIXTURE_PROXY_URL}\nNO_PROXY=127.0.0.1,localhost\n`:''
+  await writeFile(join(profileHome,'.env'),`WORKER_FIXTURE_KEY=fixture-model-key\nFIXTURE_PROXY_URL=${proxyURL}\nUNRELATED_SECRET=fixture-unrelated-secret\n${proxyVars}`,{mode:0o600})
+  const resolver=new HermesWorkerProcess(process.env.YAOYAO_HERMES_PYTHON!,resolve('src/runner/worker/hermes_worker.py'),{mode:'resolve',profile,hermesHome,hermesSource:process.env.YAOYAO_HERMES_SOURCE})
+  workers.push(resolver)
+  const resolved=await resolver.wait('resolved');await resolver.close()
+  expect(resolved.proxyEnv??{}).not.toHaveProperty('UNRELATED_SECRET')
+  expect(resolved.proxyEnv??{}).not.toHaveProperty('FIXTURE_PROXY_URL')
+  if(mode==='unconfigured')expect(resolved).not.toHaveProperty('proxyEnv')
+  const worker=new HermesWorkerProcess(process.env.YAOYAO_HERMES_PYTHON!,resolve('src/runner/worker/hermes_worker.py'),{diagnostic:true,mode:'run',home:workerHome,hermesSource:process.env.YAOYAO_HERMES_SOURCE,model:resolved.model,proxyEnv:resolved.proxyEnv,sessionId:randomUUID(),taskId:randomUUID(),cwd:resolved.cwd,tools:[],prompt:'返回测试回复。',history:[]},text=>{diagnostics+=text})
+  workers.push(worker)
+  const frames:unknown[]=[];worker.onEvent=frame=>frames.push(frame)
+  const complete=await worker.wait('complete',45000).catch(error=>{throw new Error(`${String(error)}\n${diagnostics}`)})
+  expect(complete).toMatchObject({text:'代理链路已完成完整回复。',completed:true})
+  expect(modelRequests.length).toBeGreaterThan(0)
+  if(proxied){
+   expect(proxyRequests.some(request=>request.url==='http://worker-proxy.invalid/v1/chat/completions')).toBe(true)
+   expect(proxyRequests.find(request=>request.url.endsWith('/chat/completions'))?.auth).toBe('Basic '+Buffer.from('fixture-user:fixture-proxy-password').toString('base64'))
+   expect(directRequests).toEqual([])
+  }else{
+   expect(directRequests).toContain('/v1/chat/completions')
+   expect(proxyRequests).toEqual([])
+  }
+  expect(JSON.stringify(frames)+diagnostics).not.toMatch(/fixture-proxy-password|fixture-unrelated-secret|proxyEnv/)
+ }finally{
+  await Promise.all(workers.map(worker=>worker.close()))
+  await Promise.all([close(proxy),close(direct)])
+  await rm(root,{recursive:true,force:true})
+ }
+},60000)

+ 74 - 0
tests/server/workerProxy.test.ts

@@ -0,0 +1,74 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,writeFile,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import type {WorkerProxyEnvironment} from '../../src/runner/worker/process'
+
+it('keeps proxy settings private, reuses them after takeover, and refreshes them when resuming a session',async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-worker-proxy-')),db=new DatabaseSync(':memory:'),script=join(home,'probe.py')
+ // Exercise the real Gateway, process launcher and lease lifecycle; replace
+ // only the model loop and the VM provider. The probe stays on the private pipe.
+ await writeFile(script,`import json,os,sys
+boot=json.loads(sys.stdin.readline())
+print(json.dumps({"nonce":boot["nonce"],"type":"probe","env":{k:v for k,v in os.environ.items() if k.lower().endswith("_proxy")},"boot":boot}),flush=True)
+for line in sys.stdin:
+    if json.loads(line).get("type")=="interrupt": break
+`)
+ const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'python3',hermesSource:home,hermesHome:home,network:'none'}},home,script)
+ const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner'},gateways:ComputerGateway[]=[],events:unknown[]=[]
+ const first={HTTPS_PROXY:'http://user:first-private-proxy@127.0.0.1:7890',NO_PROXY:'localhost'}
+ const second={https_proxy:'http://user:second-private-proxy@127.0.0.1:8890',no_proxy:'internal.test'}
+ let current:WorkerProxyEnvironment|undefined=first
+ const createGateway=()=>{
+  const gateway=new ComputerGateway(runtime,meta,randomUUID(),async()=>{},async()=>({}))
+  gateway.onEvent=frame=>events.push(frame);gateways.push(gateway);return gateway
+ }
+ const probe=(gateway:ComputerGateway)=>runtime.workers.get(gateway.workId)!.process.wait('probe',5000)
+ try{
+  await runtime.ready
+  const resolver=vi.spyOn(runtime,'resolve').mockImplementation(async()=>({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.',model:{provider:'custom',api_mode:'chat_completions',model:'fixture'},...(current?{proxyEnv:{...current}}:{})}))
+  const state={id,containerId:'fixture',running:true,workspace:home,isolation:'container' as const}
+  const ensure=vi.spyOn(runtime.provider,'ensure').mockResolvedValue(state)
+  vi.spyOn(runtime.provider,'inspect').mockResolvedValue(state)
+  vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
+  const gateway=createGateway();await gateway.connect()
+  const created=await gateway.rpc('session.create',{profile:'default'})
+  await gateway.rpc('prompt.submit',{session_id:created.session_id,text:'first turn'})
+  expect((await probe(gateway)).env).toEqual(first)
+  current=second
+  await gateway.takeControl(randomUUID(),()=>{})
+  await gateway.giveBack('continue')
+  const continued=await probe(gateway)
+  expect(continued.env).toEqual(first)
+  expect(continued.boot.history).toContainEqual({role:'user',content:'first turn'})
+  expect(resolver).toHaveBeenCalledTimes(1)
+  await gateway.close()
+
+  const resumed=createGateway();await resumed.connect()
+  expect((await resumed.rpc('session.resume',{profile:'default',session_id:created.session_id})).session_id).toBe(created.session_id)
+  await resumed.rpc('prompt.submit',{session_id:created.session_id,text:'next turn'})
+  expect((await probe(resumed)).env).toEqual(second)
+  expect(resolver).toHaveBeenCalledTimes(2)
+  await resumed.close()
+
+  current=undefined
+  const direct=createGateway();await direct.connect()
+  await direct.rpc('session.resume',{profile:'default',session_id:created.session_id})
+  await direct.rpc('prompt.submit',{session_id:created.session_id,text:'direct turn'})
+  const frame=await probe(direct)
+  expect(frame.env).toEqual({})
+  expect(frame.boot.model).not.toHaveProperty('proxyEnv')
+  expect(frame.boot).not.toHaveProperty('proxyEnv')
+  expect(resolver).toHaveBeenCalledTimes(3)
+  const persisted=db.prepare('SELECT value FROM computer_sessions').all()
+  for(const value of [created,persisted,events,ensure.mock.calls])expect(JSON.stringify(value)).not.toMatch(/private-proxy|proxyEnv|HTTPS_PROXY|https_proxy/)
+ }finally{
+  await Promise.all(gateways.map(gateway=>gateway.close()))
+  await runtime.controls.close();await runtime.pool.close();db.close()
+  vi.restoreAllMocks();await rm(home,{recursive:true,force:true})
+ }
+})

+ 48 - 3
tests/server/workerResolution.test.ts

@@ -1,5 +1,5 @@
 // @vitest-environment node
 // @vitest-environment node
-import {afterEach,beforeEach,expect,it} from 'vitest'
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
 import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
 import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'
 import {tmpdir,homedir} from 'node:os'
 import {tmpdir,homedir} from 'node:os'
 import {join,resolve} from 'node:path'
 import {join,resolve} from 'node:path'
@@ -12,12 +12,12 @@ beforeEach(async()=>{
  await mkdir(join(source,'hermes_cli'),{recursive:true});await mkdir(home)
  await mkdir(join(source,'hermes_cli'),{recursive:true});await mkdir(home)
  // Only replace the installed Hermes config/SDK boundary. Exercise the real
  // Only replace the installed Hermes config/SDK boundary. Exercise the real
  // Python reader and Node protocol, including failures and secret filtering.
  // Python reader and Node protocol, including failures and secret filtering.
- await writeFile(join(source,'dotenv.py'),'def dotenv_values(path): return {}\n')
+ await writeFile(join(source,'dotenv.py'),'import json\ndef dotenv_values(path): return json.loads(path.read_text()) if path.exists() else {}\n')
  await writeFile(join(source,'hermes_constants.py'),'def set_hermes_home_override(path): pass\n')
  await writeFile(join(source,'hermes_constants.py'),'def set_hermes_home_override(path): pass\n')
  await writeFile(join(source,'hermes_cli','config.py'),'import os,json\nfrom pathlib import Path\ndef load_config_readonly(): return json.loads((Path(os.environ["HERMES_HOME"])/"config.yaml").read_text())\n')
  await writeFile(join(source,'hermes_cli','config.py'),'import os,json\nfrom pathlib import Path\ndef load_config_readonly(): return json.loads((Path(os.environ["HERMES_HOME"])/"config.yaml").read_text())\n')
  await writeFile(join(source,'hermes_cli','runtime_provider.py'),'raise RuntimeError("provider secret must never escape")\n')
  await writeFile(join(source,'hermes_cli','runtime_provider.py'),'raise RuntimeError("provider secret must never escape")\n')
 })
 })
-afterEach(async()=>{await Promise.all(workers.splice(0).map(w=>w.close()));await rm(root,{recursive:true,force:true})})
+afterEach(async()=>{await Promise.all(workers.splice(0).map(w=>w.close()));vi.unstubAllEnvs();await rm(root,{recursive:true,force:true})})
 function read(mode='resolve-workspace',profile='default'){
 function read(mode='resolve-workspace',profile='default'){
  const worker=new HermesWorkerProcess('python3',resolve('src/runner/worker/hermes_worker.py'),{mode,profile,hermesSource:source,hermesHome:home})
  const worker=new HermesWorkerProcess('python3',resolve('src/runner/worker/hermes_worker.py'),{mode,profile,hermesSource:source,hermesHome:home})
  workers.push(worker);return worker.wait('resolved')
  workers.push(worker);return worker.wait('resolved')
@@ -53,3 +53,48 @@ it('still requires a usable model for an Agent run and does not expose SDK detai
  await expect(read('resolve')).rejects.toMatchObject({code:'computer_model_unavailable'})
  await expect(read('resolve')).rejects.toMatchObject({code:'computer_model_unavailable'})
  try{await read('resolve')}catch(error){expect(String(error)).not.toContain('provider secret')}
  try{await read('resolve')}catch(error){expect(String(error)).not.toContain('provider secret')}
 })
 })
+
+it('resolves only each selected Profile proxy snapshot, preserving case and explicit empty values',async()=>{
+ const profiles={
+  default:{HTTP_PROXY:'http://user:proxy-secret@127.0.0.1:7890',HTTPS_PROXY:'http://127.0.0.1:7891',ALL_PROXY:'socks5://127.0.0.1:7892',NO_PROXY:'localhost,127.0.0.1,::1'},
+  writer:{http_proxy:'http://127.0.0.1:8890',https_proxy:'http://127.0.0.1:8891',all_proxy:'',no_proxy:'internal.test'},
+  direct:{},
+ }
+ vi.stubEnv('HTTPS_PROXY','http://runner-secret@127.0.0.1:9999')
+ // A provider import must not change the already captured Profile snapshot.
+ await writeFile(join(source,'hermes_cli','runtime_provider.py'),'import os\nos.environ["HTTPS_PROXY"]="http://fallback-secret@127.0.0.1:9998"\ndef resolve_runtime_provider(**kwargs): return {"provider":"custom","api_mode":"chat_completions","api_key":"model-private-key"}\n')
+ for(const [profile,proxyEnv] of Object.entries(profiles)){
+  const directory=profile==='default'?home:join(home,'profiles',profile)
+  await mkdir(directory,{recursive:true})
+  await writeFile(join(directory,'config.yaml'),JSON.stringify({model:{default:'fixture',provider:'custom'}}))
+  await writeFile(join(directory,'.env'),JSON.stringify({...proxyEnv,UNRELATED_SECRET:'profile-private-key',BARE_KEY:null}))
+ }
+ const frames=await Promise.all(Object.keys(profiles).map(profile=>read('resolve',profile)))
+ frames.forEach((frame,index)=>{
+  expect(frame.proxyEnv??{}).toEqual(Object.values(profiles)[index])
+  expect(frame.model).toEqual({provider:'custom',api_mode:'chat_completions',api_key:'model-private-key',model:'fixture'})
+  expect(JSON.stringify(frame)).not.toMatch(/profile-private-key|runner-secret|fallback-secret/)
+ })
+ expect(frames[2]).not.toHaveProperty('proxyEnv')
+ expect(await read('resolve-workspace')).not.toHaveProperty('proxyEnv')
+ await rm(join(home,'.env'))
+ expect(await read('resolve')).not.toHaveProperty('proxyEnv')
+})
+
+it('starts Python with only explicit proxy keys and never inherits Runner secrets or proxy variables',async()=>{
+ const proxyEnv={HTTP_PROXY:'http://user:proxy-secret@127.0.0.1:7890',HTTPS_PROXY:'',ALL_PROXY:'socks5://127.0.0.1:7891',NO_PROXY:'localhost,127.0.0.1',http_proxy:'',https_proxy:'http://127.0.0.1:7892',all_proxy:'',no_proxy:'internal.test'}
+ for(const key of Object.keys(proxyEnv))vi.stubEnv(key,'runner-private-proxy')
+ vi.stubEnv('WORKER_SECRET','runner-private-key')
+ const script=join(root,'environment.py')
+ await writeFile(script,'import os,sys,json\nboot=json.loads(sys.stdin.readline())\nprint(json.dumps({"nonce":boot["nonce"],"type":"resolved","env":dict(os.environ),"boot":boot}),flush=True)\n')
+ for(const configured of [proxyEnv,undefined]){
+  const worker=new HermesWorkerProcess('python3',script,{proxyEnv:configured?{...configured,WORKER_SECRET:'injected-private-key',PYTHONPATH:'/forbidden',HOME:'/forbidden',PATH:'/forbidden'}:undefined})
+  workers.push(worker)
+  const frame=await worker.wait('resolved')
+  for(const key of Object.keys(proxyEnv))expect(frame.env[key]).toBe(configured?.[key as keyof typeof proxyEnv])
+  expect(frame.env).toMatchObject({HOME:homedir(),PATH:process.env.PATH,PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'})
+  expect(frame.env).not.toHaveProperty('WORKER_SECRET')
+  expect(frame.env).not.toHaveProperty('PYTHONPATH')
+  expect(frame.boot).not.toHaveProperty('proxyEnv')
+ }
+})