workspace-server.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import {nativeEnvironmentFixture} from './native-environment.js'
  2. import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
  3. import { FixtureBotPlugins } from './bot-plugins.js'
  4. /** Isolated, deterministic Hermes fixture. Team-tool transport is simulated, with no live model. */
  5. import { createServer } from 'node:http'
  6. import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join, resolve } from 'node:path'
  9. import { randomUUID, randomBytes, scryptSync } from 'node:crypto'
  10. import { WebSocketServer } from 'ws'
  11. import serve from 'koa-static'
  12. import { createApplication, createNodeServer } from '../../src/server/app.js'
  13. import { loadServerConfig } from '../../src/server/config.js'
  14. import { LocalAuthStore } from '../../src/server/localAuth.js'
  15. const home =
  16. process.env.WORKSPACE_FIXTURE_HOME || mkdtempSync(join(tmpdir(), 'yaoyao-workspace-browser-'))
  17. const port = Number(process.env.WORKSPACE_FIXTURE_PORT || 18800),
  18. upstreamPort = Number(process.env.WORKSPACE_FIXTURE_UPSTREAM_PORT || 19119)
  19. const sessions = new Map<
  20. string,
  21. {
  22. id: string
  23. profile: string
  24. source: string
  25. title: string
  26. messages: Array<{ role: string; content: string; id: string; timestamp: number }>
  27. running: boolean
  28. cwd?: string
  29. }
  30. >()
  31. const latestRuntimeBySession = new Map<string, string>()
  32. const calls: Array<{ method: string; params: Record<string, unknown> }> = []
  33. const heldReplies: Array<() => void> = []
  34. const pluginsFixture = process.env.WORKSPACE_FIXTURE_PLUGINS === '1' ? new FixtureBotPlugins() : undefined
  35. const profileState = new Map([
  36. ['default', { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
  37. ['server', { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
  38. ])
  39. function broadcastUpstreamEvent(
  40. type: string,
  41. payload: Record<string, unknown>,
  42. sessionId?: string,
  43. profile = 'default',
  44. ) {
  45. const frame = JSON.stringify({
  46. method: 'event',
  47. params: {
  48. type,
  49. payload,
  50. ...(sessionId ? { session_id: sessionId, profile } : {}),
  51. },
  52. })
  53. for (const socket of wss.clients) {
  54. if (socket.readyState === 1) socket.send(frame)
  55. }
  56. }
  57. const upstream = createServer((req, res) => {
  58. const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
  59. res.setHeader('content-type', 'application/json')
  60. const send = (v: unknown) => res.end(JSON.stringify(v))
  61. if (pluginsFixture?.handle(req, res, url.pathname)) return
  62. if (url.pathname === '/api/config') {
  63. send({ terminal: { cwd: `/tmp/hermes-fixture/${url.searchParams.get('profile') || 'default'}` } })
  64. return
  65. }
  66. if (url.pathname === '/__release' && req.method === 'POST') {
  67. const held = heldReplies.splice(0)
  68. for (const complete of held) complete()
  69. send({ released: held.length })
  70. return
  71. }
  72. if (url.pathname.includes('/plugins/')) {
  73. if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && ['/api/plugins/yaoyao-bot-bridge/bind', '/api/plugins/yaoyao-bot-bridge/unbind'].includes(url.pathname)) {
  74. req.resume()
  75. send({ok:true,native_tools:true})
  76. return
  77. }
  78. if (process.env.WORKSPACE_FIXTURE_TEAM_TOOLS === '1' && url.pathname === '/api/plugins/yaoyao-bot-bridge/capabilities') {
  79. send({ version: 1, ready: true, native_tools: true, in_process: true })
  80. return
  81. }
  82. res.statusCode = 404
  83. send({ error: 'This fixture has no plugins' })
  84. return
  85. }
  86. if (url.pathname === '/__calls') {
  87. send({ calls })
  88. return
  89. }
  90. if (url.pathname === '/__test/sessions/title' && req.method === 'POST') {
  91. const id = url.searchParams.get('id')?.trim() || ''
  92. const title = url.searchParams.get('title')?.trim() || ''
  93. const session = sessions.get(id)
  94. const runtimeId = latestRuntimeBySession.get(id)
  95. if (!session || !runtimeId || !title) {
  96. res.statusCode = 409
  97. send({ error: 'session route is not active' })
  98. return
  99. }
  100. session.title = title
  101. broadcastUpstreamEvent(
  102. 'session.title',
  103. { session_id: id, title },
  104. runtimeId,
  105. session.profile,
  106. )
  107. send({ id, title })
  108. return
  109. }
  110. if (url.pathname === '/api/status') {
  111. send({ auth_required: true, overall: 'ready', gateway_running: true })
  112. return
  113. }
  114. if (url.pathname === '/api/auth/me') {
  115. send({ user_id: 'fixture', display_name: 'Fixture', provider: 'basic' })
  116. return
  117. }
  118. if (url.pathname === '/auth/password-login') {
  119. res.setHeader('set-cookie', 'hermes_session_at=fixture; Path=/; HttpOnly')
  120. send({ ok: true })
  121. return
  122. }
  123. if (url.pathname === '/api/auth/ws-ticket') {
  124. send({ ticket: 'fixture' })
  125. return
  126. }
  127. if (url.pathname === '/api/profiles') {
  128. send({ profiles: [...profileState.values()] })
  129. return
  130. }
  131. if (url.pathname === '/api/sessions') {
  132. const requestedSource = url.searchParams.get('source')
  133. const visible = [...sessions.values()].filter(session =>
  134. !requestedSource || session.source === requestedSource)
  135. send({
  136. sessions: visible.map((s) => ({
  137. id: s.id,
  138. title: s.title,
  139. source: s.source,
  140. profile: s.profile,
  141. })),
  142. total: visible.length,
  143. hasMore: false,
  144. offset: Number(url.searchParams.get('offset') ?? 0),
  145. limit: Number(url.searchParams.get('limit') ?? 100),
  146. })
  147. return
  148. }
  149. const detail = /^\/api\/sessions\/([^/]+)$/.exec(url.pathname)
  150. if (detail) {
  151. const session = sessions.get(detail[1]!)
  152. if (!session) {
  153. res.statusCode = 404
  154. send({ error: 'session not found' })
  155. return
  156. }
  157. send({
  158. id: session.id,
  159. profile: session.profile,
  160. source: session.source,
  161. title: session.title,
  162. message_count: session.messages.length,
  163. })
  164. return
  165. }
  166. const history = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname)
  167. if (history) {
  168. const messages = sessions.get(history[1]!)?.messages || []
  169. send({ session_id: history[1], messages,
  170. pagination: {offset: 0, limit: 100, returned: messages.length, total: messages.length, has_more: false} })
  171. return
  172. }
  173. if (url.pathname === '/api/files/download') {
  174. res.setHeader('content-type', 'text/plain')
  175. res.end('Fixture report\n')
  176. return
  177. }
  178. send({ ok: true })
  179. })
  180. const wss = new WebSocketServer({ server: upstream, path: '/api/ws' })
  181. wss.on('connection', (socket) => {
  182. const runtimes = new Map<string, string>()
  183. socket.send(
  184. JSON.stringify({
  185. method: 'event',
  186. params: { type: 'gateway.ready', payload: { capabilities: [] } },
  187. }),
  188. )
  189. socket.on('message', (raw) => {
  190. const f = JSON.parse(String(raw))
  191. calls.push({ method: f.method, params: f.params })
  192. const respond = (result: unknown) => socket.send(JSON.stringify({ id: f.id, result }))
  193. const event = (type: string, payload: unknown, sessionId: string) => {
  194. if (socket.readyState === 1)
  195. socket.send(
  196. JSON.stringify({
  197. method: 'event',
  198. params: { type, payload, session_id: sessionId, profile: 'default' },
  199. }),
  200. )
  201. }
  202. if (f.method === 'profiles.list') {
  203. respond({ profiles: [...profileState.values()] })
  204. return
  205. }
  206. if (f.method === 'profiles.configure') {
  207. const profile = profileState.get(String(f.params.name))
  208. if (!profile) { respond({ error: 'profile_not_found' }); return }
  209. for (const [namespace, value] of Object.entries(f.params.ui_meta ?? {})) {
  210. profile.ui_meta[namespace] = value
  211. profile.ui_meta_revisions[namespace] = (profile.ui_meta_revisions[namespace] ?? 0) + 1
  212. }
  213. respond({ profile })
  214. return
  215. }
  216. if (f.method === 'session.create' || f.method === 'session.resume') {
  217. let stored = f.method === 'session.resume' ? sessions.get(f.params.session_id) : undefined
  218. if (!stored) {
  219. stored = {
  220. id: randomUUID(),
  221. profile: f.params.profile,
  222. source: String(f.params.source ?? 'web'),
  223. title: String(f.params.title ?? '新对话'),
  224. messages: [],
  225. running: false,
  226. }
  227. sessions.set(stored.id, stored)
  228. }
  229. // A live Hermes resume joins the same runtime, including other viewers.
  230. const runtimeId = latestRuntimeBySession.get(stored.id) ?? randomUUID()
  231. runtimes.set(runtimeId, stored.id)
  232. latestRuntimeBySession.set(stored.id, runtimeId)
  233. respond({
  234. session_id: runtimeId,
  235. stored_session_id: stored.id,
  236. session_key: stored.id,
  237. running: stored.running,
  238. info: { profile_name: stored.profile, cwd: stored.cwd },
  239. })
  240. return
  241. }
  242. const stored = sessions.get(runtimes.get(f.params.session_id) ?? '')
  243. if (f.method === 'session.cwd.set' && stored) {
  244. stored.cwd = String(f.params.cwd)
  245. respond({ cwd: stored.cwd })
  246. return
  247. }
  248. if (f.method === 'prompt.submit' && stored) {
  249. stored.running = true
  250. const requestedTitle = /^\[cross-client-title:([^\]\r\n]+)\]/
  251. .exec(String(f.params.text))?.[1]?.trim()
  252. if (requestedTitle) {
  253. stored.title = requestedTitle
  254. setTimeout(() => broadcastUpstreamEvent('sessions.changed', {}), 0)
  255. }
  256. stored.messages.push({
  257. id: randomUUID(),
  258. role: 'user',
  259. content: f.params.text,
  260. timestamp: Date.now() / 1000,
  261. })
  262. respond({ status: 'streaming' })
  263. const latencyMarker = /\[latency:([^\]]+)\]/.exec(String(f.params.text))?.[1]
  264. if (latencyMarker) {
  265. const chunks = latencyMarker.includes('long')
  266. ? Array.from({ length: 200 }, (_, i) => `第${String(i + 1).padStart(3, '0')}段。用于比较两个移动端的连续文本显示表现,全部内容来自隔离测试服务。\n\n`)
  267. : Array.from({ length: 40 }, (_, i) => `${String(i + 1).padStart(2, '0')}测试文字 `)
  268. let text = '', index = 0
  269. setTimeout(() => {
  270. const tick = () => {
  271. if (socket.readyState !== 1 || !stored!.running) return
  272. if (index === chunks.length) {
  273. const tail = ` END-${latencyMarker}`; text += tail
  274. event('message.delta', { text: tail }, f.params.session_id)
  275. stored!.running = false
  276. stored!.messages.push({ id: randomUUID(), role: 'assistant', content: text, timestamp: Date.now() / 1000 })
  277. event('message.complete', { text, status: 'complete' }, f.params.session_id)
  278. return
  279. }
  280. const part = chunks[index++]!; text += part
  281. event('message.delta', { text: part }, f.params.session_id)
  282. setTimeout(tick, 20)
  283. }
  284. tick()
  285. }, 250)
  286. return
  287. }
  288. const name = /你是 (.*?)。/.exec(f.params.text)?.[1] || '助手'
  289. const requested = /本轮用户指定成员:([^\n]*)/.exec(f.params.text)?.[1] ?? ''
  290. const delegates = f.params.text.includes('你是管理员。') && !f.params.text.includes('本批次执行结果:') && requested.startsWith('@')
  291. const markdownStream = String(f.params.text).includes('[streaming-markdown]')
  292. const crossClientRun = String(f.params.text).includes('[cross-client-live]')
  293. const markdownPrefix = '# 流式标题\n\n**即时格式化**\n\n- 第一项\n- 第二项\n\n```ts\nconst answer = 42' + (String(f.params.text).includes('[long]') ? `\n\x60\x60\x60\n\n${'这是持续生成的长回复段落。\n\n'.repeat(80)}流式末尾标记\n\n\x60\x60\x60ts\nconst tail = 1` : '')
  294. const markdownFinal = `${markdownPrefix}\n\x60\x60\x60\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n最终完整标记`
  295. let text = markdownStream ? markdownFinal : `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
  296. event('message.start', {}, f.params.session_id)
  297. if (String(f.params.text).includes('[message-files]')) {
  298. event('reasoning.delta', { text: '![过程图](/tmp/process.png)' }, f.params.session_id)
  299. event('tool.start', { tool_id: 'process-file', name: 'read_file', arguments: { path: '/tmp/input.pdf' } }, f.params.session_id)
  300. event('tool.complete', { tool_id: 'process-file', name: 'read_file', result: { path: '/tmp/process.pdf' } }, f.params.session_id)
  301. }
  302. if (crossClientRun) event('tool.start', { tool_id: 'cross-client-tool', name: 'read_file' }, f.params.session_id)
  303. else setTimeout(() => event('message.delta', { text: markdownStream ? markdownPrefix : text.slice(0, 12) }, f.params.session_id), 80)
  304. const complete = () => {
  305. if (crossClientRun) event('tool.complete', { tool_id: 'cross-client-tool', name: 'read_file', result: 'ok' }, f.params.session_id)
  306. stored!.running = false
  307. stored!.messages.push({
  308. id: randomUUID(),
  309. role: 'assistant',
  310. content: text,
  311. timestamp: Date.now() / 1000,
  312. })
  313. event('message.complete', { text, status: 'complete' }, f.params.session_id)
  314. }
  315. if (pluginsFixture && f.params.text.includes('[plugin-roundtrip]')) {
  316. void pluginsFixture.exercise(f.params.session_id).then(({ tool, result }) => {
  317. event('tool.start', { tool_id: 'plugin-fixture', name: tool.name }, f.params.session_id)
  318. event('tool.complete', { tool_id: 'plugin-fixture', name: tool.name, result }, f.params.session_id)
  319. text = String(result.content?.[0]?.text ?? '插件未返回文字'); complete()
  320. }).catch(error => { stored!.running = false; event('run.failed', { error: String(error) }, f.params.session_id) })
  321. }
  322. else if (crossClientRun || markdownStream || f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
  323. else setTimeout(complete, 250)
  324. return
  325. }
  326. if (f.method === 'session.interrupt' && stored) stored.running = false
  327. if (f.method === 'file.attach') {
  328. respond({ attached: true, ref_text: '@file:fixture-upload.txt' })
  329. return
  330. }
  331. respond({ ok: true, status: 'interrupted' })
  332. })
  333. })
  334. await new Promise<void>((resolve) => upstream.listen(upstreamPort, '127.0.0.1', resolve))
  335. const config = loadServerConfig({
  336. HERMES_YAOYAO_HOME: home,
  337. HERMES_YAOYAO_PORT: String(port),
  338. HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}`,
  339. HERMES_YAOYAO_UPSTREAM_USERNAME: 'fixture',
  340. HERMES_YAOYAO_UPSTREAM_PASSWORD: 'fixture',
  341. HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1,localhost',
  342. })
  343. // Synthetic credentials are seeded only into this disposable fixture directory.
  344. const usersPath = join(home, 'users.json')
  345. if (!existsSync(usersPath)) {
  346. const salt = randomBytes(16),
  347. now = Date.now()
  348. writeFileSync(
  349. usersPath,
  350. JSON.stringify({
  351. version: 1,
  352. users: [
  353. {
  354. id: randomUUID(),
  355. username: 'fixture',
  356. normalizedUsername: 'fixture',
  357. role: 'admin',
  358. enabled: true,
  359. mustChangePassword: false,
  360. salt: salt.toString('base64'),
  361. passwordHash: scryptSync('fixture-pass', salt, 32, {
  362. N: 2 ** 14,
  363. r: 8,
  364. p: 1,
  365. maxmem: 64 * 1024 * 1024,
  366. }).toString('base64'),
  367. authVersion: 1,
  368. createdAt: now,
  369. updatedAt: now,
  370. },
  371. ],
  372. }),
  373. { mode: 0o600 },
  374. )
  375. }
  376. const auth = new LocalAuthStore(home, false)
  377. const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuthProvider():undefined
  378. const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch, pluginFetch: pluginsFixture?.fetch }),
  379. node = createNodeServer(runtime)
  380. const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?nativeEnvironmentFixture(runtime,home,port):undefined
  381. runtime.app.use((ctx, next) => {
  382. if (ctx.path !== '/__test/workspace-transcript' || ctx.method !== 'POST') return next()
  383. const owner = auth.require(ctx).id
  384. if (typeof ctx.query.id === 'string') {
  385. const message = runtime.workspace.messages(owner, ctx.query.id, Number.MAX_SAFE_INTEGER, 1)[0]!
  386. runtime.workspace.saveMessage(owner, { ...message, content: message.content + '\n\n实时追加的内容', status: 'streaming' })
  387. ctx.body = { ok: true }; return
  388. }
  389. const agent = runtime.workspace.createAgent(owner, { name: '长会话性能验收', profile: 'default' })
  390. const conversation = runtime.workspace.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)!
  391. for (let index = 1; index <= 300; index++) runtime.workspace.saveMessage(owner, {
  392. id: randomUUID(), conversationId: conversation.id, seq: index, role: index % 2 ? 'user' : 'assistant',
  393. content: `消息 ${index}\n\n${'用于验证长会话缓存与滚动位置。'.repeat(12)}`, reasoning: '', status: 'complete',
  394. attachments: [], tools: [], createdAt: Date.now() + index,
  395. })
  396. ctx.body = { agentId: agent.id, conversationId: conversation.id }
  397. })
  398. runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/browser-page')return next();ctx.type='html';ctx.body='<!doctype html><meta charset="utf-8"><title>浏览器操作验收</title><body style="font:22px sans-serif;padding:48px;background:#edf4ff"><h1>机器人的独立浏览器</h1><p>这是隔离的真实浏览器验收页面。</p><input aria-label="输入内容"><button onclick="document.querySelector(\'output\').textContent=document.querySelector(\'input\').value">确认</button><output></output></body>'})
  399. runtime.app.use((ctx,next) => {
  400. if (ctx.path !== '/__test/task-plan' || ctx.method !== 'POST') return next()
  401. const owner=auth.require(ctx).id
  402. const lead=runtime.workspace.createAgent(owner,{name:'验收负责人',profile:'default',canManageTeam:true})
  403. const worker=runtime.workspace.createAgent(owner,{name:'验收研究员',profile:'default'})
  404. const team=runtime.workspace.createGroup(owner,{name:'任务分工验收',memberIds:[lead.id,worker.id],administratorId:lead.id})
  405. const first=runtime.workspace.tasks(owner,team.id)[0]!
  406. runtime.workspace.updateTask(owner,team.id,first.id,{title:'调研任务'})
  407. const second=runtime.workspace.createTask(owner,team.id,{title:'独立任务'})
  408. const source=runtime.workspace.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
  409. const goal=runtime.workspaceRuntime.tasks.begin(owner,first,lead,'完成可核对的研究报告',{conversationId:source.id,runId:randomUUID(),agentId:lead.id},['来源可核对'])
  410. const temporary=ctx.query.helper==='1'?runtime.workspace.createAgent(owner,{name:'验收研究员(临时)',profile:'default',execution:'computer'},{createdByAgentId:lead.id,createdFromRunId:randomUUID(),temporaryGoalId:goal.id,helperActivation:goal.activation??1,helperRunnerId:randomUUID()}):undefined
  411. const assignment={id:randomUUID(),goalId:goal.id,conversationId:team.id,agentId:temporary?.id??worker.id,title:'研究资料',brief:'核对资料',acceptanceCriteria:['来源可核对'],dependsOn:[],status:'review',attempt:1,result:'资料已汇总',artifactIds:[],createdAt:Date.now(),updatedAt:Date.now()}
  412. runtime.workspace.put(owner,'assignment',assignment.id,assignment)
  413. runtime.workspace.event(owner,'assignment.changed',assignment,team.id)
  414. runtime.workspace.saveMessage(owner,{id:randomUUID(),conversationId:source.id,seq:0,role:'system',content:`任务已有结果。[打开任务](/conversations/${team.id}?taskId=${first.id})`,reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()})
  415. ctx.body={conversationId:team.id,firstTaskId:first.id,secondTaskId:second.id,sourceConversationId:source.id}
  416. })
  417. runtime.app.use((ctx,next)=>{
  418. if(!grokAuth||!ctx.path.startsWith('/__test/grok-auth/')||ctx.method!=='POST')return next()
  419. const owner=auth.requireAdmin(ctx).id
  420. if(ctx.path==='/__test/grok-auth/complete'){
  421. const attempt=runtime.grokAuth.snapshot(owner).attempt
  422. if(!attempt?.loginUrl){ctx.status=409;ctx.body={error:'No pending fixture authorization'};return}
  423. grokAuth.accept(new URL(attempt.loginUrl).searchParams.get('uuid')!);ctx.body={ok:true};return
  424. }
  425. if(ctx.path==='/__test/grok-auth/reject'){grokAuth.rejectCloud=true;ctx.body={ok:true};return}
  426. return next()
  427. })
  428. const computerFixtures=new Map<string,{mode:string;generation:number;controlId?:string;actions:unknown[]}>()
  429. const originalComputer=runtime.runners.computer.bind(runtime.runners),originalRunner=runtime.runners.computerRunner.bind(runtime.runners),originalSharedRunner=runtime.runners.sharedComputerRunner.bind(runtime.runners)
  430. runtime.runners.sharedComputerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id:'11111111-1111-4111-8111-111111111111'} as any:originalSharedRunner(owner,agent)
  431. runtime.runners.computerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id:'11111111-1111-4111-8111-111111111111'} as any:originalRunner(owner,agent)
  432. runtime.runners.computer=async(owner,agent,op,data,authorize)=>{
  433. const state=computerFixtures.get(agent.id);if(!state)return originalComputer(owner,agent,op,data,authorize)
  434. authorize()
  435. if(op==='detail')return {enabled:agent.execution==='computer',container:state.mode==='off'?'missing':state.mode==='stopped'?'stopped':'running',ready:!['off','stopped'].includes(state.mode),inUse:state.mode==='human',controlMode:state.mode,image:vmFixture.image,mode:agent.computerEnvironmentId?'shared':'per-bot',maxInstances:vmFixture.maxInstances,idleStopMinutes:vmFixture.idleStopMinutes}
  436. if(op==='lifecycle'){state.mode=data.action==='remove'?'off':data.action==='stop'?'stopped':'idle';state.generation++;return {ok:true}}
  437. if(op==='frame'){const png=readFileSync(process.env.YAOYAO_VM_PREVIEW_PNG||resolve('public/icons/icon-512.png'));return {id:'22222222-2222-4222-8222-222222222222',generation:state.generation,width:png.readUInt32BE(16),height:png.readUInt32BE(20),capturedAt:Date.now(),data:png.toString('base64')}}
  438. if(op==='take'){state.controlId=String(data.controlId);state.mode='human';state.generation++}
  439. if(op==='input')state.actions.push(data.action)
  440. if(op==='giveback'){state.mode='agent';state.controlId=undefined;state.generation++}
  441. return {mode:state.mode,generation:state.generation,controlId:state.controlId,canResume:true,ok:true}
  442. }
  443. runtime.app.use((ctx,next)=>{
  444. if(ctx.path!=='/__test/computer')return next()
  445. const owner=auth.require(ctx).id
  446. if(ctx.method==='POST'){
  447. const agent=runtime.workspace.createAgent(owner,{name:(ctx.query.shared==='1'?'共享电脑主成员':'电脑面板验收')+' '+(computerFixtures.size+1),profile:'default',execution:'computer'})
  448. computerFixtures.set(agent.id,{mode:ctx.query.off==='1'?'off':'idle',generation:1,actions:[]})
  449. if(ctx.query.shared==='1'){
  450. const second=runtime.workspace.createAgent(owner,{name:'可信共享成员',profile:'default',execution:'computer'})
  451. computerFixtures.set(second.id,{mode:'off',generation:1,actions:[]})
  452. const team=runtime.workspace.createGroup(owner,{name:'共享电脑验收',memberIds:[agent.id,second.id],administratorId:agent.id})
  453. ctx.body={agentId:agent.id,secondId:second.id,conversationId:team.id};return
  454. }
  455. ctx.body={agentId:agent.id,conversationId:runtime.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!.id};return
  456. }
  457. ctx.body={actions:[...computerFixtures.values()].flatMap(state=>state.actions)}
  458. })
  459. const vmFixture={configured:true,runtime:'docker',daemonUp:true,image:false,mode:'per-bot',maxInstances:2,idleStopMinutes:5,busy:false,job:undefined as any}
  460. let vmRunnerId=''
  461. const originalSummary=runtime.runners.summary.bind(runtime.runners),originalLocalVm=runtime.runners.localVm.bind(runtime.runners)
  462. runtime.runners.summary=record=>record.id===vmRunnerId?{...originalSummary(record),online:true,features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1']}:originalSummary(record)
  463. runtime.runners.localVm=async(owner,id,p)=>{
  464. if(id!==vmRunnerId)return originalLocalVm(owner,id,p)
  465. if(p.op==='prepare'){vmFixture.image=true;vmFixture.job={id:p.id,state:'complete',message:'本地虚拟机已就绪'}}
  466. if(p.op==='policy'){vmFixture.mode=String(p.mode);vmFixture.maxInstances=Number(p.maxInstances)}
  467. if(p.op==='idle-policy')vmFixture.idleStopMinutes=Number(p.idleStopMinutes)
  468. return {...vmFixture}
  469. }
  470. runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/local-vm')return next();const owner=auth.requireAdmin(ctx).id;vmFixture.image=ctx.query.ready==='1';vmFixture.mode='per-bot';vmFixture.maxInstances=2;vmFixture.idleStopMinutes=5;vmFixture.job=undefined;(runtime.runners as any).online.delete(vmRunnerId);if(!vmRunnerId){for(const record of runtime.runners.records())if(record.sourceNodeId==='local'&&record.enabled)runtime.runners.remove(record.id);vmRunnerId=runtime.runners.enroll(owner,{name:'本机虚拟机',allowedProfiles:['default']}).runner.id;}ctx.body={runnerId:vmRunnerId}})
  471. runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/hybrid-runner')return next();auth.requireAdmin(ctx);(runtime.runners as any).online.set(vmRunnerId,{instance:randomUUID(),epoch:'fixture',seen:Date.now(),features:['computer-worker-v1','computer-control-v1','local-vm-v1','image-ready-v1','host-computer-tools-v1','idle-stop-policy-v1']});ctx.body={ok:true}})
  472. runtime.app.use((ctx,next)=>{
  473. if(ctx.path!=='/__test/cloud-host-option')return next();const owner=auth.requireAdmin(ctx).id,cloud=runtime.workspaceRuntime.cloud!
  474. cloud.state=async()=>({configured:true,running:false});cloud.status=async()=>({backend:'grok',mode:'off'});cloud.requireAvailable=async()=>{};cloud.connect=async()=>({} as any)
  475. const agent=runtime.workspace.createAgent(owner,{name:'云虚拟机本机选项',computer:'cloud',profile:'default'})
  476. ctx.body={agentId:agent.id,conversationId:runtime.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!.id}
  477. })
  478. runtime.app.use(serve(resolve('dist')))
  479. runtime.app.use((ctx) => {
  480. if (!ctx.path.startsWith('/api/')) {
  481. ctx.type = 'html'
  482. ctx.body = readFileSync(resolve('dist/index.html'))
  483. }
  484. })
  485. await new Promise<void>((resolve) => node.server.listen(port, '127.0.0.1', resolve))
  486. process.stdout.write(`Workspace fixture http://127.0.0.1:${port}; home=${home}\n`)
  487. for (const signal of ['SIGTERM', 'SIGINT'])
  488. process.once(signal, () => {
  489. closeNative?.()
  490. void node.close().then(() => {
  491. for (const socket of wss.clients) socket.terminate()
  492. wss.close()
  493. upstream.close()
  494. })
  495. })