executionSettings.ts 12 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. import Router from '@koa/router'
  2. import {randomUUID} from 'node:crypto'
  3. import {existsSync} from 'node:fs'
  4. import {join} from 'node:path'
  5. import {z} from 'zod'
  6. import type {WorkspaceStore} from './workspaceStore.js'
  7. import {parse} from './workspaceStore.js'
  8. import type {WorkspaceNodes} from './workspaceGateway.js'
  9. import type {LocalAuthStore} from './localAuth.js'
  10. import type {WorkspaceAgent} from '../shared/workspace.js'
  11. import type {ExecutionSelection,VmProxySettings,TaskEnvironment,SystemResourceSummary} from '../shared/executionEnvironment.js'
  12. import {HttpError} from './errors.js'
  13. const SYSTEM='_system', KIND='execution-settings'
  14. const key=z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/).refine(k=>! /^(?:YAOYAO_|HERMES_YAOYAO_|NODE_OPTIONS$|LD_|DYLD_|PYTHONPATH$|PYTHONHOME$|BASH_ENV$|ENV$|HOME$|PATH$|CODEX_HOME$|HTTPS?_PROXY$|ALL_PROXY$|NO_PROXY$)/i.test(k),'该变量由运行环境管理')
  15. const variables=z.record(key,z.string().max(32000).refine(s=>!s.includes('\0'))).refine(v=>Object.keys(v).length<=100)
  16. const resourceInput=z.object({name:z.string().trim().min(1).max(80),kind:z.enum(['variables','api','codex']),agentIds:z.array(z.string().uuid()).max(500),
  17. variables:variables.optional(),baseUrl:z.string().url().max(2000).optional(),token:z.string().min(1).max(32000).optional(),
  18. tokenEnv:z.union([key,z.literal('')]).optional(),authJson:z.string().max(64000).optional(),revision:z.number().int().nonnegative().default(0)}).strict()
  19. type Resource=z.infer<typeof resourceInput>&{id:string}
  20. interface StoredResource {id:string;sealed:string;revision:number}
  21. export const SERVICE_TOOLS=[{id:'yaoyao_service_request',name:'yaoyao_service_request',description:'调用本轮获准的系统服务。serviceId 必须来自系统服务目录;凭据由系统附加。模型、图片等请求格式遵循对应服务和 Skill。',inputSchema:{type:'object',properties:{serviceId:{type:'string'},path:{type:'string'},method:{enum:['GET','POST']},body:{type:'object'}},required:['serviceId','path','method'],additionalProperties:false}}]
  22. /** One encrypted system store; grants reference globally unique Bot IDs. */
  23. export class ExecutionSettings {
  24. testProxy:()=>Promise<unknown>=async()=>{throw new HttpError(409,'未连接虚拟环境执行节点','proxy_node_unavailable')}
  25. onProxyChange:()=>Promise<void>=async()=>{}
  26. nodeStatus:()=>Array<{id:string;name:string;online:boolean;status:string}>=()=>[]
  27. activeTurns:()=>Array<{agentId:string;mode:ExecutionSelection['mode'];revision:number}>=()=>[]
  28. constructor(readonly store:WorkspaceStore,readonly nodes:WorkspaceNodes,readonly auth:LocalAuthStore){
  29. if(!store.get(SYSTEM,KIND,'selection')){
  30. const existing=existsSync(join(store.home,'host-tools.json'))||!!store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind='agent' LIMIT 1").get()
  31. store.put(SYSTEM,KIND,'selection',{mode:existing?'server':'none',revision:1})
  32. }
  33. }
  34. selection():ExecutionSelection {const value=this.store.require<{mode:ExecutionSelection['mode'];revision:number}>(SYSTEM,KIND,'selection');return {...value,credentialSource:value.mode==='server'?'native':'yaoyao'}}
  35. proxy():VmProxySettings {const value=this.store.get<{sealed:string}>(SYSTEM,KIND,'proxy');return value?this.nodes.open<VmProxySettings>(value.sealed):{enabled:false,protocol:'http',host:'',port:8080,username:'',revision:0}}
  36. private entries(){return this.store.list<StoredResource>(SYSTEM,'execution-resource').map(row=>this.nodes.open<Resource>(row.sealed))}
  37. private summary(v:Resource):SystemResourceSummary{return {id:v.id,name:v.name,kind:v.kind,revision:v.revision,agentIds:v.agentIds,envKeys:[...Object.keys(v.variables??{}),...(v.tokenEnv?[v.tokenEnv]:[]),...(v.kind==='codex'?['CODEX_HOME']:[])],baseUrl:v.baseUrl,tokenEnv:v.tokenEnv,configured:true}}
  38. agents(){return (this.store.db.prepare("SELECT owner,data FROM workspace_entities WHERE kind='agent'").all() as {owner:string;data:string}[]).flatMap(row=>{const a=JSON.parse(row.data) as WorkspaceAgent;return a.archived||a.remoteAgentId?[]:[{id:a.id,name:a.name,owner:row.owner}]})}
  39. view(){const {password,...proxy}=this.proxy();return {execution:this.selection(),proxy:{...proxy,hasPassword:!!password},resources:this.entries().map(v=>this.summary(v)),agents:this.agents(),nodes:this.nodeStatus(),activeTurns:this.activeTurns()}}
  40. saveSelection(value:unknown){const v=parse(z.object({mode:z.enum(['none','server','virtual']),revision:z.number().int().positive()}).strict(),value);if(v.revision!==this.selection().revision)throw new HttpError(409,'配置已变化,请刷新','execution_revision_conflict');this.store.put(SYSTEM,KIND,'selection',{mode:v.mode,revision:v.revision+1});return this.selection()}
  41. async saveProxy(value:unknown){
  42. const v=parse(z.object({enabled:z.boolean(),protocol:z.enum(['http','socks5']),host:z.string().trim().max(253).refine(s=>!s||/^[A-Za-z0-9.:[\]_-]+$/.test(s)),port:z.number().int().min(1).max(65535),username:z.string().max(255),password:z.string().max(255).optional(),revision:z.number().int().nonnegative()}).strict(),value)
  43. const old=this.proxy();if(old.revision!==v.revision)throw new HttpError(409,'代理配置已变化,请刷新','execution_revision_conflict')
  44. if(v.enabled&&!v.host)throw new HttpError(400,'请输入代理服务器地址','proxy_host_required')
  45. this.store.put(SYSTEM,KIND,'proxy',{sealed:this.nodes.seal({...v,password:v.password??old.password,revision:v.revision+1})})
  46. await this.onProxyChange();return this.view().proxy
  47. }
  48. saveResource(value:unknown,id:string=randomUUID()){
  49. const v=parse(resourceInput,value),old=this.entries().find(row=>row.id===id)
  50. if((old?.revision??0)!==v.revision)throw new HttpError(409,'授权已变化,请刷新','execution_revision_conflict')
  51. const known=new Set(this.agents().map(a=>a.id));if(v.agentIds.some(a=>!known.has(a)))throw new HttpError(400,'授权列表包含不可用的 Bot','execution_agent_invalid')
  52. if(old&&old.kind!==v.kind)throw new HttpError(400,'请新建不同类型的授权','execution_resource_invalid')
  53. const next={...old,...v,id,revision:v.revision+1}
  54. if(v.kind==='api'){
  55. const url=new URL(next.baseUrl??'https://invalid.invalid');if(!next.baseUrl||url.protocol!=='https:'||url.username||url.password||url.search||url.hash||!next.token)throw new HttpError(400,'服务需要 HTTPS 地址和授权令牌','execution_resource_invalid')
  56. }
  57. if(v.kind==='codex'){
  58. try{const data=JSON.parse(next.authJson??'');if(!data||typeof data!=='object'||Array.isArray(data)||!(data.OPENAI_API_KEY||data.tokens?.access_token))throw new Error()}catch{throw new HttpError(400,'需要有效的 Codex auth.json;不会将它用作通用 API 密钥','execution_resource_invalid')}
  59. }
  60. if(next.tokenEnv&&Object.hasOwn(next.variables??{},next.tokenEnv))throw new HttpError(409,'授权令牌与环境变量名称重复','execution_variable_conflict')
  61. const other=this.entries().filter(r=>r.id!==id)
  62. const ownKeys=this.summary(next).envKeys
  63. if(other.some(r=>r.agentIds.some(a=>next.agentIds.includes(a))&&this.summary(r).envKeys.some(k=>ownKeys.includes(k))))throw new HttpError(409,'同一 Bot 的变量名称重复,请合并或移除重复授权','execution_variable_conflict')
  64. this.store.put(SYSTEM,'execution-resource',id,{id,revision:next.revision,sealed:this.nodes.seal(next)});return this.summary(next)
  65. }
  66. removeResource(id:string){this.store.remove(SYSTEM,'execution-resource',id)}
  67. private granted(owner:string,agentId:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',agentId);if(!this.auth.isUserActive(owner)||agent.archived)throw new HttpError(403,'Bot 授权已结束','execution_grant_revoked');return this.entries().filter(r=>r.agentIds.includes(agentId))}
  68. openTurn(owner:string,agentId:string,selection:ExecutionSelection){
  69. const initial=selection.mode==='server'?[]:this.granted(owner,agentId)
  70. const check=(id?:string)=>{const current=this.granted(owner,agentId);for(const r of initial.filter(v=>!id||id===v.id))if(!current.some(v=>v.id===r.id&&v.revision===r.revision))throw new HttpError(403,'系统变量或服务授权已变化,请开始新一轮任务','execution_grant_revoked')}
  71. const taskEnvironment=():TaskEnvironment=>{check();const agent=this.store.require<WorkspaceAgent>(owner,'agent',agentId);if(agent.computerEnvironmentId){const peers=(this.store.db.prepare("SELECT data FROM workspace_entities WHERE kind='agent'").all() as {data:string}[]).map(row=>JSON.parse(row.data) as WorkspaceAgent).filter(a=>!a.archived&&a.computerEnvironmentId===agent.computerEnvironmentId);if(initial.some(r=>this.summary(r).envKeys.length&&peers.some(a=>!r.agentIds.includes(a.id))))throw new HttpError(403,'共享虚拟环境内仍有未获授权的 Bot;请为成员统一授权,或改用独立环境和服务工具','execution_shared_credentials_forbidden')}const result:TaskEnvironment={variables:{},files:[]};for(const r of initial){Object.assign(result.variables,r.variables);if(r.kind==='api'&&r.tokenEnv)result.variables[r.tokenEnv]=r.token!;if(r.kind==='codex')result.files.push({name:'auth.json',content:r.authJson!,envKey:'CODEX_HOME'})}return result}
  72. return {check,taskEnvironment,catalog:()=>initial.some(r=>r.kind==='api')?SERVICE_TOOLS:[],description:initial.map(r=>({id:r.id,name:r.name,kind:r.kind,envKeys:this.summary(r).envKeys,baseUrl:r.baseUrl})),
  73. call:async(args:unknown,signal:AbortSignal)=>{
  74. const body=parse(z.object({serviceId:z.string(),path:z.string().max(2000),method:z.enum(['GET','POST']),body:z.record(z.string(),z.unknown()).optional()}).strict(),args)
  75. check(body.serviceId);const r=initial.find(r=>r.id===body.serviceId&&r.kind==='api');if(!r)throw new HttpError(403,'当前环境未授权该系统服务','execution_service_forbidden')
  76. const base=new URL(r.baseUrl!.replace(/\/?$/,'/')),url=new URL(body.path.replace(/^\//,''),base)
  77. if(url.origin!==base.origin||!url.pathname.startsWith(base.pathname)||url.username||url.password||/%(?:2f|5c|25)/i.test(url.pathname))throw new HttpError(400,'请求路径超出已授权服务','execution_service_path')
  78. try{const response=await fetch(url,{method:body.method,headers:{authorization:`Bearer ${r.token}`,'content-type':'application/json'},body:body.method==='POST'?JSON.stringify(body.body??{}):undefined,signal:AbortSignal.any([signal,AbortSignal.timeout(60000)]),redirect:'error'})
  79. const chunks:Buffer[]=[];let size=0;for await(const part of response.body??[]){const b=Buffer.from(part);size+=b.length;if(size>8*1024*1024)throw new Error('limit');chunks.push(b)}check(body.serviceId)
  80. const data=Buffer.concat(chunks).toString().split(r.token!).join('[已隐藏授权]');return {status:response.status,body:data}
  81. }catch(error){check(body.serviceId);throw new HttpError(502,'服务调用失败,请检查连接、授权和请求格式','execution_service_failed')}
  82. }}
  83. }
  84. router(){const router=new Router({prefix:'/api/app/admin/execution'});router.use(async(ctx,next)=>{this.auth.requireAdmin(ctx);ctx.set('Cache-Control','no-store');await next()});const body=(ctx:any)=>ctx.request.body
  85. router.get('/',ctx=>{ctx.body=this.view()});router.put('/environment',ctx=>{ctx.body=this.saveSelection(body(ctx))});router.put('/proxy',async ctx=>{ctx.body=await this.saveProxy(body(ctx))})
  86. router.post('/proxy/test',async ctx=>{ctx.body=await this.testProxy()})
  87. router.post('/resources',ctx=>{ctx.body=this.saveResource(body(ctx));ctx.status=201});router.put('/resources/:id',ctx=>{ctx.body=this.saveResource(body(ctx),ctx.params.id)});router.delete('/resources/:id',ctx=>{this.removeResource(ctx.params.id);ctx.body={ok:true}})
  88. return router
  89. }
  90. }