container.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { createHash, randomBytes } from 'node:crypto'
  2. import { spawn } from 'node:child_process'
  3. import { mkdir, realpath, chmod } from 'node:fs/promises'
  4. import { join, resolve, posix } from 'node:path'
  5. import { z } from 'zod'
  6. export const COMPUTER_WORKSPACE = '/home/cua/workspace'
  7. export const CUA_DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
  8. export const CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
  9. const managedLabel='cn.samien.yaoyao.computer', runnerLabel='cn.samien.yaoyao.runner', specLabel='cn.samien.yaoyao.computer-spec', ownerLabel='cn.samien.yaoyao.computer-owner'
  10. const mib=1024*1024
  11. export interface CommandResult {stdout:string;stderr:string}
  12. export type ContainerCommand=(runtime:'docker'|'podman',args:string[],options?:{timeout?:number;signal?:AbortSignal;input?:Buffer})=>Promise<CommandResult>
  13. const command:ContainerCommand=(runtime,args,options={})=>new Promise((resolveResult,reject)=>{
  14. const child=spawn(runtime,args,{stdio:'pipe',signal:options.signal}),out:Buffer[]=[],err:Buffer[]=[]
  15. let bytes=0,failure:Error|undefined
  16. const timer=setTimeout(()=>{failure=Object.assign(new Error('电脑命令超时'),{killed:true});child.kill('SIGTERM')},options.timeout??15000)
  17. const collect=(target:Buffer[],chunk:Buffer)=>{bytes+=chunk.length;if(bytes>8*mib){failure=Object.assign(new Error('电脑输出超过限制'),{killed:true});child.kill('SIGTERM')}else target.push(chunk)}
  18. child.stdout.on('data',chunk=>collect(out,Buffer.from(chunk)));child.stderr.on('data',chunk=>collect(err,Buffer.from(chunk)))
  19. child.once('error',error=>{failure=error});child.stdin.on('error',()=>{})
  20. child.once('close',code=>{clearTimeout(timer);const result={stdout:Buffer.concat(out).toString(),stderr:Buffer.concat(err).toString()};if(failure||code!==0)reject(Object.assign(failure??new Error('电脑命令失败'),{...result,...(!failure?{code}:{} )}));else resolveResult(result)})
  21. child.stdin.end(options.input)
  22. })
  23. const schema=z.object({
  24. id:z.string().uuid(),ownerKey:z.string().min(1).max(256),
  25. imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),
  26. cpus:z.number().int().min(1).max(8).default(2),memoryMiB:z.number().int().min(1024).max(16384).default(4096),
  27. cwd:z.string().max(4096).refine(value=>!/[\u0000-\u001f,]/.test(value)&&posix.isAbsolute(value)&&posix.normalize(value)===value&&value!=='/'&&!/^\/(?:proc|sys|dev|run|tmp|etc|usr|bin|sbin)(?:\/|$)/.test(value)&&value!=='/home/cua','电脑工作目录不受支持').default(COMPUTER_WORKSPACE),
  28. pids:z.number().int().min(64).max(1024).default(512),
  29. }).strict()
  30. export type ComputerSpecification=z.input<typeof schema>
  31. type Spec=z.output<typeof schema>
  32. export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
  33. export interface ComputerProvider {
  34. validateSpecification(spec:ComputerSpecification):ComputerSpecification
  35. readonly capabilities:{isolation:'container'|'vm';shell:boolean;desktop:boolean;persistentWorkspace:boolean;sharedDesktop:boolean;snapshots:boolean;network:'none'|'public-proxy'}
  36. inspect(spec:ComputerSpecification):Promise<ComputerState|undefined>
  37. ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
  38. stop(spec:ComputerSpecification):Promise<void>
  39. remove(spec:ComputerSpecification):Promise<void>
  40. execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult>
  41. health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
  42. }
  43. export class ComputerError extends Error { constructor(readonly code:string,message:string){super(message)} }
  44. const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex')
  45. const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>String(value).toUpperCase().replace(/^CAP_/, '')).sort():[]
  46. /** A private container per environment. No tools, filesystem operations or
  47. * process execution fall back to the host. Remote hosts run their own Runner. */
  48. export class ContainerComputerProvider implements ComputerProvider {
  49. private queue=new Map<string,Promise<unknown>>()
  50. readonly capabilities={isolation:'container',shell:true,desktop:true,persistentWorkspace:true,sharedDesktop:false,snapshots:false,network:'none'} as const
  51. constructor(readonly runtime:'docker'|'podman',readonly runnerId:string,readonly home:string,readonly run:ContainerCommand=command,readonly environment:NodeJS.ProcessEnv=process.env) {
  52. z.string().uuid().parse(runnerId)
  53. if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
  54. }
  55. private async verifyRuntime() {
  56. if(this.runtime==='docker') {
  57. const endpoint=this.environment.DOCKER_HOST||JSON.parse((await this.run('docker',['context','inspect','--format','{{json .Endpoints.docker.Host}}'])).stdout)
  58. if(typeof endpoint!=='string'||!(/^(?:unix|npipe):\/\//.test(endpoint)))throw new ComputerError('computer_remote_engine','远程 Docker 引擎请在目的电脑安装 Runner')
  59. }else{
  60. const connections=JSON.parse((await this.run('podman',['system','connection','list','--format','json'])).stdout)
  61. const endpoint=this.environment.CONTAINER_HOST||connections.find((entry:any)=>entry.Default)?.URI
  62. 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')}
  63. }
  64. }
  65. validateSpecification(value:ComputerSpecification):Spec {
  66. const parsed=schema.safeParse(value)
  67. if(!parsed.success)throw new ComputerError('computer_spec_invalid','电脑环境配置无效')
  68. return parsed.data
  69. }
  70. private name(spec:Spec){return `yaoyao-computer-${digest([this.runnerId,spec.id]).slice(0,24)}`}
  71. private labels(spec:Spec){return {[managedLabel]:spec.id,[runnerLabel]:this.runnerId,[ownerLabel]:digest(spec.ownerKey),[specLabel]:digest(spec)}}
  72. private serial<T>(spec:Spec,action:()=>Promise<T>):Promise<T> {
  73. const result=(this.queue.get(spec.id)??Promise.resolve()).catch(()=>{}).then(action)
  74. this.queue.set(spec.id,result)
  75. void result.finally(()=>{if(this.queue.get(spec.id)===result)this.queue.delete(spec.id)}).catch(()=>{})
  76. return result
  77. }
  78. private async workspace(spec:Spec) {
  79. const base=resolve(this.home,'computer-workspaces')
  80. await mkdir(base,{recursive:true,mode:0o700})
  81. const canonical=await realpath(base),path=join(canonical,spec.id)
  82. if(/[,\n\r\0]/.test(path))throw new ComputerError('computer_path_invalid','电脑数据目录包含不支持的字符')
  83. await mkdir(path,{recursive:true,mode:0o700})
  84. if(await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','电脑工作区不能指向其他目录')
  85. if(process.platform!=='win32')await chmod(path,0o700)
  86. return path
  87. }
  88. private async inspectRaw(name:string):Promise<any|undefined> {
  89. try {const result=await this.run(this.runtime,['inspect',name]);return JSON.parse(result.stdout)[0]}
  90. catch(error){
  91. if(/no such (?:object|container)|no container with name or ID/i.test(String((error as any).stderr??'')))return undefined
  92. throw error
  93. }
  94. }
  95. private assertOwned(spec:Spec,detail:any) {
  96. 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))
  97. throw new ComputerError('computer_owner_mismatch','电脑实例不属于当前执行节点或配置已变化')
  98. }
  99. private assertConfiguration(spec:Spec,detail:any,workspace:string) {
  100. this.assertOwned(spec,detail)
  101. const host=detail.HostConfig??{},expectedCaps=this.runtime==='podman'?['SETGID','SETUID','SYS_CHROOT']:['SETGID','SETUID']
  102. const nanoCPUs=host.NanoCpus??(host.CpuPeriod>0?host.CpuQuota/host.CpuPeriod*1e9:0)
  103. const noPrivilege=(host.SecurityOpt??[]).some((value:string)=>/^no-new-privileges(?::true)?$/.test(value))
  104. const ports=Object.entries(host.PortBindings??{}),mounts=detail.Mounts??[]
  105. const destinations=spec.cwd===COMPUTER_WORKSPACE?[COMPUTER_WORKSPACE]:[COMPUTER_WORKSPACE,spec.cwd]
  106. if(detail.Config?.Labels?.[specLabel]!==digest(spec)||detail.Image!==spec.imageId||host.Privileged!==false||host.Memory!==spec.memoryMiB*mib||host.MemorySwap!==spec.memoryMiB*mib
  107. ||nanoCPUs!==spec.cpus*1e9||host.PidsLimit!==spec.pids||host.IpcMode!=='private'||host.CgroupnsMode!=='private'
  108. ||host.NetworkMode!=='none'||host.PidMode==='host'||host.UsernsMode==='host'||host.UTSMode==='host'
  109. ||!noPrivilege||(host.SecurityOpt??[]).some((value:string)=>/unconfined/.test(value))
  110. ||JSON.stringify(capabilities(host.CapAdd))!==JSON.stringify(expectedCaps)||!capabilities(host.CapDrop).includes('ALL')
  111. ||(host.Devices??[]).length||(host.DeviceRequests??[]).length||host.RestartPolicy?.Name!=='no'
  112. ||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
  113. ||ports.length!==0)
  114. throw new ComputerError('computer_configuration_unsafe','电脑实例的隔离、资源或数据目录不符合要求,请先停止并核对')
  115. }
  116. private state(spec:Spec,detail:any,workspace:string):ComputerState {
  117. this.assertConfiguration(spec,detail,workspace)
  118. if(Object.values(detail.NetworkSettings?.Ports??{}).some(bindings=>Array.isArray(bindings)&&bindings.length))throw new ComputerError('computer_viewer_unsafe','电脑环境不能发布网络端口')
  119. return {id:spec.id,containerId:detail.Id,running:detail.State?.Running===true,workspace,isolation:'container'}
  120. }
  121. async inspect(value:ComputerSpecification):Promise<ComputerState|undefined> {
  122. await this.verifyRuntime()
  123. const spec=this.validateSpecification(value),workspace=await this.workspace(spec),detail=await this.inspectRaw(this.name(spec))
  124. return detail?this.state(spec,detail,workspace):undefined
  125. }
  126. private args(spec:Spec,workspace:string) {
  127. return ['create','--name',this.name(spec),'--hostname',this.name(spec),// The XFCE/VNC startup resolves its own hostname even without a network.
  128. '--add-host',`${this.name(spec)}:127.0.0.1`,
  129. ...Object.entries(this.labels(spec)).flatMap(([key,value])=>['--label',`${key}=${value}`]),
  130. '--restart','no','--network','none','--ipc','private','--cgroupns','private',
  131. '--memory',`${spec.memoryMiB}m`,'--memory-swap',`${spec.memoryMiB}m`,'--cpus',String(spec.cpus),'--pids-limit',String(spec.pids),
  132. '--shm-size','512m','--security-opt','no-new-privileges:true','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID',
  133. ...(this.runtime==='podman'?['--userns','keep-id:uid=1000,gid=1000','--user','root','--cap-add','SYS_CHROOT']:[]),
  134. ...[...new Set([COMPUTER_WORKSPACE,spec.cwd])].flatMap(destination=>['--mount',`type=bind,source=${workspace},target=${destination}${this.runtime==='podman'?',relabel=private':''}`]),
  135. '--env',`VNC_PW=${randomBytes(24).toString('base64url')}`,spec.imageId]
  136. }
  137. async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
  138. const spec=this.validateSpecification(value)
  139. return this.serial(spec,async()=>{
  140. authorize();await this.verifyRuntime();authorize()
  141. const workspace=await this.workspace(spec)
  142. let detail=await this.inspectRaw(this.name(spec))
  143. if(!detail) {
  144. const image=JSON.parse((await this.run(this.runtime,['image','inspect',spec.imageId])).stdout)[0]
  145. if(image?.Id!==spec.imageId||image.Config?.Labels?.['com.openmausbot.cua-driver']!=='0.20.0'||image.Config?.Labels?.['com.openmausbot.image-layer']!=='5')
  146. throw new ComputerError('computer_image_incompatible','电脑镜像缺少已验证的驱动,请先安装兼容镜像')
  147. authorize()
  148. const created=await this.run(this.runtime,this.args(spec,workspace))
  149. const id=created.stdout.trim()
  150. if(!/^[a-f0-9]{64}$/.test(id))throw new ComputerError('computer_creation_uncertain','电脑创建结果不确定,请核对实例')
  151. detail=await this.inspectRaw(id)
  152. }
  153. let state=this.state(spec,detail,workspace)
  154. authorize()
  155. if(!state.running)await this.run(this.runtime,['start','--',state.containerId])
  156. try {
  157. authorize();detail=await this.inspectRaw(state.containerId);state=this.state(spec,detail,workspace);authorize()
  158. if(!state.running)throw new ComputerError('computer_start_failed','电脑环境未能启动')
  159. return state
  160. }catch(error){await this.stopOwned(spec,state.containerId).catch(()=>{});throw error}
  161. })
  162. }
  163. private async stopOwned(spec:Spec,id:string) {
  164. const detail=await this.inspectRaw(id)
  165. if(!detail)return
  166. this.assertOwned(spec,detail)
  167. if(detail.State?.Running)await this.run(this.runtime,['stop','--time','10','--',id],{timeout:15000})
  168. const stopped=await this.inspectRaw(id)
  169. if(stopped?.State?.Running)throw new ComputerError('computer_stop_uncertain','尚未确认电脑环境已停止')
  170. }
  171. async stop(value:ComputerSpecification):Promise<void> {
  172. const spec=this.validateSpecification(value)
  173. 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)}})
  174. }
  175. async remove(value:ComputerSpecification):Promise<void> {
  176. const spec=this.validateSpecification(value)
  177. return this.serial(spec,async()=>{
  178. await this.verifyRuntime()
  179. const detail=await this.inspectRaw(this.name(spec));if(!detail)return
  180. this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)
  181. await this.run(this.runtime,['rm','--',detail.Id])
  182. // Persistent workspace deletion is a separate, explicit operation.
  183. })
  184. }
  185. async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult> {
  186. const spec=this.validateSpecification(value)
  187. 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','电脑命令无效或过长')
  188. return this.serial(spec,async()=>{
  189. options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
  190. const state=await this.inspect(spec)
  191. if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
  192. options.authorize()
  193. try {
  194. 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})
  195. options.authorize();return result
  196. }catch(error){
  197. // Killing a docker exec client alone cannot prove its guest process stopped.
  198. // A cancelled or unacknowledged operation closes this private environment.
  199. if(options.signal?.aborted||(error as any).killed||typeof (error as any).code!=='number')await this.stopOwned(spec,state.containerId)
  200. throw error
  201. }
  202. })
  203. }
  204. async health(value:ComputerSpecification,authorize:()=>void):Promise<unknown> {
  205. return JSON.parse((await this.execute(value,[CUA_DRIVER,'call','health_report','{}','--socket',CUA_SOCKET],{authorize})).stdout)
  206. }
  207. }