ソースを参照

新增隔离电脑 Provider 与持久控制租约

LSamien 1 ヶ月 前
親
コミット
bf16c5cb71

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

@@ -66,3 +66,6 @@
 - 单独启用 `HERMES_TEAM_RUNNER=1` 后,真实 Hermes 隔离协议验收通过,包含原生工具创建成员、组队、结构化分派、验收、完成、结果回传和双管理员工具隔离。执行细节和当前限制见 `docs/runner.md`。这是传输层验收,尚非 VM 或真实模型自主决策验收。
 - 桌面 Runner 阶段:App 新增原生配置导入、系统加密存储、私有进程传参、自动启动与有界重连。开发 App 的 4 项生命周期/凭据测试通过;打包 arm64 App 的真实生命周期测试再次通过,覆盖配置导入、服务崩溃后的重新连接、窗口关闭保留连接、退出清理与再次打开自动连接。
 - 此次打包验收使用 `/tmp/yaoyao-runner-package-130917f/mac-arm64/夭夭.app`,为临时 ad-hoc 签名测试包,严格签名检查通过。未替换 `desktop-release` 中较早的开发证书快照,也未安装、部署、公证或发布。服务全量 680 项通过;配置与 Runner 定向 23 项通过。
+- 电脑 Provider 基础:实现私有容器、归属与约束校验、持久工作目录、guest 执行和取消时的停止确认。15 项 Docker/Podman 离线测试通过;真实 Docker 无网络环境的创建、画面读取、持久文件重建和取消写入验收通过,截图已核对,测试容器已清理。详见 `docs/computer-provider.md`。尚未接入任务资源租约和受控 Worker,VM 功能仍未完成。
+- 电脑租约基础:SQLite 保存持有者、递增代次、期限与停止状态;配额在启动前预占,未确认停止的资源保持占用,取消后退休旧容器 ID。资源池 6 项测试及接入资源池后的真实 Docker 生命周期测试通过。仍未将该资源池接入 Bot 任务和受控 Worker。
+- 当前源码验证:类型检查、711 项全量测试通过;2 项真实环境测试默认按需跳过,已分别执行过真实 Hermes Runner 协议和真实 Docker 电脑/租约验收。所有电脑验收容器均已清理。整体目标仍在进行,下一步是受控 Worker、联网代理及任务/权限接入,随后完成跨端电脑界面与其余交付项。

+ 34 - 0
docs/computer-provider.md

@@ -0,0 +1,34 @@
+# 私有电脑 Provider 基础
+
+实现位于 `src/runner/computers/container.ts`,提供统一的创建、查询、执行、停止和删除接口。每个环境有独立 UUID、账号归属键、Runner 身份、容器和持久工作目录。`pool.ts` 提供持久电脑租约与资源配额。此阶段尚未将电脑分配接入 Bot 任务,也未提供受控 Hermes Worker;这是后续任务接入的执行底座。
+
+## 已实现的约束
+
+- 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 与进程数上限。每次执行前核对运行实例、镜像、归属、挂载与约束。
+- 默认 `--network none`,不发布任何端口。容器只有 loopback 网卡,不能直接访问宿主服务或外网。自己的主机名映射到 loopback,保证 XFCE/VNC 在无网络环境下正常启动。联网代理属于下一阶段。
+- 桌面通过容器内的 CUA socket 操作;验收已读取真实截图。后续查看/接管接口仍需账号鉴权和独立的控制租约。
+- 取消或无法确认的 guest 命令会停止所属私有容器,确认停止后才释放串行执行位置。仅终止 `docker exec` 客户端不能证明 guest 进程已停止。
+- 删除容器保留工作目录,重建后可继续读取工作文件。工作区快照、恢复、显式删除及共享桌面尚未实现。
+
+权限校验以回调传入,在异步检查与实际执行的前后重验。ComputerPool 在 SQLite 中保存持有者、租约代次、期限与停止状态,默认每个 Runner 同时 2 个环境、总计最多 32 个环境。启动时必须先恢复未确认停止的资源;旧租约先失效,再停止并删除旧容器身份,保留工作区。不确认停止的环境保持占用,不能交给新任务。当前 Provider 与资源池都不作为裸命令接口对浏览器或模型开放,尚待接入运行中的任务权限和生命周期。
+
+## 参考与差异
+
+吸收 OpenMausBot `server/container-computer.ts` 的不可变镜像、每 Bot 环境、持久工作目录、能力检查和实际健康/画面验证;默认网络进一步收紧为无网络环境。后续吸收 rakazo 的屏幕租约和接管机制,在任务协调层加入持久 fencing 代次,先确认旧执行停止,再允许新控制者使用资源。
+
+## 验收状态
+
+Provider 的 15 项与 ComputerPool 的 6 项离线测试通过,覆盖 Docker/Podman 参数边界、账号/Runner 归属、挂载/网络/权限/资源篡改、异步撤权、危险目录、配额、取消、重启恢复及过期扫描不误停新的持有者。Podman 尚未做真实运行验收。
+
+真实 Docker 验收通过:创建独立电脑、验证只有 loopback 网卡、guest UID 1000 写入文件、读取 CUA 健康报告和真实桌面截图、删除重建后读取持久文件、通过 ComputerPool 取消延迟写入,确认旧容器被删除,拒绝旧租约继续执行。截图在 `test-results/computer/desktop.png`。每次测试均使用新的 UUID 和临时目录,并清理本轮容器。
+
+```sh
+YAOYAO_COMPUTER_IMAGE=sha256:本机已验证镜像的完整ID \
+  npx vitest run tests/live/computer.live.test.ts
+```
+
+本轮 arm64 验证镜像为 `sha256:574b51992c53e22d48997d4286ba83e47b872de34250276c99d2f390bf3640e0`。独立镜像构建/交付、联网、Worker、任务租约接入和跨端电脑面板仍待完成,不能以本测试替代整个 VM 功能验收。

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

@@ -0,0 +1,200 @@
+import { createHash, randomBytes } from 'node:crypto'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { mkdir, realpath, chmod } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import { z } from 'zod'
+
+export const COMPUTER_WORKSPACE = '/home/cua/workspace'
+export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
+export const CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
+const managedLabel='cn.samien.yaoyao.computer', runnerLabel='cn.samien.yaoyao.runner', specLabel='cn.samien.yaoyao.computer-spec', ownerLabel='cn.samien.yaoyao.computer-owner'
+const mib=1024*1024
+const execAsync=promisify(execFile)
+export interface CommandResult {stdout:string;stderr:string}
+export type ContainerCommand=(runtime:'docker'|'podman',args:string[],options?:{timeout?:number;signal?:AbortSignal})=>Promise<CommandResult>
+const command:ContainerCommand=async(runtime,args,options={})=>execAsync(runtime,args,{encoding:'utf8',maxBuffer:8*mib,timeout:options.timeout??15000,signal:options.signal})
+const schema=z.object({
+  id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
+  imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
+  cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
+  pids:z.number().int().min(64).max(1024).default(512),
+}).strict()
+export type ComputerSpecification=z.input<typeof schema>
+type Spec=z.output<typeof schema>
+export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
+export interface ComputerProvider {
+  validateSpecification(spec:ComputerSpecification):ComputerSpecification
+  readonly capabilities:{isolation:'container'|'vm';shell:boolean;desktop:boolean;persistentWorkspace:boolean;sharedDesktop:boolean;snapshots:boolean;network:'none'|'public-proxy'}
+  inspect(spec:ComputerSpecification):Promise<ComputerState|undefined>
+  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}):Promise<CommandResult>
+  health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
+}
+export class ComputerError extends Error { constructor(readonly code:string,message:string){super(message)} }
+const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex')
+const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>String(value).toUpperCase().replace(/^CAP_/, '')).sort():[]
+
+/** A private container per environment. No tools, filesystem operations or
+ * process execution fall back to the host. Remote hosts run their own Runner. */
+export class ContainerComputerProvider implements ComputerProvider {
+  private queue=new Map<string,Promise<unknown>>()
+  readonly capabilities={isolation:'container',shell:true,desktop:true,persistentWorkspace:true,sharedDesktop:false,snapshots:false,network:'none'} as const
+  constructor(readonly runtime:'docker'|'podman',readonly runnerId:string,readonly home:string,readonly run:ContainerCommand=command,readonly environment:NodeJS.ProcessEnv=process.env) {
+    z.string().uuid().parse(runnerId)
+    if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
+  }
+  private async verifyRuntime() {
+    if(this.runtime==='docker') {
+      const endpoint=this.environment.DOCKER_HOST||JSON.parse((await this.run('docker',['context','inspect','--format','{{json .Endpoints.docker.Host}}'])).stdout)
+      if(typeof endpoint!=='string'||!(/^(?:unix|npipe):\/\//.test(endpoint)))throw new ComputerError('computer_remote_engine','远程 Docker 引擎请在目的电脑安装 Runner')
+    }else{
+      const connections=JSON.parse((await this.run('podman',['system','connection','list','--format','json'])).stdout)
+      const endpoint=this.environment.CONTAINER_HOST||connections.find((entry:any)=>entry.Default)?.URI
+      if(endpoint){const url=new URL(endpoint);if(url.protocol!=='unix:'&&!['127.0.0.1','localhost','[::1]'].includes(url.hostname))throw new ComputerError('computer_remote_engine','远程 Podman 引擎请在目的电脑安装 Runner')}
+    }
+  }
+  validateSpecification(value:ComputerSpecification):Spec {
+    const parsed=schema.safeParse(value)
+    if(!parsed.success)throw new ComputerError('computer_spec_invalid','电脑环境配置无效')
+    return parsed.data
+  }
+  private name(spec:Spec){return `yaoyao-computer-${digest([this.runnerId,spec.id]).slice(0,24)}`}
+  private labels(spec:Spec){return {[managedLabel]:spec.id,[runnerLabel]:this.runnerId,[ownerLabel]:digest(spec.ownerKey),[specLabel]:digest(spec)}}
+  private serial<T>(spec:Spec,action:()=>Promise<T>):Promise<T> {
+    const result=(this.queue.get(spec.id)??Promise.resolve()).catch(()=>{}).then(action)
+    this.queue.set(spec.id,result)
+    void result.finally(()=>{if(this.queue.get(spec.id)===result)this.queue.delete(spec.id)}).catch(()=>{})
+    return result
+  }
+  private async workspace(spec:Spec) {
+    const base=resolve(this.home,'computer-workspaces')
+    await mkdir(base,{recursive:true,mode:0o700})
+    const canonical=await realpath(base),path=join(canonical,spec.id)
+    if(/[,\n\r\0]/.test(path))throw new ComputerError('computer_path_invalid','电脑数据目录包含不支持的字符')
+    await mkdir(path,{recursive:true,mode:0o700})
+    if(await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','电脑工作区不能指向其他目录')
+    if(process.platform!=='win32')await chmod(path,0o700)
+    return path
+  }
+  private async inspectRaw(name:string):Promise<any|undefined> {
+    try {const result=await this.run(this.runtime,['inspect',name]);return JSON.parse(result.stdout)[0]}
+    catch(error){
+      if(/no such (?:object|container)|no container with name or ID/i.test(String((error as any).stderr??'')))return undefined
+      throw error
+    }
+  }
+  private assertOwned(spec:Spec,detail:any) {
+    if(!detail||!/^[a-f0-9]{64}$/.test(String(detail.Id))||Object.entries(this.labels(spec)).filter(([key])=>key!==specLabel).some(([key,value])=>detail.Config?.Labels?.[key]!==value))
+      throw new ComputerError('computer_owner_mismatch','电脑实例不属于当前执行节点或配置已变化')
+  }
+  private assertConfiguration(spec:Spec,detail:any,workspace:string) {
+    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??[]
+    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))
+      ||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!==1||mounts[0].Type!=='bind'||mounts[0].Source!==workspace||mounts[0].Destination!==COMPUTER_WORKSPACE||mounts[0].RW!==true
+      ||ports.length!==0)
+      throw new ComputerError('computer_configuration_unsafe','电脑实例的隔离、资源或数据目录不符合要求,请先停止并核对')
+  }
+  private state(spec:Spec,detail:any,workspace:string):ComputerState {
+    this.assertConfiguration(spec,detail,workspace)
+    if(Object.values(detail.NetworkSettings?.Ports??{}).some(bindings=>Array.isArray(bindings)&&bindings.length))throw new ComputerError('computer_viewer_unsafe','电脑环境不能发布网络端口')
+    return {id:spec.id,containerId:detail.Id,running:detail.State?.Running===true,workspace,isolation:'container'}
+  }
+  async inspect(value:ComputerSpecification):Promise<ComputerState|undefined> {
+    await this.verifyRuntime()
+    const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.inspectRaw(this.name(spec))
+    return detail?this.state(spec,detail,workspace):undefined
+  }
+  private args(spec:Spec,workspace:string) {
+    return ['create','--name',this.name(spec),'--hostname',this.name(spec),// The XFCE/VNC startup resolves its own hostname even without a network.
+      '--add-host',`${this.name(spec)}:127.0.0.1`,
+      ...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',
+      ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
+      '--mount',`type=bind,source=${workspace},target=${COMPUTER_WORKSPACE}${this.runtime==='podman'?',relabel=private':''}`,
+      '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
+  }
+  async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{
+      authorize();await this.verifyRuntime();authorize()
+      const workspace=await this.workspace(spec)
+      let detail=await this.inspectRaw(this.name(spec))
+      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')
+          throw new ComputerError('computer_image_incompatible','电脑镜像缺少已验证的驱动,请先安装兼容镜像')
+        authorize()
+        const created=await this.run(this.runtime,this.args(spec,workspace))
+        const id=created.stdout.trim()
+        if(!/^[a-f0-9]{64}$/.test(id))throw new ComputerError('computer_creation_uncertain','电脑创建结果不确定,请核对实例')
+        detail=await this.inspectRaw(id)
+      }
+      let state=this.state(spec,detail,workspace)
+      authorize()
+      if(!state.running)await this.run(this.runtime,['start','--',state.containerId])
+      try {
+        authorize();detail=await this.inspectRaw(state.containerId);state=this.state(spec,detail,workspace);authorize()
+        if(!state.running)throw new ComputerError('computer_start_failed','电脑环境未能启动')
+        return state
+      }catch(error){await this.stopOwned(spec,state.containerId).catch(()=>{});throw error}
+    })
+  }
+  private async stopOwned(spec:Spec,id:string) {
+    const detail=await this.inspectRaw(id)
+    if(!detail)return
+    this.assertOwned(spec,detail)
+    if(detail.State?.Running)await this.run(this.runtime,['stop','--time','10','--',id],{timeout:15000})
+    const stopped=await this.inspectRaw(id)
+    if(stopped?.State?.Running)throw new ComputerError('computer_stop_uncertain','尚未确认电脑环境已停止')
+  }
+  async stop(value:ComputerSpecification):Promise<void> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{await this.verifyRuntime();const detail=await this.inspectRaw(this.name(spec));if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)}})
+  }
+  async remove(value:ComputerSpecification):Promise<void> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{
+      await this.verifyRuntime()
+      const detail=await this.inspectRaw(this.name(spec));if(!detail)return
+      this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)
+      await this.run(this.runtime,['rm','--',detail.Id])
+      // Persistent workspace deletion is a separate, explicit operation.
+    })
+  }
+  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number}):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.serial(spec,async()=>{
+      options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
+      const state=await this.inspect(spec)
+      if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+      options.authorize()
+      try {
+        const result=await this.run(this.runtime,['exec','--user','1000:1000','--workdir',COMPUTER_WORKSPACE,'--',state.containerId,...argv],{timeout:Math.min(60000,Math.max(1000,options.timeout??30000)),signal:options.signal})
+        options.authorize();return result
+      }catch(error){
+        // Killing a docker exec client alone cannot prove its guest process stopped.
+        // A cancelled or unacknowledged operation closes this private environment.
+        if(options.signal?.aborted||(error as any).killed||typeof (error as any).code!=='number')await this.stopOwned(spec,state.containerId)
+        throw error
+      }
+    })
+  }
+  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)
+  }
+}

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

@@ -0,0 +1,120 @@
+import {randomUUID} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import type {ComputerProvider,ComputerSpecification} from './container.js'
+import {ComputerError} from './container.js'
+
+export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
+type Status='free'|'preparing'|'active'|'stopping'|'uncertain'
+interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
+interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
+
+/** Durable exclusive use. A generation is invalidated before cancellation; no
+ * successor is admitted until the old private environment is confirmed stopped. */
+export class ComputerPool {
+  private active=new Map<string,Active>()
+  private changing=new Map<string,Promise<unknown>>()
+  private ready=false
+  private closing=false
+  constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {
+    if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
+    db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
+  }
+  private rows():Entry[]{return (this.db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))}
+  private get(id:string):Entry|undefined {const row=this.db.prepare('SELECT value FROM computer_environments WHERE id=?').get(id) as {value:string}|undefined;return row?JSON.parse(row.value):undefined}
+  private save(entry:Entry){entry.updatedAt=this.now();this.db.prepare('INSERT INTO computer_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(entry.spec.id,JSON.stringify(entry))}
+  private serial<T>(id:string,action:()=>Promise<T>):Promise<T>{
+    const pending=(this.changing.get(id)??Promise.resolve()).catch(()=>{}).then(action)
+    this.changing.set(id,pending);void pending.finally(()=>{if(this.changing.get(id)===pending)this.changing.delete(id)}).catch(()=>{})
+    return pending
+  }
+  /** Must run under the Runner's OS instance lock before accepting commands. */
+  async recover():Promise<void>{
+    if(this.ready)throw new ComputerError('computer_pool_active','运行中的资源池不能重新执行启动恢复')
+    for(const entry of this.rows())if(entry.status!=='free')await this.serial(entry.spec.id,()=>this.stopEntry(entry)).catch(()=>{})
+    this.ready=true
+  }
+  status(ownerKey:string){return this.rows().filter(row=>row.spec.ownerKey===ownerKey).map(row=>({environmentId:row.spec.id,status:row.status,generation:row.generation,holderId:row.lease?.holderId,expiresAt:row.expiresAt}))}
+  private require(lease:ComputerLease,activeOnly=true):Entry {
+    const entry=this.get(lease.environmentId)
+    if(!entry||entry.spec.ownerKey!==lease.ownerKey||entry.lease?.id!==lease.id||entry.lease.holderId!==lease.holderId||entry.generation!==lease.generation||(activeOnly&&entry.status!=='active'))throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
+    return entry
+  }
+  private assert(lease:ComputerLease){
+    const entry=this.require(lease),active=this.active.get(lease.id)
+    if(this.closing||!active||active.controller.signal.aborted||entry.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑控制授权已到期')
+    active.authorize()
+  }
+  async acquire(spec:ComputerSpecification,holderId:string,authorize:()=>void,signal?:AbortSignal):Promise<ComputerLease>{
+    spec=this.provider.validateSpecification(spec)
+    if(!holderId||holderId.length>256)throw new ComputerError('computer_holder_invalid','电脑任务身份无效')
+    return this.serial(spec.id,async()=>{
+      authorize()
+      if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
+      let entry=this.get(spec.id)
+      if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
+      if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
+      if(entry&&entry.status!=='free')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
+      const rows=this.rows()
+      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free').length>=this.limits.concurrent)throw new ComputerError('computer_quota','电脑资源已达上限,请等待其他任务释放')
+      const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
+      entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
+      // This reservation is synchronous, before any provider await or next acquire.
+      this.save(entry)
+      const abort=()=>{const current=this.get(spec.id);if(current?.lease?.id===lease.id)this.invalidate(current)}
+      const active:Active={lease,authorize,controller:new AbortController(),operations:new Set(),detach:()=>signal?.removeEventListener('abort',abort)};this.active.set(lease.id,active)
+      signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
+      const guard=()=>{const current=this.require(lease,false);if(this.closing||active.controller.signal.aborted||current.status!=='preparing'||current.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑启动授权已失效');authorize()}
+      try{
+        await this.provider.ensure(spec,guard);guard()
+        entry.status='active';this.save(entry);return lease
+      }catch(error){await this.stopEntry(entry).catch(()=>{});throw error}
+    })
+  }
+  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>{
+    this.assert(lease)
+    const active=this.active.get(lease.id)!
+    const pending=Promise.resolve().then(()=>{this.assert(lease);return action({signal:active.controller.signal,authorize:()=>this.assert(lease)})}).then(value=>{this.assert(lease);return value})
+    active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
+    return pending
+  }
+  release(lease:ComputerLease):Promise<void>{
+    const entry=this.require(lease,false)
+    this.invalidate(entry)
+    return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
+  }
+  private invalidate(expected:Entry){
+    const entry=this.get(expected.spec.id)
+    if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
+    if(entry.status!=='stopping'){entry.generation++;entry.status='stopping';entry.expiresAt=0;this.save(entry)}
+    const active=entry.lease?this.active.get(entry.lease.id):undefined
+    active?.controller.abort()
+    return entry
+  }
+  private async stopEntry(expected:Entry):Promise<void>{
+    const entry=this.invalidate(expected)
+    if(!entry)return
+    const active=entry.lease?this.active.get(entry.lease.id):undefined
+    try{
+      await this.provider.stop(entry.spec)
+      // Retire the runtime identity as well as the lease generation. A delayed
+      // exec targeting the old container ID can never enter its successor.
+      await this.provider.remove(entry.spec)
+      active?.detach();if(entry.lease)this.active.delete(entry.lease.id)
+      entry.status='free';entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+    }catch(error){entry.status='uncertain';this.save(entry);throw error}
+  }
+  async expire():Promise<void>{
+    for(const id of this.rows().map(entry=>entry.spec.id)){
+      const entry=this.get(id)!
+      if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
+        this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
+      }
+    }
+  }
+  async close():Promise<void>{
+    this.closing=true
+    for(const entry of this.rows())if(entry.status!=='free')this.invalidate(entry)
+    await Promise.allSettled(this.rows().filter(entry=>entry.status!=='free').map(entry=>this.serial(entry.spec.id,()=>this.stopEntry(this.get(entry.spec.id)!))))
+  }
+}

+ 51 - 0
tests/live/computer.live.test.ts

@@ -0,0 +1,51 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {mkdtemp,rm,readFile,writeFile,mkdir} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {DatabaseSync} from 'node:sqlite'
+import {ComputerPool} from '../../src/runner/computers/pool'
+import {randomUUID} from 'node:crypto'
+import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../../src/runner/computers/container'
+
+// Only creates a UUID-owned private fixture; never adopts user containers.
+it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('executes in a real private computer and preserves its workspace across recreation',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-computer-live-'))
+  const provider=new ContainerComputerProvider('docker',randomUUID(),home)
+  const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:process.env.YAOYAO_COMPUTER_IMAGE!}
+  const db=new DatabaseSync(':memory:'),pool=new ComputerPool(db,provider)
+  try {
+    await pool.recover()
+    const lease=await pool.acquire(spec,'initial-task',()=>{})
+    const initial=(await provider.inspect(spec))!
+    expect(initial.running).toBe(true)
+    expect((await provider.execute(spec,['ls','/sys/class/net'],{authorize:()=>{}})).stdout.trim()).toBe('lo')
+    const result=await provider.execute(spec,['/bin/sh','-c','id -u; printf isolated-fixture > persistence-proof.txt; pwd'],{authorize:()=>{}})
+    expect(result.stdout).toContain('1000\n');expect(result.stdout).toContain('/home/cua/workspace')
+    expect(await readFile(join(initial.workspace,'persistence-proof.txt'),'utf8')).toBe('isolated-fixture')
+    let healthError=''
+    try{await expect.poll(async()=>{
+      try{return await provider.health(spec,()=>{})}catch(error){healthError=`${String(error)} ${String((error as any).stderr??'')}`;return null}
+    },{timeout:60000,interval:1000}).toMatchObject({schema_version:'1'})}catch{
+      const diagnostics=await provider.run('docker',['logs','--tail','70',initial.containerId]).catch(()=>({stdout:'',stderr:''}))
+      const vnc=await provider.run('docker',['exec','--user','0',initial.containerId,'/bin/sh','-c','tail -40 /var/log/supervisor/vncserver* /home/cua/.vnc/*.log 2>/dev/null']).catch(error=>({stdout:String(error.stdout??''),stderr:String(error.stderr??'')}))
+      throw new Error(`Computer health: ${healthError}\n${diagnostics.stdout}\n${diagnostics.stderr}\n${vnc.stdout}\n${vnc.stderr}`)
+    }
+    await provider.execute(spec,[CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file','/tmp/yaoyao-computer-proof.png'],{authorize:()=>{}})
+    const screenshot=Buffer.from((await provider.execute(spec,['base64','-w0','/tmp/yaoyao-computer-proof.png'],{authorize:()=>{}})).stdout.trim(),'base64')
+    expect(screenshot.subarray(1,4).toString()).toBe('PNG');expect(screenshot.byteLength).toBeGreaterThan(1024)
+    await mkdir('test-results/computer',{recursive:true});await writeFile('test-results/computer/desktop.png',screenshot)
+    await pool.release(lease)
+    const nextLease=await pool.acquire(spec,'next-task',()=>{})
+    const recreated=(await provider.inspect(spec))!
+    expect(recreated.containerId).not.toBe(initial.containerId)
+    expect((await provider.execute(spec,['cat','persistence-proof.txt'],{authorize:()=>{}})).stdout).toBe('isolated-fixture')
+    const pending=pool.use(nextLease,context=>provider.execute(spec,['/bin/sh','-c','sleep 30; printf should-not-run > cancelled-proof.txt'],context))
+    const settled=pending.catch(error=>error)
+    await new Promise(resolve=>setTimeout(resolve,500));await pool.release(nextLease)
+    expect(await settled).toBeInstanceOf(Error)
+    expect(await provider.inspect(spec)).toBeUndefined()
+    expect(()=>pool.use(lease,async()=>{})).toThrow('电脑控制权已改变')
+    await expect(readFile(join(initial.workspace,'cancelled-proof.txt'))).rejects.toMatchObject({code:'ENOENT'})
+  }finally{await pool.close();await provider.remove(spec);db.close();await rm(home,{recursive:true,force:true})}
+},100000)

+ 90 - 0
tests/server/computerPool.test.ts

@@ -0,0 +1,90 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {randomUUID} from 'node:crypto'
+import {ComputerPool} from '../../src/runner/computers/pool'
+import {ContainerComputerProvider,type ComputerProvider,type ComputerSpecification} from '../../src/runner/computers/container'
+const databases:DatabaseSync[]=[]
+afterEach(()=>{for(const db of databases.splice(0))db.close()})
+const spec=():ComputerSpecification=>({id:randomUUID(),ownerKey:'owner',imageId:`sha256:${'a'.repeat(64)}`})
+function fixture(limits={concurrent:2,environments:32,ttlMs:30000}){
+  let clock=100000
+  const db=new DatabaseSync(':memory:');databases.push(db)
+  const validate=new ContainerComputerProvider('docker',randomUUID(),'/unused').validateSpecification
+  const provider={validateSpecification:validate,ensure:vi.fn(async(_spec,authorize)=>{authorize();return {running:true}}),stop:vi.fn(async()=>{}),remove:vi.fn(async()=>{})} as unknown as ComputerProvider
+  const pool=new ComputerPool(db,provider,limits,()=>clock)
+  return {db,provider,pool,advance:(value:number)=>{clock+=value}}
+}
+it('reserves quota before starting, rejects another holder and rejects malformed specs before storage',async()=>{
+  const f=fixture({concurrent:1,environments:2,ttlMs:30000});await f.pool.recover()
+  const first=spec();const lease=await f.pool.acquire(first,'task-one',()=>{})
+  expect(await f.pool.acquire(first,'task-one',()=>{})).toEqual(lease)
+  await expect(f.pool.acquire(first,'task-two',()=>{})).rejects.toMatchObject({code:'computer_busy'})
+  await expect(f.pool.acquire(spec(),'task-three',()=>{})).rejects.toMatchObject({code:'computer_quota'})
+  await expect(f.pool.acquire({...spec(),imageId:'untrusted:latest'},'invalid',()=>{})).rejects.toMatchObject({code:'computer_spec_invalid'})
+  expect(f.db.prepare('SELECT count(*) AS n FROM computer_environments').get()?.n).toBe(1)
+  expect(f.pool.status('different-owner')).toEqual([])
+  await f.pool.close()
+})
+it('fences cancellation before an old operation returns and waits for its stop before admitting a successor',async()=>{
+  const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task-one',()=>{})
+  let entered=false
+  const operation=f.pool.use(lease,({signal})=>new Promise<void>((resolve)=>{entered=true;signal.addEventListener('abort',()=>resolve(),{once:true})})).catch(error=>error)
+  await vi.waitFor(()=>expect(entered).toBe(true))
+  const released=f.pool.release(lease)
+  expect(await operation).toMatchObject({code:'computer_lease_stale'})
+  await released
+  const next=await f.pool.acquire(resource,'task-two',()=>{})
+  expect(next.generation).toBeGreaterThan(lease.generation)
+  expect(()=>f.pool.release(lease)).toThrow('电脑控制权已改变')
+  expect(f.provider.stop).toHaveBeenCalledTimes(1)
+  await f.pool.close()
+})
+it('keeps an unconfirmed stop reserved and recovers it before a new acquisition',async()=>{
+  const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
+  vi.mocked(f.provider.stop).mockRejectedValueOnce(new Error('daemon unavailable'))
+  await expect(f.pool.release(lease)).rejects.toThrow('daemon unavailable')
+  expect(f.pool.status('owner')[0]?.status).toBe('uncertain')
+  await expect(f.pool.acquire(resource,'new-task',()=>{})).rejects.toMatchObject({code:'computer_busy'})
+  const restarted=new ComputerPool(f.db,f.provider)
+  await expect(restarted.acquire(resource,'new-task',()=>{})).rejects.toMatchObject({code:'computer_pool_unavailable'})
+  await restarted.recover()
+  const next=await restarted.acquire(resource,'new-task',()=>{})
+  expect(next.generation).toBeGreaterThan(lease.generation)
+  await restarted.close()
+})
+it('does not expire a new holder acquired while an earlier environment stop was in flight',async()=>{
+  const f=fixture();await f.pool.recover();const a=spec(),b=spec()
+  const first=await f.pool.acquire(a,'first',()=>{}),second=await f.pool.acquire(b,'second',()=>{})
+  let release!:()=>void,entered=false
+  const gate=new Promise<void>(resolve=>{release=resolve})
+  vi.mocked(f.provider.stop).mockImplementation(async resource=>{if(resource.id===a.id){entered=true;await gate}})
+  f.advance(30001)
+  const sweep=f.pool.expire()
+  await vi.waitFor(()=>expect(entered).toBe(true))
+  await f.pool.release(second)
+  const replacement=await f.pool.acquire(b,'replacement',()=>{})
+  release();await sweep
+  expect(f.pool.status('owner').find(row=>row.environmentId===a.id)?.status).toBe('free')
+  expect(f.pool.status('owner').find(row=>row.environmentId===b.id)).toMatchObject({status:'active',holderId:'replacement',generation:replacement.generation})
+  expect(()=>f.pool.renew(first)).toThrow()
+  f.pool.renew(replacement);await f.pool.close()
+})
+it('rechecks authority and cleans up a creation cancelled while the provider is starting',async()=>{
+  const f=fixture();await f.pool.recover();const abort=new AbortController()
+  let release!:()=>void,entered=false
+  const gate=new Promise<void>(resolve=>{release=resolve})
+  vi.mocked(f.provider.ensure).mockImplementationOnce(async(_spec,authorize)=>{entered=true;await gate;authorize();return {} as any})
+  const acquiring=f.pool.acquire(spec(),'starting',()=>{},abort.signal).catch(error=>error)
+  await vi.waitFor(()=>expect(entered).toBe(true));abort.abort();release()
+  expect(await acquiring).toBeInstanceOf(Error)
+  expect(f.pool.status('owner')[0]?.status).toBe('free')
+  expect(f.provider.stop).toHaveBeenCalledTimes(1)
+  await f.pool.close()
+})
+it('rejects changes of account identity and resource configuration for a persistent environment',async()=>{
+  const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
+  await f.pool.release(lease)
+  for(const change of [{ownerKey:'other'},{memoryMiB:8192}])await expect(f.pool.acquire({...resource,...change},'changed',()=>{})).rejects.toMatchObject({code:'computer_owner_mismatch'})
+  expect(f.provider.ensure).toHaveBeenCalledTimes(1)
+})

+ 91 - 0
tests/server/computerProvider.test.ts

@@ -0,0 +1,91 @@
+// @vitest-environment node
+import {afterEach,expect,it} from 'vitest'
+import {randomUUID} from 'node:crypto'
+import {mkdtemp,rm,symlink,mkdir} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {ContainerComputerProvider,type ContainerCommand,type ComputerSpecification} from '../../src/runner/computers/container'
+const homes:string[]=[]
+afterEach(async()=>{await Promise.all(homes.splice(0).map(home=>rm(home,{recursive:true,force:true})))})
+async function fixture(runtime:'docker'|'podman'='docker'){
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-computer-unit-'));homes.push(home)
+  const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:`sha256:${'a'.repeat(64)}`}
+  let detail:any
+  const calls:string[][]=[]
+  const run:ContainerCommand=async(_runtime,args)=>{
+    calls.push(args)
+    if(args[0]==='context')return {stdout:JSON.stringify('unix:///tmp/fixture-engine.sock'),stderr:''}
+    if(args[0]==='system')return {stdout:'[]',stderr:''}
+    if(args[0]==='image')return {stdout:JSON.stringify([{Id:spec.imageId,Config:{Labels:{'com.openmausbot.cua-driver':'0.20.0','com.openmausbot.image-layer':'5'}}}]),stderr:''}
+    if(args[0]==='inspect'){
+      if(!detail)throw Object.assign(new Error('missing'),{stderr:'No such object'})
+      return {stdout:JSON.stringify([detail]),stderr:''}
+    }
+    if(args[0]==='create'){
+      const value=(flag:string)=>args[args.indexOf(flag)+1]!
+      const values=(flag:string)=>args.flatMap((arg,i)=>arg===flag?[args[i+1]!]:[])
+      const workspace=/source=([^,]+)/.exec(value('--mount'))![1]
+      detail={Id:'b'.repeat(64),Image:spec.imageId,Config:{Labels:Object.fromEntries(values('--label').map(label=>label.split('=')))},State:{Running:false},
+        HostConfig:{Memory:4096*1024*1024,MemorySwap:4096*1024*1024,NanoCpus:2e9,PidsLimit:512,Privileged:false,IpcMode:'private',CgroupnsMode:'private',NetworkMode:'none',RestartPolicy:{Name:'no'},CapAdd:values('--cap-add'),CapDrop:values('--cap-drop'),SecurityOpt:values('--security-opt'),PortBindings:{}},
+        Mounts:[{Type:'bind',Source:workspace,Destination:'/home/cua/workspace',RW:true}],NetworkSettings:{Ports:{}}}
+      return {stdout:detail.Id,stderr:''}
+    }
+    if(args[0]==='start')detail.State.Running=true
+    if(args[0]==='stop')detail.State.Running=false
+    if(args[0]==='rm')detail=undefined
+    return {stdout:'fixture',stderr:''}
+  }
+  const provider=new ContainerComputerProvider(runtime,randomUUID(),home,run,{})
+  return {provider,spec,calls,home,run,get detail(){return detail}}
+}
+it.each(['docker','podman'] as const)('uses the same private ownership and execution boundary for %s',async runtime=>{
+  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'])
+  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])
+})
+it.each([
+  (d:any)=>{d.HostConfig.Privileged=true},
+  (d:any)=>{d.HostConfig.CapAdd.push('SYS_ADMIN')},
+  (d:any)=>{d.HostConfig.Memory=0},
+  (d:any)=>{d.HostConfig.NetworkMode='host'},
+  (d:any)=>{d.HostConfig.PidMode='host'},
+  (d:any)=>{d.HostConfig.SecurityOpt=['seccomp=unconfined']},
+  (d:any)=>{d.Mounts[0].Source='/Users/private'},
+  (d:any)=>{d.Mounts.push({Source:'/var/run/docker.sock'})},
+  (d:any)=>{d.HostConfig.PortBindings['6901/tcp']=[{HostIp:'127.0.0.1',HostPort:'12345'}]},
+  (d:any)=>{d.Image=`sha256:${'c'.repeat(64)}`},
+])('refuses execution when an existing container no longer matches its constraints (%#)',async mutate=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{});mutate(f.detail)
+  await expect(f.provider.execute(f.spec,['true'],{authorize:()=>{}})).rejects.toMatchObject({code:'computer_configuration_unsafe'})
+  expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
+  await f.provider.stop(f.spec)
+  expect(f.detail.State.Running).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()
+  await expect(f.provider.stop(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
+  await expect(f.provider.remove(f.spec)).rejects.toMatchObject({code:'computer_owner_mismatch'})
+  expect(f.calls.some(args=>['stop','rm'].includes(args[0]!))).toBe(false)
+})
+it('rejects workspace links and remote engine contexts before creating a computer',async()=>{
+  const f=await fixture(),outside=await mkdtemp(join(tmpdir(),'yaoyao-computer-outside-'));homes.push(outside)
+  await mkdir(join(f.home,'computer-workspaces'));await symlink(outside,join(f.home,'computer-workspaces',f.spec.id))
+  await expect(f.provider.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_path_unsafe'})
+  const remote=new ContainerComputerProvider('docker',randomUUID(),f.home,f.run,{DOCKER_HOST:'tcp://remote.invalid:2375'})
+  await expect(remote.ensure(f.spec,()=>{})).rejects.toMatchObject({code:'computer_remote_engine'})
+  expect(f.calls.some(args=>args[0]==='create')).toBe(false)
+})
+it('checks authority again after asynchronous inspection, before a guest command starts',async()=>{
+  const f=await fixture();await f.provider.ensure(f.spec,()=>{})
+  let active=true
+  const provider=new ContainerComputerProvider('docker',f.provider.runnerId,f.home,async(runtime,args,options)=>{
+    const result=await f.run(runtime,args,options);if(args[0]==='inspect')active=false;return result
+  },{})
+  await expect(provider.execute(f.spec,['true'],{authorize:()=>{if(!active)throw new Error('revoked')}})).rejects.toThrow('revoked')
+  expect(f.calls.some(args=>args[0]==='exec')).toBe(false)
+})