Explorar el Código

feat(computer): 允许虚拟机使用 root

Samien hace 1 mes
padre
commit
00403fbc5a

+ 0 - 2
compose.desktops.cursor.yaml

@@ -59,8 +59,6 @@ services:
     mem_limit: 4g
     cpus: 2
     pids_limit: 512
-    security_opt:
-      - no-new-privileges:true
     cap_drop:
       - ALL
     cap_add:

+ 0 - 4
compose.desktops.yaml

@@ -59,8 +59,6 @@ services:
     mem_limit: 4g
     cpus: 2
     pids_limit: 512
-    security_opt:
-      - no-new-privileges:true
     cap_drop:
       - ALL
     cap_add:
@@ -88,8 +86,6 @@ services:
     mem_limit: 4g
     cpus: 2
     pids_limit: 512
-    security_opt:
-      - no-new-privileges:true
     cap_drop:
       - ALL
     cap_add:

+ 13 - 8
deploy/computer/compose_bridge.py

@@ -34,7 +34,8 @@ LEASE = None
 RESTARTING = False
 DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
 CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
-ENV = {'PATH': '/opt/venv/bin:/usr/local/bin:/usr/bin:/bin', 'HOME': '/home/cua', 'USER': 'cua', 'DISPLAY': ':1'}
+BASE_ENV = {'PATH': '/opt/venv/bin:/usr/local/bin:/usr/bin:/bin', 'DISPLAY': ':1'}
+ENV = {**BASE_ENV, 'HOME': '/home/cua', 'USER': 'cua'}
 
 def reset_desktop():
     global RESTARTING, LEASE
@@ -52,14 +53,15 @@ def current(body):
         raise ValueError('桌面控制授权已失效')
     return LEASE
 
-def start(argv, cwd):
-    # Change directory after dropping privileges: the persistent workspace is
-    # owned by cua and the bridge does not need DAC_OVERRIDE.
+def start(argv, cwd, user='cua'):
+    # The bridge itself runs as root; commands run in the explicitly selected
+    # guest account without acquiring additional container capabilities.
     wrapper = 'import os,sys;os.chdir(sys.argv[1]);os.execvpe(sys.argv[2],sys.argv[2:],os.environ)'
-    return subprocess.Popen(['python3','-c',wrapper,cwd,*argv], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=ENV, user=1000, group=1000, extra_groups=[], start_new_session=True)
+    uid, environment = (0, {**BASE_ENV, 'HOME': '/root', 'USER': 'root'}) if user == 'root' else (1000, ENV)
+    return subprocess.Popen(['python3','-c',wrapper,cwd,*argv], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment, user=uid, group=uid, extra_groups=[], start_new_session=True)
 
-def run(argv, cwd='/home/cua/workspace', data=None, timeout=30):
-    process = start(argv, cwd)
+def run(argv, cwd='/home/cua/workspace', data=None, timeout=30, user='cua'):
+    process = start(argv, cwd, user)
     try:
         stdout, stderr = process.communicate(data, timeout=timeout)
     except subprocess.TimeoutExpired:
@@ -143,11 +145,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
                 cwd = body.get('cwd', '/home/cua/workspace')
                 if not isinstance(cwd, str) or (cwd != '/home/cua/workspace' and not cwd.startswith('/home/cua/workspace/')) or '..' in Path(cwd).parts:
                     raise ValueError('Compose 桌面工作目录必须位于 /home/cua/workspace')
+                user = body.get('user', 'cua')
+                if user not in ('cua', 'root'):
+                    raise ValueError('桌面命令用户无效')
                 with LOCK:
                     lease = current(body)
                     # Spawn while holding the lease lock, then release the lock
                     # before waiting so revocation can stop the container.
-                    process = start(argv, cwd)
+                    process = start(argv, cwd, user)
                     lease['active'] += 1
                 try:
                     data = base64.b64decode(body['input']) if body.get('input') else None

+ 2 - 2
docs/computer-provider.md

@@ -7,8 +7,8 @@
 - Docker 和 Podman 使用同一份接口与配置校验;远程引擎要求将 Runner 放到目的电脑,避免把本机目录误当作远程挂载路径。
 - 镜像必须使用完整不可变 ID,并验证兼容的 CUA 驱动标签。当前采用本机已有的 OpenMausBot CUA 0.20.0、layer 5 镜像接口;没有复制或修改参考仓库。
 - 环境运行在容器隔离层。macOS 上 Docker/Podman 的底层 Linux 虚拟机由运行时提供,每个环境不是一台独占内核的 VM。
-- 仅挂载该环境的持久工作目录;不挂载 Docker socket、宿主 HOME、Hermes 配置或凭据。命令固定以 guest UID/GID 1000 执行,工作目录为 `/home/cua/workspace`。
-- 固定独立 IPC/cgroup 命名空间、禁止提权、收紧 capability,并设置 CPU、内存、swap 与进程数上限。每次执行前核对运行实例、镜像、归属、挂载与约束。
+- 仅挂载该环境的持久工作目录;不挂载 Docker socket、宿主 HOME、Hermes 配置或凭据。命令默认以 guest UID/GID 1000 执行,也可显式选择 root;工作目录为 `/home/cua/workspace`。
+- 固定独立 IPC/cgroup 命名空间并收紧 capability,同时设置 CPU、内存、swap 与进程数上限。每次执行前核对运行实例、镜像、归属、挂载与约束;Bot 可显式选择 cua 或 root 用户执行 Linux 命令。
 - 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。可通过专属进程通道启用受控公网代理,仍不增加外部网卡或端口。详见 `docs/computer-network.md`。
 - 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。查看和人工接管已接入账号鉴权及独立控制租约,见 `docs/computer-control.md`。
 - 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。

+ 0 - 2
scripts/generate-desktop-compose.mjs

@@ -37,8 +37,6 @@ ${recipe.platform?`    platform: ${recipe.platform}\n`:''}    build:
     mem_limit: 4g
     cpus: 2
     pids_limit: 512
-    security_opt:
-      - no-new-privileges:true
     cap_drop:
       - ALL
     cap_add:

+ 2 - 2
src/runner/computers/compose.ts

@@ -33,10 +33,10 @@ export class ComposeComputerProvider extends ContainerComputerProvider {
   async stop(spec:ComputerSpecification){await this.releaseFence(spec)}
   async remove(spec:ComputerSpecification){await this.stop(spec)}
   async deleteWorkspace(_spec:ComputerSpecification){} // Compose owns the named volume.
-  async execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:string;mayFence?():boolean}):Promise<CommandResult>{
+  async execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:string;mayFence?():boolean;user?:'cua'|'root'}):Promise<CommandResult>{
     options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
     const lease=this.leases.get(spec.id);if(!lease)throw new ComputerError('computer_lease_stale','需要当前共享桌面的控制权')
-    const result=await this.relay(spec.id,'execute',{ownerKey:spec.ownerKey,...lease,argv,cwd:spec.cwd??'/home/cua/workspace',timeout:options.timeout??30000,...(options.input?{input:options.input.toString('base64')}:{})})
+    const result=await this.relay(spec.id,'execute',{ownerKey:spec.ownerKey,...lease,argv,cwd:spec.cwd??'/home/cua/workspace',timeout:options.timeout??30000,user:options.user??'cua',...(options.input?{input:options.input.toString('base64')}:{})})
     options.authorize();if(result.exitCode)throw Object.assign(new ComputerError('computer_command_failed','桌面命令执行失败'),{code:result.exitCode,stdout:result.stdout,stderr:result.stderr})
     return result
   }

+ 12 - 6
src/runner/computers/container.ts

@@ -38,6 +38,7 @@ export interface ComputerState {id:string;containerId:string;running:boolean;wor
  * concurrent readers, desktop driver input is serialized, and lifecycle
  * operations (start/stop/reconfigure) drain every reader first. */
 export type ComputerLane='exec'|'gui'
+export type ComputerUser='cua'|'root'
 export interface ComputerProvider {
   releaseFence?(spec:ComputerSpecification,keepRunning?:boolean):Promise<void>
   renewFence?(spec:ComputerSpecification):Promise<void>
@@ -47,7 +48,7 @@ export interface ComputerProvider {
   ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
   stop(spec:ComputerSpecification):Promise<void>
   remove(spec:ComputerSpecification):Promise<void>
-  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:ComputerLane;mayFence?():boolean}):Promise<CommandResult>
+  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:ComputerLane;mayFence?():boolean;user?:ComputerUser}):Promise<CommandResult>
   health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
 }
 class Semaphore {
@@ -153,13 +154,12 @@ export class ContainerComputerProvider implements ComputerProvider {
     this.assertOwned(spec,detail)
     const host=detail.HostConfig??{},expectedCaps=this.runtime==='podman'?['SETGID','SETUID','SYS_CHROOT']:['SETGID','SETUID']
     const nanoCPUs=host.NanoCpus??(host.CpuPeriod>0?host.CpuQuota/host.CpuPeriod*1e9:0)
-    const noPrivilege=(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))
     const ports=Object.entries(host.PortBindings??{}),mounts=detail.Mounts??[]
     const destinations=spec.cwd===COMPUTER_WORKSPACE?[COMPUTER_WORKSPACE]:[COMPUTER_WORKSPACE,spec.cwd]
     if(detail.Config?.Labels?.[specLabel]!==digest(spec)||detail.Image!==spec.imageId||host.Privileged!==false||host.Memory!==spec.memoryMiB*mib||host.MemorySwap!==spec.memoryMiB*mib
       ||nanoCPUs!==spec.cpus*1e9||host.PidsLimit!==spec.pids||host.IpcMode!=='private'||host.CgroupnsMode!=='private'
       ||host.NetworkMode!=='none'||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
-      ||!noPrivilege||(host.SecurityOpt??[]).some((value:string)=>/unconfined/.test(value))
+      ||(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value)||/unconfined/.test(value))
       ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
       ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
       ||mounts.length!==destinations.length||mounts.some((mount:any)=>mount.Type!=='bind'||mount.Source!==workspace||!destinations.includes(mount.Destination)||mount.RW!==true)||new Set(mounts.map((mount:any)=>mount.Destination)).size!==destinations.length
@@ -197,7 +197,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       ...Object.entries(this.labels(spec)).flatMap(([key,value])=>['--label',`${key}=${value}`]),
       '--restart','no','--network','none','--ipc','private','--cgroupns','private',
       '--memory',`${spec.memoryMiB}m`,'--memory-swap',`${spec.memoryMiB}m`,'--cpus',String(spec.cpus),'--pids-limit',String(spec.pids),
-      '--shm-size','512m','--security-opt','no-new-privileges:true','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
+      '--shm-size','512m','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
       ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
       ...[...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']:[]),
@@ -209,6 +209,11 @@ export class ContainerComputerProvider implements ComputerProvider {
       authorize();await this.verifyRuntime();authorize()
       const workspace=await this.workspace(spec)
       let detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
+      if(detail?.HostConfig?.SecurityOpt?.some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))) {
+        await this.stopOwned(spec,detail.Id)
+        await this.run(this.runtime,['rm','--',detail.Id])
+        detail=undefined
+      }
       if(!detail) {
         const image=JSON.parse((await this.run(this.runtime,['image','inspect',spec.imageId])).stdout)[0]
         if(image?.Id!==spec.imageId||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')
@@ -251,7 +256,7 @@ export class ContainerComputerProvider implements ComputerProvider {
       // Persistent workspace deletion is a separate, explicit operation.
     })
   }
-  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:ComputerLane;mayFence?():boolean}):Promise<CommandResult> {
+  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:ComputerLane;mayFence?():boolean;user?:ComputerUser}):Promise<CommandResult> {
     const spec=this.validateSpecification(value)
     if(!argv.length||argv.length>64||argv.some(arg=>typeof arg!=='string'||arg.includes('\0'))||argv.join('').length>65536)throw new ComputerError('computer_command_invalid','电脑命令无效或过长')
     return this.lane(spec.id).read(async()=>{
@@ -260,7 +265,8 @@ export class ContainerComputerProvider implements ComputerProvider {
       if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
       options.authorize()
       try {
-        const result=await this.run(this.runtime,['exec',...(options.input?['-i']:[]),'--user','1000:1000','--workdir',spec.cwd,'--',state.containerId,...argv],{timeout:Math.min(60000,Math.max(1000,options.timeout??30000)),signal:options.signal,input:options.input})
+        const user=options.user??'cua',uid=user==='root'?'0:0':'1000:1000',home=user==='root'?'/root':'/home/cua'
+        const result=await this.run(this.runtime,['exec',...(options.input?['-i']:[]),'--user',uid,'--env',`HOME=${home}`,'--env',`USER=${user}`,'--workdir',spec.cwd,'--',state.containerId,...argv],{timeout:Math.min(60000,Math.max(1000,options.timeout??30000)),signal:options.signal,input:options.input})
         options.authorize();return result
       }catch(error){
         // Killing a docker exec client alone cannot prove its guest process stopped.

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

@@ -28,7 +28,7 @@ const object=(properties:Record<string,unknown>,required:string[])=>({type:'obje
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
   {name:'computer_export',description:'将隔离电脑中的文件作为可下载附件回传当前会话,最大 25 MiB。',inputSchema:object({path:text,name:text},['path'])},
-  {name:'computer_shell',description:'在当前隔离电脑和 Profile 工作目录执行 shell 命令。',inputSchema:object({command:text},['command'])},
+  {name:'computer_shell',description:'在当前隔离电脑和 Profile 工作目录执行 shell 命令。默认使用 cua 用户;只有安装系统软件或修改系统路径时才选择 root。',inputSchema:object({command:text,user:{type:'string',enum:['cua','root'],default:'cua'}},['command'])},
   {name:'computer_read_file',description:'读取隔离电脑内的 UTF-8 文本文件。',inputSchema:object({path:text},['path'])},
   {name:'computer_write_file',description:'向隔离电脑写入 UTF-8 文本文件。',inputSchema:object({path:text,content:text},['path','content'])},
   {name:'computer_desktop_state',description:'查看隔离电脑当前桌面,返回真实截图。',inputSchema:object({},[])},
@@ -358,7 +358,7 @@ export class ComputerGateway {
         return await this.skillSession.call(name,args)
       }
       return await this.runtime.pool.use(live.lease,async context=>{
-        const execute=(argv:string[],input?:Buffer,lane?:'exec'|'gui')=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input,...(lane?{lane}:{})})
+        const execute=(argv:string[],input?:Buffer,lane?:'exec'|'gui',user?:'cua'|'root')=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input,...(lane?{lane}:{}),...(user?{user}:{})})
         if(this.meta.hostAccess&&name.startsWith('host_'))return hostTool(name,args,{...context,cwd:this.hostCwd})
         if((this.meta.hostAccess||this.hermesManaged)&&name==='computer_copy_file'){
           const body=z.object({from:z.enum(['host','vm']),source:pathField,destination:pathField}).strict().parse(args),host={...context,cwd:this.hostCwd}
@@ -398,8 +398,8 @@ export class ComputerGateway {
           }finally{await execute(['rm','-f','--',snapshot]).catch(()=>{})}
         }
         if(name==='computer_shell'){
-          const body=z.object({command:z.string().min(1).max(65536)}).strict().parse(args)
-          try{return {exitCode:0,...await execute(['/bin/bash','-lc',body.command])}}catch(error){if(typeof(error as any).code==='number')return {exitCode:(error as any).code,stdout:String((error as any).stdout??''),stderr:String((error as any).stderr??'')};throw error}
+          const body=z.object({command:z.string().min(1).max(65536),user:z.enum(['cua','root']).default('cua')}).strict().parse(args)
+          try{return {exitCode:0,...await execute(['/bin/bash','-lc',body.command],undefined,undefined,body.user)}}catch(error){if(typeof(error as any).code==='number')return {exitCode:(error as any).code,stdout:String((error as any).stdout??''),stderr:String((error as any).stderr??'')};throw error}
         }
         if(name==='computer_read_file'){
           const body=z.object({path:pathField}).strict().parse(args)

+ 1 - 1
tests/live/profileSkills.live.test.ts

@@ -24,7 +24,7 @@ it.skipIf(!enabled).each(['local','compose'])('shares real native Profile skills
   await writeFile(join(home,'skills','source-proof','scripts','proof.py'),'print("source-skill-proof")\n')
   try{
     if(mode==='compose'){
-      await docker(['run','-d','--name',fixture,'--network','none','--shm-size','512m','--memory','2g','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID','--cap-add','CHOWN','--cap-add','KILL','--security-opt','no-new-privileges:true',
+      await docker(['run','-d','--name',fixture,'--network','none','--shm-size','512m','--memory','2g','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID','--cap-add','CHOWN','--cap-add','KILL',
         '--env','YAOYAO_COMPOSE_DESKTOP_ID='+id,'--mount',`type=bind,src=${resolve('deploy/computer')},dst=/usr/local/libexec/yaoyao,readonly`,
         '--mount',`type=volume,src=${ipc},dst=/run/yaoyao-private/bridge`,'--mount',`type=volume,src=${ipc},dst=/run/skill-test`,
         '--mount',`type=volume,src=${workspace},dst=/home/cua/workspace`,image])

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

@@ -64,12 +64,14 @@ it('reuses a fixed desktop, rotates authorization and never creates or deletes i
   await pool.recover()
   const first=await pool.acquire(spec,'agent-a',()=>{})
   await pool.use(first,c=>provider.execute(spec,['pwd'],c))
+  await pool.use(first,c=>provider.execute(spec,['id','-u'],{...c,user:'root'}))
   await pool.release(first,true)
   expect(()=>pool.use(first,async()=>{})).toThrow()
   const second=await pool.acquire(spec,'agent-b',()=>{})
   expect(tokens[0]).not.toBe(tokens[1])
   await pool.release(second)
   expect(operations.filter(x=>x.op==='release').map(x=>x.body.cancel)).toEqual([false,true])
+  expect(operations.filter(x=>x.op==='execute').map(x=>x.body.user)).toEqual(['cua','root'])
   expect(engine).not.toHaveBeenCalled()
  }finally{await pool.close();db.close()}
 })

+ 14 - 1
tests/server/computerProvider.test.ts

@@ -43,7 +43,9 @@ it.each(['docker','podman'] as const)('uses the same private ownership and execu
   const f=await fixture(runtime),state=await f.provider.ensure(f.spec,()=>{})
   expect(state).toMatchObject({running:true,isolation:'container'})
   await f.provider.execute(f.spec,['/bin/sh','-c','echo hello'],{authorize:()=>{}})
-  expect(f.calls.find(args=>args[0]==='exec')).toEqual(['exec','--user','1000:1000','--workdir','/home/cua/workspace','--',state.containerId,'/bin/sh','-c','echo hello'])
+  expect(f.calls.find(args=>args[0]==='exec')).toEqual(['exec','--user','1000:1000','--env','HOME=/home/cua','--env','USER=cua','--workdir','/home/cua/workspace','--',state.containerId,'/bin/sh','-c','echo hello'])
+  await f.provider.execute(f.spec,['/bin/sh','-c','id -u'],{authorize:()=>{},user:'root'})
+  expect(f.calls.find(args=>args.includes('0:0'))).toEqual(['exec','--user','0:0','--env','HOME=/root','--env','USER=root','--workdir','/home/cua/workspace','--',state.containerId,'/bin/sh','-c','id -u'])
   await expect(f.provider.ensure({...f.spec,ownerKey:'different-account'},()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
   await f.provider.remove(f.spec)
   expect(f.calls.find(args=>args[0]==='rm')).toEqual(['rm','--',state.containerId])
@@ -66,6 +68,17 @@ it.each([
   await f.provider.stop(f.spec)
   expect(f.detail.State.Running).toBe(false)
 })
+
+it('replaces an existing desktop created with the old no-new-privileges flag',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{})
+  f.detail.HostConfig.SecurityOpt=['no-new-privileges:true'];f.detail.State.Running=true
+  const state=await f.provider.ensure(f.spec,()=>{})
+  expect(state.running).toBe(true)
+  expect(f.detail.HostConfig.SecurityOpt).toEqual([])
+  expect(f.calls.filter(args=>args[0]==='create')).toHaveLength(2)
+  expect(f.calls.some(args=>args[0]==='rm')).toBe(true)
+  expect(f.calls.flat().includes('no-new-privileges:true')).toBe(false)
+})
 it('does not stop or remove an identically named container belonging to another node',async()=>{
   const f=await fixture();await f.provider.ensure(f.spec,()=>{})
   f.detail.Config.Labels['cn.samien.yaoyao.runner']=randomUUID()

+ 3 - 0
tests/server/hostComputerTools.test.ts

@@ -57,6 +57,9 @@ for line in sys.stdin:
     await expect(worker.onTool('computer_copy_file',{from:'host',source:join(home,'private.txt'),destination:'bad.txt'},randomUUID())).rejects.toMatchObject({code:'computer_tool_forbidden'})
    }
    expect(await worker.onTool('computer_shell',{command:'pwd'},randomUUID())).toMatchObject({stdout:'vm-proof'})
+   expect(execute).toHaveBeenLastCalledWith(expect.anything(),['/bin/bash','-lc','pwd'],expect.objectContaining({user:'cua'}))
+   await worker.onTool('computer_shell',{command:'id -u',user:'root'},randomUUID())
+   expect(execute).toHaveBeenLastCalledWith(expect.anything(),['/bin/bash','-lc','id -u'],expect.objectContaining({user:'root'}))
    if(!hostAccess&&gateways.length===3){allowed=false;await expect(worker.onTool('computer_shell',{command:'no'},randomUUID())).rejects.toThrow('revoked')}
    await gateway.close()
   }