Explorar o código

修复 Bot 初始化、环境检测与远程设备注册

为 Bot 缺失快照增加保护与自动恢复;检测阶段延迟 Hermes 及后台任务,正式进入后持久保存启用状态。补齐已登录会话的设备注册,并统一使用 Chromium 网络连接设备。
Samien hai 1 mes
pai
achega
18748f65cd
Modificáronse 36 ficheiros con 878 adicións e 55 borrados
  1. 4 0
      bin/lib/desktop-activation.d.mts
  2. 18 0
      bin/lib/desktop-activation.mjs
  3. 24 2
      bin/lib/service-update.mjs
  4. 6 1
      desktop/core-lifecycle.test.mjs
  5. 2 0
      desktop/desktop-activation.mjs
  6. 35 5
      desktop/host-manager.mjs
  7. 74 3
      desktop/host-manager.test.mjs
  8. 40 8
      desktop/main.mjs
  9. 46 2
      desktop/mode-switch.test.mjs
  10. 1 1
      desktop/onboarding-flow.test.mjs
  11. 1 1
      desktop/remote-login.mjs
  12. 12 1
      desktop/remote-login.test.mjs
  13. 50 0
      desktop/service-activation.test.mjs
  14. 26 0
      desktop/service-manager.mjs
  15. 33 0
      desktop/service-manager.test.mjs
  16. 2 2
      desktop/update-entry.test.mjs
  17. 32 7
      desktop/update-restart.test.mjs
  18. 1 1
      desktop/windows-smoke.test.mjs
  19. 2 1
      scripts/build-desktop.mjs
  20. 2 1
      scripts/build-windows-desktop.mjs
  21. 4 0
      src/client/components/workspace/transcriptStore.ts
  22. 38 8
      src/server/app.ts
  23. 22 0
      src/server/desktopActivation.ts
  24. 9 4
      src/server/index.ts
  25. 6 3
      src/server/routes.ts
  26. 9 3
      src/server/serviceInstance.ts
  27. 14 0
      tests/client/conversationRecovery.test.ts
  28. 8 0
      tests/client/workspaceTranscript.test.ts
  29. 17 0
      tests/server/app.test.ts
  30. 89 0
      tests/server/backgroundActivation.test.ts
  31. 59 0
      tests/server/desktopActivation.test.ts
  32. 58 0
      tests/server/desktopBoot.test.ts
  33. 32 0
      tests/server/desktopHosts.test.ts
  34. 48 0
      tests/server/desktopSync.test.ts
  35. 23 0
      tests/server/serviceInstance.test.ts
  36. 31 1
      tests/server/workspaceRoutes.test.ts

+ 4 - 0
bin/lib/desktop-activation.d.mts

@@ -0,0 +1,4 @@
+export function desktopActivationPath(home: string): string
+export function hasDesktopActivation(home: string): boolean
+export function grantDesktopActivation(home: string): void
+export function deferDesktopActivation(home: string): void

+ 18 - 0
bin/lib/desktop-activation.mjs

@@ -0,0 +1,18 @@
+import { randomUUID } from 'node:crypto'
+import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+export const desktopActivationPath = home => join(home, 'desktop-runtime-activation.json')
+export function hasDesktopActivation(home) {
+  try { return JSON.parse(readFileSync(desktopActivationPath(home), 'utf8')).version === 1 }
+  catch { return false }
+}
+export function grantDesktopActivation(home) {
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const path = desktopActivationPath(home), temporary = `${path}.${randomUUID()}.tmp`
+  try {
+    writeFileSync(temporary, JSON.stringify({ version: 1 }), { mode: 0o600, flag: 'wx' })
+    renameSync(temporary, path)
+  } finally { rmSync(temporary, { force: true }) }
+}
+export function deferDesktopActivation(home) { rmSync(desktopActivationPath(home), { force: true }) }

+ 24 - 2
bin/lib/service-update.mjs

@@ -9,6 +9,7 @@ import { DatabaseSync } from 'node:sqlite'
 import { readJSON, readRuntime, syncDecision, verifyRuntimePackage } from './runtime-release.mjs'
 import { backupData, databaseSchema, legacyDataIdle, restoreData } from './service-data.mjs'
 import { normalizeReleaseSource } from './release-source.mjs'
+import { deferDesktopActivation } from './desktop-activation.mjs'
 
 const sleep = ms => new Promise(done => setTimeout(done, ms))
 export const alive = pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' } }
@@ -168,9 +169,29 @@ export class LaunchAgentService {
     const temporary = `${this.plistPath}.${randomUUID()}`
     writeFileSync(temporary, xml, { mode: 0o600 }); renameSync(temporary, this.plistPath)
   }
-  async boot() {
+  prepareBoot(deferHermes) {
+    // A live service is only being reused. Its authorization and background
+    // work must survive inspection; reset only a service we are about to boot.
+    if (deferHermes && !this.pid()) {
+      const plist = JSON.parse(command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', this.plistPath]))
+      plist.EnvironmentVariables ??= {}
+      plist.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES = '1'
+      this.writePlist(plist)
+      deferDesktopActivation(this.home)
+      return true
+    }
+    return false
+  }
+  async boot({ deferHermes = this.environment.HERMES_YAOYAO_DEFER_HERMES === '1' } = {}) {
+    const prepared = this.prepareBoot(deferHermes)
     let loaded = false
     try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); loaded = true } catch { /* Not registered yet. */ }
+    // launchd caches a loaded job's environment even when it has no process.
+    // Reload that stopped job so its next process receives the deferred mode.
+    if (loaded && prepared && !this.pid()) {
+      command('launchctl', ['bootout', `${this.domain}/${this.label}`])
+      loaded = false
+    }
     if (loaded) { command('launchctl', ['kickstart', `${this.domain}/${this.label}`]); return }
     const deadline = Date.now() + 20000
     for (;;) {
@@ -191,13 +212,14 @@ export class LaunchAgentService {
     Object.assign(plist.EnvironmentVariables, { NODE_ENV: 'production', HERMES_YAOYAO_HOME: this.home, HERMES_YAOYAO_PORT: String(this.port),
       HERMES_YAOYAO_SERVICE_ROOT: stable, HERMES_YAOYAO_RELEASE_ROOT: this.releaseRoot, HERMES_YAOYAO_DESKTOP: '0',
       HERMES_YAOYAO_STATIC_DIR: join(stable, existsSync(join(root, 'ui')) ? 'ui' : 'dist') })
+    if (this.environment.HERMES_YAOYAO_DEFER_HERMES === '1') plist.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES = '1'
     delete plist.EnvironmentVariables.ELECTRON_RUN_AS_NODE
     this.writePlist(plist); await this.boot()
   }
   async restore(previous) {
     if (!previous) { rmSync(this.plistPath, { force: true }); return }
     this.writePlist(previous.plist)
-    if (previous.wasRunning && !this.pid()) await this.boot()
+    if (previous.wasRunning && !this.pid()) await this.boot({ deferHermes: false })
   }
   async verify(expected, timeout = 45000) {
     const deadline = Date.now() + timeout

+ 6 - 1
desktop/core-lifecycle.test.mjs

@@ -19,10 +19,15 @@ test('packaged core service starts, preserves window-close work, recovers, and s
   try{
     await until(()=>localJSON(`http://127.0.0.1:${upstreamPort}/api/status`),Boolean)
     app=await electron.launch({...(process.env.DESKTOP_TEST_EXECUTABLE?{executablePath:process.env.DESKTOP_TEST_EXECUTABLE}:{}),args:process.env.DESKTOP_TEST_EXECUTABLE?[]:[root],cwd:root,env:{...process.env,HERMES_YAOYAO_DESKTOP_TEST_HOME:home,HERMES_YAOYAO_DESKTOP_PORT:String(port),HERMES_YAOYAO_UPSTREAM:`http://127.0.0.1:${upstreamPort}`},timeout:30000})
-    const page=await app.firstWindow();await enterLocal(page);await page.waitForURL(`http://127.0.0.1:${port}/**`)
+    const page=await app.firstWindow()
+    await page.locator('#login-form').waitFor()
+    const prepared=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'))
+    assert.equal((await localJSON(`http://127.0.0.1:${port}/desktop/service`,{'x-yaoyao-desktop-token':prepared.token})).activationRequired,true)
+    await enterLocal(page);await page.waitForURL(`http://127.0.0.1:${port}/**`)
     const initial=JSON.parse(await readFile(join(home,'service-instance.json'),'utf8'));pid=initial.pid
     const identity=await localJSON(`http://127.0.0.1:${port}/desktop/service`,{'x-yaoyao-desktop-token':initial.token})
     assert.equal(identity.desktopOwned,true);assert.equal(identity.pid,pid);assert.equal(identity.version,release.webVersion)
+    assert.equal(identity.activationRequired,false)
     assert.equal(await page.evaluate(async()=>{try{await window.yaoyaoDesktop.retry();return false}catch{return true}}),true)
     await mkdir(join(root,'test-results/desktop'),{recursive:true});await page.screenshot({path:join(root,'test-results/desktop/packaged-core.png')})
     await app.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].close())

+ 2 - 0
desktop/desktop-activation.mjs

@@ -0,0 +1,2 @@
+// Packaging copies the shared implementation beside the desktop service manager.
+export { deferDesktopActivation } from '../bin/lib/desktop-activation.mjs'

+ 35 - 5
desktop/host-manager.mjs

@@ -7,7 +7,39 @@ import {parseDesktopHostConfiguration} from './host-config.mjs'
 
 const FATAL = Symbol('desktop-host-fatal')
 
-export function remoteExchange(config,body){return new Promise((resolve,reject)=>{
+function checkExchangeStatus(status){
+ if(status===401||status===403){const error=new Error('电脑授权已失效,请重新登录授权这台电脑');error[FATAL]=true;throw error}
+ if(status===409){const error=new Error('电脑连接协议不兼容,请更新夭夭后重新授权');error[FATAL]=true;throw error}
+ if(status!==200)throw new Error(`电脑未连接(HTTP ${status})`)
+}
+
+async function fetchExchange(config,body,fetchImpl){
+ const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
+ if(!['http:','https:'].includes(url.protocol))throw new Error('夭夭服务地址无效')
+ const signal=AbortSignal.timeout(10000)
+ try{
+  const response=await fetchImpl(url.href,{method:'POST',credentials:'omit',redirect:'error',cache:'no-store',signal,
+   headers:{Authorization:`Bearer ${config.token}`,'x-desktop-host-protocol':'1','content-type':'application/json'},body:JSON.stringify(body)})
+  if(response.status!==200){await response.body?.cancel();checkExchangeStatus(response.status)}
+  const reader=response.body?.getReader()
+  if(!reader)throw new Error('电脑响应为空')
+  let size=0;const chunks=[]
+  try{
+   while(true){
+    const {done,value}=await reader.read()
+    if(done)break
+    size+=value.byteLength
+    if(size>16*1024*1024){await reader.cancel();throw new Error('桌面命令超过限制')}
+    chunks.push(value)
+   }
+  }finally{reader.releaseLock()}
+  return JSON.parse(Buffer.concat(chunks).toString())
+ }catch(error){if(signal.aborted)throw new Error('电脑连接超时');throw error}
+}
+
+export function remoteExchange(config,body,fetchImpl){
+ if(fetchImpl)return fetchExchange(config,body,fetchImpl)
+ return new Promise((resolve,reject)=>{
  const url=new URL(`/api/desktop-host/v1/${config.hostId}/exchange`,config.serverURL)
  if(!['http:','https:'].includes(url.protocol))return reject(new Error('夭夭服务地址无效'))
  const bytes=Buffer.from(JSON.stringify(body)),send=url.protocol==='https'?httpsRequest:httpRequest
@@ -16,9 +48,7 @@ export function remoteExchange(config,body){return new Promise((resolve,reject)=
   res.on('data',chunk=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('桌面命令超过限制'));else chunks.push(chunk)})
   res.on('error',reject)
   res.on('end',()=>{try{
-   if(res.statusCode===401||res.statusCode===403){const error=new Error('电脑授权已失效,请重新登录授权这台电脑');error[FATAL]=true;throw error}
-   if(res.statusCode===409){const error=new Error('电脑连接协议不兼容,请更新夭夭后重新授权');error[FATAL]=true;throw error}
-   if(res.statusCode!==200)throw new Error(`电脑未连接(HTTP ${res.statusCode})`)
+   checkExchangeStatus(res.statusCode)
    resolve(JSON.parse(Buffer.concat(chunks).toString()))
   }catch(error){reject(error)}})
  })
@@ -116,7 +146,7 @@ export class DesktopHostManager {
   try{
    await core.prepareInfo?.()
    if(this.config!==config||this.core!==core)return
-   const value=await remoteExchange(config,{host:core.info(),results:core.takeResults()})
+   const value=await remoteExchange(config,{host:core.info(),results:core.takeResults()},this.options.fetchImpl)
    this.backoff=400
    if(this.config!==config||this.core!==core)return
    this.publish(process.platform==='win32'&&!(Array.isArray(value.capabilities?.desktopPlatforms)&&value.capabilities.desktopPlatforms.includes('win32'))

+ 74 - 3
desktop/host-manager.test.mjs

@@ -6,7 +6,7 @@ import {join} from 'node:path'
 import {createServer} from 'node:http'
 import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
 import {HostEnvironmentReporter} from './host-environment.mjs'
-import {DesktopHostManager} from './host-manager.mjs'
+import {DesktopHostManager,remoteExchange} from './host-manager.mjs'
 import {parseDesktopHostConfiguration} from './host-config.mjs'
 
 const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))
@@ -23,13 +23,13 @@ test('computer configuration validation mirrors the runner rules',()=>{
  assert.throws(()=>parseDesktopHostConfiguration({...base,token:'short'}),/电脑凭据/)
 })
 
-async function fixture(config){
+async function fixture(config,options={}){
  const home=await mkdtemp(join(tmpdir(),'yaoyao-desktop-host-')),key=randomBytes(32)
  const encrypt=value=>{const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv);const bytes=Buffer.concat([cipher.update(value),cipher.final()]);return Buffer.concat([iv,cipher.getAuthTag(),bytes])}
  const decrypt=bytes=>{const cipher=createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString()}
  const cores=[],handled=[]
  const manager=new DesktopHostManager({home,root:'/app',dataRoot:home,
-  encrypt,decrypt,
+  encrypt,decrypt,...options,
   createCore:()=>{const reporter=new HostEnvironmentReporter('/Users/fixture');const core={id:randomUUID(),closed:false,results:[],exchanges:0,
     info(){core.exchanges++;return {id:core.id,name:'测试 Mac',platform:'darwin',screen:true,accessibility:true,approved:[],...reporter.fields()}},
     acceptCapabilities(capabilities){reporter.accept(capabilities)},
@@ -65,6 +65,77 @@ test('a damaged installation identity is not silently replaced by a new computer
  }finally{await rm(home,{recursive:true,force:true})}
 })
 
+test('device polling uses the injected login transport without cookies or redirects',async()=>{
+ const config={protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
+ const requests=[]
+ const f=await fixture(config,{fetchImpl:async(url,options)=>{
+  requests.push({url,options})
+  return Response.json({commands:[{id:'test-command'}],capabilities:{environmentMetadata:1}})
+ }})
+ try{
+  await f.manager.importFile(f.configPath)
+  clearTimeout(f.manager.timer)
+  await f.manager.cycle();clearTimeout(f.manager.timer)
+  assert.equal(requests.length,1)
+  assert.equal(requests[0].url,`${config.serverURL}/api/desktop-host/v1/${config.hostId}/exchange`)
+  const options=requests[0].options
+  assert.equal(options.method,'POST')
+  assert.equal(options.credentials,'omit')
+  assert.equal(options.redirect,'error')
+  assert.equal(options.headers.Authorization,`Bearer ${config.token}`)
+  assert.equal(options.headers['x-desktop-host-protocol'],'1')
+  assert.equal(JSON.parse(options.body).host.name,'测试 Mac')
+  assert.match(f.manager.state,/已连接/)
+  assert.deepEqual(f.handled,[{id:'test-command'}])
+ }finally{await f.manager.stop();await rm(f.home,{recursive:true,force:true})}
+})
+
+test('injected transport stops polling on revoked authorization or incompatible protocol',async()=>{
+ for(const status of [401,403,409]){
+  const config={protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
+  let requests=0
+  const f=await fixture(config,{fetchImpl:async()=>{requests++;return new Response('',{status})}})
+  try{
+   await f.manager.importFile(f.configPath);clearTimeout(f.manager.timer)
+   await f.manager.cycle();await f.manager.cycle()
+   assert.equal(requests,1)
+   assert.equal(f.cores[0].closed,true)
+   assert.equal(f.manager.timer,undefined)
+   assert.match(f.manager.state,status===409?/协议不兼容/:/授权已失效/)
+  }finally{await f.manager.stop();await rm(f.home,{recursive:true,force:true})}
+ }
+})
+
+test('injected transport retries transient network errors',async()=>{
+ const config={protocol:1,serverURL:'http://127.0.0.1:1',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
+ let requests=0
+ const f=await fixture(config,{fetchImpl:async()=>{
+  if(++requests===1)throw new Error('EHOSTUNREACH')
+  return Response.json({commands:[]})
+ }})
+ try{
+  await f.manager.importFile(f.configPath);clearTimeout(f.manager.timer)
+  await f.manager.cycle();clearTimeout(f.manager.timer)
+  assert.match(f.manager.state,/正在重试/)
+  assert.equal(f.cores[0].closed,false)
+  assert.equal(f.manager.backoff,800)
+  await f.manager.cycle();clearTimeout(f.manager.timer)
+  assert.match(f.manager.state,/已连接/)
+  assert.equal(f.manager.backoff,400)
+ }finally{await f.manager.stop();await rm(f.home,{recursive:true,force:true})}
+})
+
+test('injected transport rejects oversized desktop responses and invalid JSON',async()=>{
+ const config={serverURL:'https://yaoyao.example.com',hostId:randomUUID(),token:randomBytes(32).toString('base64url')}
+ let cancelled=false
+ await assert.rejects(()=>remoteExchange(config,{},async()=>new Response(new ReadableStream({
+  start(controller){controller.enqueue(new Uint8Array(16*1024*1024+1))},
+  cancel(){cancelled=true},
+ }))),/桌面命令超过限制/)
+ assert.equal(cancelled,true)
+ await assert.rejects(()=>remoteExchange(config,{},async()=>new Response('not json')),SyntaxError)
+})
+
 test('imports an encrypted config, exchanges with the paired server and stops on revocation',async()=>{
  const token=randomBytes(32).toString('base64url'),hostId=randomUUID()
  const exchanges=[]

+ 40 - 8
desktop/main.mjs

@@ -33,6 +33,7 @@ else {
   let updateWindow, updater, environmentHost, hostManager, onboarding
   let remoteMode = false, remoteURL = '', serverModeActive = false, switchingMode = false
   let recoveringService = false
+  let resumeLocalAfterUpdate = false
   const serviceURL = () => remoteMode ? remoteURL : manager?.state.url
   // Use the same Chromium networking as server detection/navigation. Auth owns
   // its cookie jar: do not send or overwrite another account's browser cookies.
@@ -218,6 +219,7 @@ else {
   app.on('window-all-closed', () => {})
   function requestQuit(stopBackground = false) {
     if (closing || quitting) return
+    const resumeLocal = serverModeActive && manager?.activationRequested === true
     closing = true; clearInterval(timer)
     updater?.stopChecking?.(); updater?.cancel(); updateWindow?.close()
     window?.hide()
@@ -240,9 +242,13 @@ else {
         else {
           closing=false
           if(remoteMode)await hostManager?.start().catch(error=>log(error.message))
-          else{
+          else if(serverModeActive){
             await manager?.reconnect().catch(error=>log(error.message))
-            await runnerManager?.start().catch(error=>log(error.message))
+            if(resumeLocal){
+              await manager?.activate().catch(error=>log(error.message))
+              environmentHost?.start()
+              await runnerManager?.start().catch(error=>log(error.message))
+            }
           }
           if (manager) stateChanged(manager.state)
           timer=setInterval(()=>{if(serverModeActive)void manager.check()},10000);timer.unref()
@@ -254,6 +260,7 @@ else {
   }
   async function prepareUpdateRestart() {
     if (closing || quitting) throw new Error('App 正在退出,请稍后重试')
+    resumeLocalAfterUpdate = serverModeActive && manager?.activationRequested === true
     closing = true; clearInterval(timer)
     await environmentHost?.close()
     await hostManager?.stop()
@@ -270,8 +277,11 @@ else {
     if (remoteMode) await hostManager?.start().catch(error => log(error.message))
     else if (serverModeActive) {
       await manager?.reconnect().catch(error => log(error.message))
-      environmentHost?.start()
-      await runnerManager?.start().catch(error => log(error.message))
+      if (resumeLocalAfterUpdate) {
+        await manager?.activate().catch(error => log(error.message))
+        environmentHost?.start()
+        await runnerManager?.start().catch(error => log(error.message))
+      }
     }
     clearInterval(timer)
     timer = setInterval(() => { if (serverModeActive) void manager?.check() }, 10000); timer.unref()
@@ -310,7 +320,7 @@ else {
         ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
       synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
         ? (onProgress, { force = false } = {}) => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress, force,
-          environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
+          environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1', HERMES_YAOYAO_DEFER_HERMES: '1' } })
         : undefined,
       fork: ({ home, port }) => {
         const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
@@ -318,6 +328,7 @@ else {
           HERMES_YAOYAO_DESKTOP: '1', HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_HOST: '127.0.0.1',
           HERMES_YAOYAO_PORT: String(port), HERMES_YAOYAO_STATIC_DIR: join(root, 'ui'),
           HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1',
+          HERMES_YAOYAO_DEFER_HERMES: '1',
           HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119' }
         delete env.ELECTRON_RUN_AS_NODE
         delete env.HERMES_YAOYAO_TLS_CERT; delete env.HERMES_YAOYAO_TLS_KEY
@@ -335,6 +346,8 @@ else {
     }
     async function runnerAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'执行节点操作未完成',detail:error.message})}}
     hostManager=new DesktopHostManager({home,root,dataRoot:app.getPath('userData'),
+      // Device polling must share the Chromium transport used to log in to LAN servers.
+      fetchImpl:authFetch,
       createCore:({root,dataRoot})=>new DesktopHostCore({root,dataRoot}),
       encrypt:value=>credentials.encrypt(value),
       decrypt:bytes=>credentials.decrypt(bytes),
@@ -356,6 +369,7 @@ else {
     })
     async function hostAction(action){try{await action()}catch(error){await dialog.showMessageBox(window,{type:'error',message:'电脑操作未完成',detail:error.message})}}
     const appDialog = options => window && !window.isDestroyed() ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
+    let authorizedOnboardingServer = ''
     async function authorizeComputer(server,session){
       const previous=await hostManager.read().catch(()=>undefined)
       const previousHostId=previous&&new URL(previous.serverURL).origin===new URL(server).origin?previous.hostId:undefined
@@ -421,12 +435,11 @@ else {
         if (force) await manager.retrySynchronization({ force: true })
         else await manager.start()
         if (manager.state.phase !== 'ready') throw new Error(manager.state.message)
-        if (!closing) environmentHost.start()
-        await runnerManager.start().catch(error => runnerManager.publish(error.message))
         return manager.state.url
       },
       register: ({ serverURL, username, password }) => remoteRegistration(serverURL, { username, password }, authFetch),
       authenticate: async ({ mode, serverURL, setup, username, password }) => {
+        authorizedOnboardingServer = ''
         const auth = await remoteSession(serverURL, { setup, username, password }, authFetch)
         for (const cookie of auth.cookieDetails) {
           await electronSession.defaultSession.cookies.set({ url: serverURL, ...cookie,
@@ -435,13 +448,15 @@ else {
         await electronSession.defaultSession.cookies.flushStore()
         let warning
         if (mode === 'remote' && auth.user.role === 'admin') {
-          try { await authorizeComputer(serverURL, auth) }
+          try { await authorizeComputer(serverURL, auth); authorizedOnboardingServer = serverURL }
           catch (error) { warning = `账号已登录,但电脑授权未恢复:${error.message}。可先进入夭夭,稍后在“电脑”菜单重新授权。` }
         }
         return { username: auth.user.username, warning }
       },
       activate: async ({ mode, serverURL, remember }) => {
         if (closing || quitting) throw new Error('App 正在退出')
+        const alreadyAuthorized = authorizedOnboardingServer === serverURL
+        authorizedOnboardingServer = ''
         if (mode === 'remote') {
           // Detach only; an independently installed local service keeps running.
           serverModeActive = false
@@ -450,9 +465,26 @@ else {
           await manager?.stop()
           remoteMode = true; remoteURL = serverURL
           await preferences.setRemoteServer(serverURL)
+          if (!alreadyAuthorized) {
+            // A remembered browser session skips password authentication. Restore
+            // its device authorization only on entry, never during detection.
+            try {
+              const current = await inspectServer(serverURL, (...args) => net.fetch(...args))
+              if (current.authenticated && current.user?.role === 'admin') {
+                const cookies = await electronSession.defaultSession.cookies.get({ url: serverURL })
+                await authorizeComputer(serverURL, { origin: serverURL, csrfToken: current.csrfToken,
+                  cookies: cookies.map(cookie => [cookie.name, cookie.value]) })
+              }
+            } catch (error) { hostManager.publish(`电脑授权未恢复:${error.message}`) }
+          }
           const enrolled = await hostManager.read().catch(error => { log(error.message); return undefined })
           if (enrolled && new URL(enrolled.serverURL).origin === new URL(serverURL).origin)
             await hostManager.start().catch(error => hostManager.publish(error.message))
+        } else {
+          await manager.activate()
+          if (closing || quitting) throw new Error('App 正在退出')
+          environmentHost.start()
+          await runnerManager.start().catch(error => runnerManager.publish(error.message))
         }
         await preferences.setStartupChoice(remember ? mode : 'ask')
         syncModeMenu()

+ 46 - 2
desktop/mode-switch.test.mjs

@@ -15,10 +15,35 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
   const identityReady=new Promise(resolve=>{releaseIdentity=resolve})
   const local=createServer(async(req,res)=>{
     if(req.url==='/desktop/service'){await identityReady;res.setHeader('Content-Type','application/json');res.end(JSON.stringify(identity))}
-    else if(req.url==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}))}
+    else if(req.url.split('?')[0]==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}))}
     else {res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的本机页面</title>')}
   })
-  const remote=createServer((_req,res)=>{if(_req.url==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}));return}res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的服务器页面</title>')})
+  let enrollments = 0, exchanges = 0, enrollmentFails = true
+  const deviceId = '99999999-9999-4999-8999-999999999999', deviceToken = 'fixture-machine-token-with-at-least-32-characters'
+  const remote=createServer((_req,res)=>{
+    if(_req.url.split('?')[0]==='/api/app/bootstrap'){
+      res.setHeader('Content-Type','application/json')
+      res.setHeader('Set-Cookie',['session=remembered; Path=/; HttpOnly','csrf=fixture; Path=/; HttpOnly'])
+      res.end(JSON.stringify({authenticated:true,csrfToken:'fixture',user:{id:'admin',role:'admin',username:'管理员'}}));return
+    }
+    if(_req.url==='/api/app/admin/desktop-hosts'&&_req.method==='POST'){
+      enrollments++
+      assert.equal(_req.headers.origin,remoteURL)
+      assert.equal(_req.headers['x-csrf-token'],'fixture')
+      assert.match(_req.headers.cookie,/session=remembered/)
+      res.setHeader('Content-Type','application/json')
+      if(enrollmentFails){res.statusCode=503;res.end(JSON.stringify({error:'fixture enrollment unavailable'}));return}
+      res.statusCode=201;res.end(JSON.stringify({host:{id:deviceId},token:deviceToken}));return
+    }
+    if(_req.url===`/api/desktop-host/v1/${deviceId}/exchange`){
+      exchanges++
+      assert.equal(_req.headers.authorization,`Bearer ${deviceToken}`)
+      assert.equal(_req.headers.origin,undefined)
+      assert.equal(_req.headers.cookie,undefined)
+      res.setHeader('Content-Type','application/json');res.end(JSON.stringify({commands:[]}));return
+    }
+    res.setHeader('Content-Type','text/html; charset=utf-8');res.end('<title>已登录的服务器页面</title>')
+  })
   await new Promise(done=>local.listen(0,'127.0.0.1',done))
   await new Promise(done=>remote.listen(0,'127.0.0.1',done))
   const origin=`http://127.0.0.1:${local.address().port}`,remoteURL=`http://127.0.0.1:${remote.address().port}`
@@ -33,6 +58,7 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     assert.equal(await page.evaluate(()=>window.yaoyaoDesktop.switchMode('invalid').then(()=>false,()=>true)),true)
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
     await page.waitForURL(remoteURL+'/**')
+    assert.equal(enrollments,1,'entering an already signed-in remote session restores this computer authorization')
     assert.deepEqual(await page.evaluate(()=>window.yaoyaoDesktop.modeState()),{mode:'client',serverURL:remoteURL,switching:false,platform:'darwin',supportedModes:['client','server']})
     await expect.poll(async()=>JSON.parse(await readFile(join(home,'desktop-preferences.json'),'utf8')).startupChoice).toBe('remote')
     // Unchecking "ask at startup" must retain the active client role.
@@ -62,8 +88,26 @@ test('signed-in desktop pages can switch both ways; other windows cannot control
     await page.locator('#prepare-local').click()
     await page.locator('#submit').click()
     await page.waitForURL(origin+'/**')
+    enrollmentFails = false
+    await app.evaluate(async (_electron, credentialsModule) => {
+      // Fixture credentials stay in this temporary directory; do not access Keychain.
+      const module = process.getBuiltinModule('module')
+      const { DesktopCredentials } = module.createRequire(credentialsModule)(credentialsModule)
+      DesktopCredentials.prototype.encrypt = async value => Buffer.from(value)
+      DesktopCredentials.prototype.decrypt = async value => value.toString('utf8')
+      globalThis.fetch = async () => { throw new Error('fixture Node transport is unavailable') }
+      const http = process.getBuiltinModule('http')
+      const originalRequest = http.request
+      http.request = (url, ...args) => {
+        if (url?.pathname?.startsWith('/api/desktop-host/')) throw new Error('fixture Node socket is unavailable')
+        return originalRequest(url, ...args)
+      }
+      module.syncBuiltinESMExports()
+    }, join(root, 'desktop/credentials.mjs'))
     await page.evaluate(()=>{void window.yaoyaoDesktop.switchMode('client')})
     await page.waitForURL(remoteURL+'/**')
+    await expect.poll(() => exchanges).toBeGreaterThan(0)
+    assert.equal(enrollments,2,'restoring the session retries device registration without a password login')
     await page.evaluate(()=>{void window.yaoyaoDesktop.openRemoteLogin()})
     await page.waitForURL('**/boot.html')
     assert.equal(app.windows().length,1)

+ 1 - 1
desktop/onboarding-flow.test.mjs

@@ -15,7 +15,7 @@ test('first launch, failed connection and remote login all stay in the only main
   let holdBootstrap = false, replyBootstrap, sessionValid = true
   const server = createServer((req, res) => {
     const json = (value, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(value)) }
-    if (req.url === '/api/app/bootstrap') {
+    if (req.url.split('?')[0] === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
       const reply = () => json({ authenticated: sessionValid && String(req.headers.cookie).includes('session=fixture'), setupRequired: false, registrationAvailable: true, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
       if (holdBootstrap) { replyBootstrap = reply; return }

+ 1 - 1
desktop/remote-login.mjs

@@ -47,7 +47,7 @@ class CookieJar{
 
 export async function inspectServer(serverURL,fetchImpl=globalThis.fetch){
   const base=normalizeServerURL(serverURL)
-  const response=await requestServer(fetchImpl,new URL('/api/app/bootstrap',base),{
+  const response=await requestServer(fetchImpl,new URL('/api/app/bootstrap?inspectOnly=1',base),{
     headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(10000),
   })
   if(!response.ok)throw new Error(`无法连接服务器(HTTP ${response.status})`)

+ 12 - 1
desktop/remote-login.test.mjs

@@ -2,7 +2,18 @@ import {test} from 'node:test'
 import assert from 'node:assert/strict'
 import {createServer} from 'node:http'
 import {randomUUID,randomBytes} from 'node:crypto'
-import {remoteSession,enrollDesktopHost,normalizeServerURL} from './remote-login.mjs'
+import {remoteSession,enrollDesktopHost,normalizeServerURL,inspectServer} from './remote-login.mjs'
+
+test('server inspection requests local login metadata without binding Hermes', async () => {
+  const calls = []
+  const info = { authenticated: true, csrfToken: 'fixture', serverKind: 'yaoyao-web', user: { username: 'admin' } }
+  const result = await inspectServer('http://127.0.0.1:15300', async (url, options) => {
+    calls.push({ path: url.pathname + url.search, method: options.method ?? 'GET' })
+    return Response.json(info)
+  })
+  assert.deepEqual(result, info)
+  assert.deepEqual(calls, [{ path: '/api/app/bootstrap?inspectOnly=1', method: 'GET' }])
+})
 
 test('server addresses are normalized like browser URLs',()=>{
   assert.equal(normalizeServerURL('192.168.1.10:15300'),'http://192.168.1.10:15300')

+ 50 - 0
desktop/service-activation.test.mjs

@@ -0,0 +1,50 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { EventEmitter } from 'node:events'
+import { mkdtemp, realpath, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DesktopServiceManager } from './service-manager.mjs'
+import { grantDesktopActivation, hasDesktopActivation } from '../bin/lib/desktop-activation.mjs'
+
+for (const legacy of [false, true]) test(`inspection preserves active background authorization when reusing a ${legacy ? 'legacy' : 'current'} service`, async () => {
+  const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-reuse-activation-')))
+  grantDesktopActivation(home)
+  const service = { url: 'http://127.0.0.1:15300', version: 'fixture', pid: 123, legacy }
+  const manager = new DesktopServiceManager({ home, fork: () => assert.fail('must reuse the live service') })
+  manager.readRecord = async () => legacy ? null : service
+  manager.verify = async () => service
+  manager.legacyService = async () => legacy ? service : null
+  try {
+    await manager.start()
+    assert.equal(manager.state.phase, 'ready')
+    assert.equal(hasDesktopActivation(home), true)
+    assert.equal(manager.activationRequested, false)
+    await manager.stop()
+    assert.equal(hasDesktopActivation(home), true, 'disconnecting must preserve independent background recovery')
+  } finally { await manager.stop(); await rm(home, { recursive: true, force: true }) }
+})
+
+test('inspection clears a previous authorization immediately before forking a new service', async () => {
+  const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-fork-activation-')))
+  grantDesktopActivation(home)
+  let forked = false
+  const child = new EventEmitter()
+  child.pid = 123
+  child.kill = () => child.emit('exit', 0)
+  const service = { url: 'http://127.0.0.1:15300', version: 'fixture', pid: child.pid }
+  const manager = new DesktopServiceManager({ home, fork: () => {
+    assert.equal(hasDesktopActivation(home), false, 'the child must start in inspection mode')
+    forked = true
+    return child
+  } })
+  manager.readRecord = async () => forked ? service : null
+  manager.verify = async () => service
+  manager.legacyService = async () => null
+  try {
+    await manager.start()
+    assert.equal(forked, true)
+    assert.equal(manager.state.phase, 'ready')
+    assert.equal(hasDesktopActivation(home), false)
+  } finally { await manager.stop(); await rm(home, { recursive: true, force: true }) }
+})

+ 26 - 0
desktop/service-manager.mjs

@@ -5,6 +5,7 @@ import { request as httpRequest } from 'node:http'
 import { request as httpsRequest } from 'node:https'
 import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
+import { deferDesktopActivation } from './desktop-activation.mjs'
 
 const exec = promisify(execFile)
 const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
@@ -47,6 +48,7 @@ export class DesktopServiceManager {
     this.generation = 0
     this.stopping = false
     this.restarts = []
+    this.activationRequested = false
   }
   publish(value) {
     if (value.phase === 'ready' && this.updateNotice) value = { ...value, updateNotice: this.updateNotice, message: `${value.message},Web 同步待完成` }
@@ -87,6 +89,20 @@ export class DesktopServiceManager {
     if (this.child?.pid === actual.pid && actual.version !== this.options.version) throw new Error('App 内置服务版本不匹配,请重新安装完整 App')
     return { ...actual, url: url.origin }
   }
+  async activate() {
+    if (this.state.legacy) { this.activationRequested = true; return }
+    const generation = this.generation, record = await this.readRecord()
+    if (!record) throw new Error('本机服务尚未就绪')
+    const service = await this.verify(record)
+    if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
+    await this.activateRecord(record, service)
+    if (this.stopping || generation !== this.generation) throw new Error('本机服务状态已改变,请重试')
+    this.activationRequested = true
+  }
+  async activateRecord(record, service) {
+    if (service.activationRequired)
+      await localJSON(new URL('/desktop/service/activate', service.url), { 'x-yaoyao-desktop-token': record.token }, 'POST')
+  }
   async legacyService() {
     // Old CLI/LaunchAgent builds lack the new descriptor. Prove the listener
     // actually holds this directory's SQLite file before reusing its own UI.
@@ -137,6 +153,8 @@ export class DesktopServiceManager {
       if (record?.url) {
         const service = await this.verify(record)
         if (generation !== this.generation || this.stopping) return
+        if (this.activationRequested) await this.activateRecord(record, service)
+        if (generation !== this.generation || this.stopping) return
         this.publish({ phase: 'ready', message: `已连接已有服务(${service.version})`, ...service, external: !this.child })
         return this.state
       }
@@ -145,6 +163,7 @@ export class DesktopServiceManager {
       if (legacy) { this.publish({ phase: 'ready', message: '已连接已有后台服务', ...legacy }); return this.state }
       if (!record) {
         if (this.options.synchronize) throw new Error('独立后台服务尚未就绪,请稍后重试')
+        deferDesktopActivation(this.root)
         const child = this.options.fork({ home: this.root, port: this.options.port })
         child.desktopExited = false
         this.child = child
@@ -168,6 +187,8 @@ export class DesktopServiceManager {
             const service = await this.verify(record)
             if (this.child && service.pid !== this.child.pid) throw new Error('服务启动期间归属发生变化')
             if (generation !== this.generation || this.stopping) return
+            if (this.activationRequested) await this.activateRecord(record, service)
+            if (generation !== this.generation || this.stopping) return
             this.publish({ phase: 'ready', message: '本地服务已就绪', ...service, external: !this.child })
             return this.state
           } catch (e) { lastError = e }
@@ -205,6 +226,9 @@ export class DesktopServiceManager {
         const record = await this.readRecord()
         if (!record) throw new Error('服务已停止')
         const service = await this.verify(record)
+        if (this.stopping || generation !== this.generation) return
+        if (this.activationRequested) await this.activateRecord(record, service)
+        if (this.stopping || generation !== this.generation) return
         if (service.pid !== this.state.pid || service.version !== this.state.version || service.build?.commit !== this.state.build?.commit)
           this.publish({ phase: 'ready', message: `已连接后台服务(${service.version})`, ...service, external: !this.child })
       }
@@ -239,6 +263,7 @@ export class DesktopServiceManager {
   async stop() {
     const owned = Boolean(this.child)
     this.stopping = true
+    this.activationRequested = false
     clearTimeout(this.restartTimer)
     ++this.generation
     await this.starting?.catch(() => {})
@@ -248,6 +273,7 @@ export class DesktopServiceManager {
   }
   async stopBackground() {
     this.stopping = true
+    this.activationRequested = false
     clearTimeout(this.restartTimer)
     ++this.generation
     await this.starting?.catch(() => {})

+ 33 - 0
desktop/service-manager.test.mjs

@@ -5,6 +5,39 @@ import {dataKey,DesktopServiceManager} from './service-manager.mjs'
 import {mkdtemp,realpath,writeFile,rm} from 'node:fs/promises'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
+import {DesktopOnboarding} from './onboarding.mjs'
+test('local inspection defers Hermes and Runner until entry and does not retain activation after disconnect',async()=>{
+  const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-deferred-start-')))
+  const record={protocol:1,instanceId:'deferred-fixture',pid:process.pid,dataKey:dataKey(home),version:'0.4.71',token:'private-token'}
+  let required=true,hermesStarts=0,runnerStarts=0
+  const server=createServer((req,res)=>{
+    assert.equal(req.headers['x-yaoyao-desktop-token'],record.token)
+    const {token,...identity}=record
+    res.setHeader('Content-Type','application/json')
+    if(req.url==='/desktop/service/activate'&&req.method==='POST'){required=false;hermesStarts++;res.end(JSON.stringify({activated:true}))}
+    else{assert.equal(req.url,'/desktop/service');assert.equal(req.method,'GET');res.end(JSON.stringify({...identity,activationRequired:required}))}
+  })
+  await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
+  record.url=`http://127.0.0.1:${server.address().port}`
+  await writeFile(join(home,'service-instance.json'),JSON.stringify(record))
+  const manager=new DesktopServiceManager({home,version:record.version})
+  const flow=new DesktopOnboarding({remoteServer:()=>'',prepareLocal:async()=>{await manager.start();return manager.state.url},inspect:async()=>({authenticated:true}),
+    activate:async()=>{await manager.activate();runnerStarts++},navigate:async()=>{}})
+  try{
+    flow.select('local');await flow.prepare()
+    assert.equal(flow.state.phase,'ready');assert.equal(hermesStarts,0);assert.equal(runnerStarts,0)
+    await flow.submit()
+    assert.equal(hermesStarts,1);assert.equal(runnerStarts,1)
+    await manager.activate();assert.equal(hermesStarts,1)
+    // A process replacement while actively using local mode retains this entry's intent.
+    required=true;await manager.check();assert.equal(hermesStarts,2)
+    await manager.stop();required=true
+    flow.open({mode:'local'});await flow.prepare()
+    assert.equal(hermesStarts,2);assert.equal(runnerStarts,1)
+    await flow.submit()
+    assert.equal(hermesStarts,3);assert.equal(runnerStarts,2)
+  }finally{await manager.stop();await new Promise(resolve=>server.close(resolve));await rm(home,{recursive:true,force:true})}
+})
 test('checks recorded and bundled versions while permitting a separately owned older service',async()=>{
   const root='/fixture/desktop',record={protocol:1,instanceId:'fixture',pid:1234,dataKey:dataKey(root),version:'0.3.32',token:'fixture'}
   let actual={...record};delete actual.token

+ 2 - 2
desktop/update-entry.test.mjs

@@ -17,7 +17,7 @@ test('the main local app can open the native updater and repeated requests reuse
   const identityReady = new Promise(resolve => { releaseIdentity = resolve })
   const server = createServer(async (req, res) => {
     if (req.url === '/desktop/service') { await identityReady; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(identity)) }
-    else if(req.url==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}))}
+    else if(req.url.split('?')[0]==='/api/app/bootstrap'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({authenticated:true,csrfToken:'fixture'}))}
     else { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end('<title>Update entry fixture</title><button onclick="window.yaoyaoDesktop.openUpdates()">检测更新</button>') }
   })
   await new Promise(done => server.listen(0, '127.0.0.1', done))
@@ -57,7 +57,7 @@ test('client mode opens local App updates without a local service or admin role'
   const root = resolve(import.meta.dirname, '..')
   const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-client-update-')))
   const remote = createServer((req, res) => {
-    if (req.url === '/api/app/bootstrap') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ authenticated: true, csrfToken: 'fixture', user: { role: 'member' } })) }
+    if (req.url.split('?')[0] === '/api/app/bootstrap') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ authenticated: true, csrfToken: 'fixture', user: { role: 'member' } })) }
     else { res.setHeader('Content-Type', 'text/html'); res.end('<title>Remote member</title><button onclick="window.yaoyaoDesktop.openUpdates()">App update</button>') }
   })
   await new Promise(done => remote.listen(0, '127.0.0.1', done))

+ 32 - 7
desktop/update-restart.test.mjs

@@ -7,16 +7,18 @@ import { createServer } from 'node:http'
 import { _electron as electron, expect } from '@playwright/test'
 import { dataKey } from './service-manager.mjs'
 
-for (const scenario of ['remote', 'local', 'remote-failure']) test(`${scenario} restart releases the lock, preserves independent servers and recovers on failure`, { timeout: 60000 }, async () => {
-  const mode = scenario === 'local' ? 'local' : 'remote', fail = scenario === 'remote-failure'
+for (const scenario of ['remote', 'local', 'remote-failure', 'local-failure', 'local-checking-failure']) test(`${scenario} restart releases the lock, preserves independent servers and recovers on failure`, { timeout: 60000 }, async () => {
+  const mode = scenario.startsWith('local') ? 'local' : 'remote', fail = scenario.endsWith('failure'), checking = scenario === 'local-checking-failure'
   const root = resolve(import.meta.dirname, '..'), home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-update-restart-')))
   const version = JSON.parse(await readFile(join(root, 'release.json'), 'utf8')).webVersion
   const identity = { protocol: 1, instanceId: 'update-restart', pid: process.pid, dataKey: dataKey(home), version }
+  let activationRequired = true, activations = 0, inspections = 0
   let releaseIdentity; const identityReady = new Promise(resolve => { releaseIdentity = resolve })
   const server = createServer(async (req, res) => {
     res.setHeader('Content-Type', 'application/json')
-    if (req.url === '/desktop/service') { await identityReady; res.end(JSON.stringify(identity)) }
-    else if (req.url === '/api/app/bootstrap') res.end(JSON.stringify({ authenticated: true, csrfToken: 'fixture', user: { role: 'member' } }))
+    if (req.url === '/desktop/service') { await identityReady; inspections++; res.end(JSON.stringify({ ...identity, activationRequired })) }
+    else if (req.url === '/desktop/service/activate') { assert.equal(req.method, 'POST'); assert.equal(req.headers['x-yaoyao-desktop-token'], 'fixture'); if (activationRequired) activations++; activationRequired = false; res.end(JSON.stringify({ activated: true })) }
+    else if (req.url.split('?')[0] === '/api/app/bootstrap') res.end(JSON.stringify({ authenticated: !checking, csrfToken: 'fixture', user: { role: 'member' } }))
     else { res.setHeader('Content-Type', 'text/html'); res.end('<title>Connected server</title>') }
   })
   await new Promise(done => server.listen(0, '127.0.0.1', done))
@@ -27,7 +29,20 @@ for (const scenario of ['remote', 'local', 'remote-failure']) test(`${scenario}
   try {
     app = await electron.launch({ args: [root], cwd: root, env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home,
       HERMES_YAOYAO_DESKTOP_TEST_AUTO_UPDATE: '1', HERMES_YAOYAO_DESKTOP_TEST_SYNC: '0', HERMES_YAOYAO_DESKTOP_PORT: mode === 'local' ? String(server.address().port) : '1' } })
-    const page = await app.firstWindow(); releaseIdentity(); await page.waitForURL(origin + '/**')
+    const page = await app.firstWindow(); releaseIdentity()
+    if (checking) await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.status().then(state => state.phase))).toBe('ready')
+    else await page.waitForURL(origin + '/**')
+    assert.equal(activations, mode === 'local' && !checking ? 1 : 0)
+    if (mode === 'local' && fail) {
+      // Failed quit must resume only a local runtime the user already entered.
+      activationRequired = true
+      const before = inspections
+      await app.evaluate(({ dialog, Menu }) => { dialog.showMessageBox = async () => ({ response: 0 }); Menu.getApplicationMenu().getMenuItemById('desktop-stop-and-quit').click() })
+      await expect.poll(() => inspections).toBeGreaterThan(before)
+      await expect.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isVisible())).toBe(true)
+      if (!checking) await expect.poll(() => activations).toBe(2)
+      else assert.equal(activations, 0)
+    }
     await app.evaluate(async ({ app }, { root, home, fail }) => {
       const { createRequire } = process.getBuiltinModule('node:module'), { writeFileSync } = process.getBuiltinModule('node:fs')
       const { autoUpdater } = createRequire(root + '/package.json')(root + '/.desktop-build/electron-updater.cjs')
@@ -39,16 +54,26 @@ for (const scenario of ['remote', 'local', 'remote-failure']) test(`${scenario}
         app.quit()
       }
     }, { root, home, fail })
-    const opened = app.waitForEvent('window'); await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
+    const opened = app.waitForEvent('window')
+    if (checking || mode === 'local' && fail) await app.evaluate(({ Menu }) => Menu.getApplicationMenu().getMenuItemById('desktop-update-check').click())
+    else await page.evaluate(() => window.yaoyaoDesktop.openUpdates())
     const updates = await opened
     await updates.getByRole('button', { name: '下载更新', exact: true }).click()
     await expect(updates.getByRole('button', { name: '重启更新', exact: true })).toBeEnabled()
     const closed = fail ? undefined : app.waitForEvent('close')
+    if (mode === 'local' && fail) activationRequired = true
     await updates.getByRole('button', { name: '重启更新', exact: true }).click().catch(error => { if (!updates.isClosed()) throw error })
     if (fail) {
       await expect(updates.getByRole('alert')).toContainText('fixture restart failed')
       assert.equal(await app.evaluate(({ app }) => app.hasSingleInstanceLock()), true)
-      assert.equal((await page.evaluate(() => window.yaoyaoDesktop.modeState())).mode, 'client')
+      if (checking) {
+        assert.equal((await page.evaluate(() => window.yaoyaoDesktop.status())).active, true)
+        assert.equal(activations, 0)
+      } else {
+        await page.waitForURL(origin + '/**')
+        await expect.poll(() => page.evaluate(() => window.yaoyaoDesktop.modeState().then(state => state.mode)).catch(() => null)).toBe(mode === 'local' ? 'server' : 'client')
+        if (mode === 'local') assert.equal(activations, 3)
+      }
     } else { await closed; app = undefined }
     assert.deepEqual(JSON.parse(await readFile(join(home, 'restart-proof.json'), 'utf8')), { holdsAppLock: false })
     assert.equal((await fetch(origin + '/api/app/bootstrap')).status, 200)

+ 1 - 1
desktop/windows-smoke.test.mjs

@@ -12,7 +12,7 @@ test('packaged Windows client logs in, encrypts pairing, restores its session an
   let exchanges = 0, app
   const server = createServer((req, res) => {
     const json = (body, status = 200) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)) }
-    if (req.url === '/api/app/bootstrap') {
+    if (req.url.split('?')[0] === '/api/app/bootstrap') {
       res.setHeader('set-cookie', 'csrf=fixture; Path=/; HttpOnly')
       return json({ authenticated: String(req.headers.cookie).includes('session=fixture'), setupRequired: false, csrfToken: 'fixture', serverKind: 'yaoyao-web' })
     }

+ 2 - 1
scripts/build-desktop.mjs

@@ -32,8 +32,9 @@ await cp(resolve(root, 'public/icons/icon-512.png'), resolve(root, 'desktop/icon
 const shell = resolve(out, 'shell')
 await mkdir(shell)
 await cp(resolve(root, 'bin/lib/data-home.mjs'), resolve(shell, 'data-home.mjs'))
+await cp(resolve(root, 'bin/lib/desktop-activation.mjs'), resolve(shell, 'desktop-activation.mjs'))
 for (const file of await readdir(resolve(root, 'desktop'))) {
-  if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
+  if (['data-home.mjs', 'desktop-activation.mjs'].includes(file) || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
   if(['file-transfer.mjs','host-files.mjs'].includes(file)){await build({entryPoints:[resolve(root,'desktop',file)],outfile:resolve(shell,file),bundle:true,platform:'node',format:'esm',target:'node24'});continue}
   if(/\.(js|mjs|cjs)$/.test(file))execFileSync(process.execPath,['--check',resolve(root,'desktop',file)],{stdio:'inherit'})
   await cp(resolve(root, 'desktop', file), resolve(shell, file))

+ 2 - 1
scripts/build-windows-desktop.mjs

@@ -24,8 +24,9 @@ await bundleDesktopUpdater(join(out, 'electron-updater.cjs'), 'win32')
 await build({ entryPoints: [join(root, 'src/server/githubReleases.ts')], outfile: join(out, 'github-release.mjs'),
   bundle: true, platform: 'node', format: 'esm', target: 'node24' })
 await cp(join(root, 'bin/lib/data-home.mjs'), join(shell, 'data-home.mjs'))
+await cp(join(root, 'bin/lib/desktop-activation.mjs'), join(shell, 'desktop-activation.mjs'))
 for (const file of await readdir(join(root, 'desktop'))) {
-  if (file === 'data-home.mjs' || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
+  if (['data-home.mjs', 'desktop-activation.mjs'].includes(file) || file.endsWith('.test.mjs') || !/\.(js|mjs|cjs|html|css|png|svg)$/.test(file)) continue
   if (['file-transfer.mjs', 'host-files.mjs'].includes(file)) {
     await build({ entryPoints: [join(root, 'desktop', file)], outfile: join(shell, file), bundle: true, platform: 'node', format: 'esm', target: 'node24' })
   } else {

+ 4 - 0
src/client/components/workspace/transcriptStore.ts

@@ -118,6 +118,10 @@ export class WorkspaceTranscriptStore {
     return detail
   }
   hydrate(snapshot: WorkspaceSnapshot): void {
+    if (!snapshot || !Number.isSafeInteger(snapshot.cursor) || snapshot.cursor < 0
+      || !Array.isArray(snapshot.agents) || !Array.isArray(snapshot.conversations) || !Array.isArray(snapshot.details)) {
+      throw new Error('Bot 会话数据尚未就绪,正在重新同步')
+    }
     const reset = snapshot.cursor === 0 && snapshot.conversations.length === 0
     const previous = new Map(reset ? [] : this.details)
     if (reset) this.reads.clear()

+ 38 - 8
src/server/app.ts

@@ -71,6 +71,7 @@ import { HermesBridgeManager } from './hermesBridge.js'
 import type { DashboardController } from './dashboardController.js'
 
 export interface ApplicationOptions {
+  deferBackground?: boolean
   dashboardSupervisor?: DashboardController
   hermesBridge?: HermesBridgeManager
   config?: ServerConfig
@@ -126,6 +127,7 @@ export interface ApplicationRuntime {
   workspaceRoutines:WorkspaceRoutines
   workspacePlugins:WorkspacePlugins
   localVm: LocalVmService
+  startBackground(): void
   close(): void
 }
 
@@ -262,9 +264,15 @@ export function createApplication(options: ApplicationOptions = {}): Application
   })
   workspaceRuntime.assertCanSubmit=()=>hermesBridge.assertDashboardAvailable()
   const workspaceMemory = new WorkspaceMemorySynthesis(workspaceRuntime)
-  workspaceMemory.start()
   const openVikingSessionSync = new OpenVikingSessionSync(workspace, openVikingService, owner => auth.isUserActive(owner))
-  openVikingSessionSync.start()
+  let backgroundStarted = false, closed = false
+  const startBackground = () => {
+    if (backgroundStarted || closed) return
+    backgroundStarted = true
+    workspaceMemory.start()
+    openVikingSessionSync.start()
+  }
+  if (!options.deferBackground) startBackground()
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
   const desktopEnvironments=new DesktopEnvironments(workspace,auth,workspaceNodes)
   const desktopHosts=new DesktopHostHub(workspace,auth)
@@ -570,7 +578,9 @@ export function createApplication(options: ApplicationOptions = {}): Application
     pushEventCoordinator,
     chatPushJobs,
     chatCache,
+    startBackground,
     close: () => {
+      closed = true
       openVikingSessionSync.close()
       workspaceMemory.close()
       runners.close()
@@ -596,10 +606,11 @@ export function createApplication(options: ApplicationOptions = {}): Application
 
 export interface NodeServerRuntime {
   server: HttpServer
+  startBackground(): void
   close(closeConnections?: boolean): Promise<void>
 }
 
-export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
+export function createNodeServer(runtime: ApplicationRuntime, options: { deferBackground?: boolean } = {}): NodeServerRuntime {
   const { config } = runtime
   const server: HttpServer = config.tlsCert && config.tlsKey
     ? createHttpsServer({
@@ -607,23 +618,42 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
         key: readFileSync(config.tlsKey),
       }, runtime.app.callback())
     : createHttpServer(runtime.app.callback())
+  let backgroundStarted = false, localVmStarted = false, closed = false
   const synchronizePushObservers = (enabled = runtime.push.isAnyProviderEnabled()) => {
+    if (!backgroundStarted || closed) return
     if (enabled) {
       runtime.chatPushJobs.start()
     } else {
       runtime.chatPushJobs.stop()
     }
   }
-  server.once('listening',()=>{const address=server.address();if(address&&typeof address!=='string')void runtime.localVm.start(`${config.tlsCert?'https':'http'}://127.0.0.1:${address.port}`).catch(error=>console.error('本地虚拟机服务启动失败',error.message))})
-  runtime.workspaceRuntime.start()
-  runtime.workspaceRoutines.start()
-  runtime.grokAuth.start()
-  synchronizePushObservers()
+  const startLocalVm = () => {
+    if (!backgroundStarted || localVmStarted || closed) return
+    const address = server.address()
+    if (!address || typeof address === 'string') return
+    localVmStarted = true
+    void runtime.localVm.start(`${config.tlsCert ? 'https' : 'http'}://127.0.0.1:${address.port}`)
+      .catch(error => console.error('本地虚拟机服务启动失败', error.message))
+  }
+  const startBackground = () => {
+    if (backgroundStarted || closed) return
+    backgroundStarted = true
+    runtime.startBackground()
+    runtime.workspaceRuntime.start()
+    runtime.workspaceRoutines.start()
+    runtime.grokAuth.start()
+    synchronizePushObservers()
+    startLocalVm()
+  }
+  server.once('listening', startLocalVm)
+  if (!options.deferBackground) startBackground()
   const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
   const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
   return {
     server,
+    startBackground,
     close: async (closeConnections = false) => {
+      closed = true
       await runtime.localVm.close()
       removePushConfigurationListener()
       removeWebSockets()

+ 22 - 0
src/server/desktopActivation.ts

@@ -0,0 +1,22 @@
+import { grantDesktopActivation, hasDesktopActivation } from '../../bin/lib/desktop-activation.mjs'
+
+/** A successful entry authorizes independent background restarts. A desktop
+ * check clears that grant only immediately before it starts a new service. */
+export class DesktopActivation {
+  private activated: boolean
+  private started = false
+  constructor(private readonly home: string, deferred: boolean, private readonly launch: () => void) {
+    this.activated = !deferred || hasDesktopActivation(home)
+  }
+  get required() { return !this.activated }
+  start() {
+    if (!this.activated || this.started) return
+    this.launch()
+    this.started = true
+  }
+  activate() {
+    grantDesktopActivation(this.home)
+    this.activated = true
+    this.start()
+  }
+}

+ 9 - 4
src/server/index.ts

@@ -13,6 +13,7 @@ import { migrateDataHome } from '../../bin/lib/data-migration.mjs'
 import { legacyDataHome, resolveDataHome } from '../../bin/lib/data-home.mjs'
 import { spawn } from 'node:child_process'
 import { ServerDashboardController } from './dashboardController.js'
+import { DesktopActivation } from './desktopActivation.js'
 
 migrateDataHome(resolveDataHome(process.env.YAOYAO_HOME || process.env.HERMES_YAOYAO_HOME, { preserveActiveUpdate: true }))
 const config = loadServerConfig()
@@ -29,13 +30,17 @@ const dashboardSupervisor = config.superviseDashboard
   ? new DashboardSupervisor({ managed: process.env.HERMES_YAOYAO_DESKTOP === '1' })
   : undefined
 const dashboardController = new ServerDashboardController(config.upstream, dashboardSupervisor)
-const runtime = createApplication({config, dashboardSupervisor: dashboardController})
-const nodeRuntime = createNodeServer(runtime)
+const runtime = createApplication({config, dashboardSupervisor: dashboardController, deferBackground: true})
+const nodeRuntime = createNodeServer(runtime, {deferBackground:true})
+const desktopActivation = new DesktopActivation(config.home, process.env.HERMES_YAOYAO_DEFER_HERMES === '1', () => {
+  nodeRuntime.startBackground()
+  dashboardSupervisor?.start()
+})
 let closeFrontend = async (): Promise<void> => undefined
 // Native update admission runs before routes (including streamed requests).
 // The capability is loopback-only and never shared with the Web renderer.
 runtime.app.middleware.unshift(instance.middleware(shutdown, () => runtime.realtime.broker.idleForUpdate
-  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate && runtime.desktopEnvironments.idleForUpdate && runtime.hermesBridge.idleForUpdate, ctx=>runtime.desktopEnvironments.bridge(ctx)))
+  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate && runtime.desktopEnvironments.idleForUpdate && runtime.hermesBridge.idleForUpdate, ctx=>runtime.desktopEnvironments.bridge(ctx), desktopActivation))
 
 if (runtime.config.production) {
   const dist = resolve(process.env.HERMES_YAOYAO_STATIC_DIR || resolve(process.cwd(), 'dist'))
@@ -95,7 +100,7 @@ nodeRuntime.server.listen(runtime.config.port, runtime.config.host, () => {
   if (runtime.config.insecureLan) {
     console.warn('Warning: trusted-LAN HTTP mode is enabled; credentials are not encrypted in transit.')
   }
-  dashboardSupervisor?.start()
+  desktopActivation.start()
 })
 
 let closing = false

+ 6 - 3
src/server/routes.ts

@@ -801,6 +801,7 @@ async function bootstrap(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
   rotateCsrf = false,
+  inspectOnly = false,
 ): Promise<void> {
   const user = dependencies.auth.current(ctx)
   const csrfToken = dependencies.csrf.issue(ctx, rotateCsrf)
@@ -820,11 +821,13 @@ async function bootstrap(
     })
     return
   }
-  let status: JsonObject = { state: user.mustChangePassword ? 'password_change_required' : 'degraded' }
+  let status: JsonObject = { state: user.mustChangePassword ? 'password_change_required' : inspectOnly ? 'ready' : 'degraded' }
   let profiles: unknown[] = []
   let upstreamReady = false
   let upstreamError: string | undefined
-  if (!user.mustChangePassword) {
+  // Desktop connection checks need only local account state. Even an existing
+  // login must not acquire Hermes credentials or open its service session here.
+  if (!user.mustChangePassword && !inspectOnly) {
     let timeout: ReturnType<typeof setTimeout> | undefined
     try {
       // Local login/settings must remain usable even when 9119 never responds.
@@ -1705,7 +1708,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       })
       return
     }
-    await bootstrap(ctx, dependencies)
+    await bootstrap(ctx, dependencies, false, ctx.query.inspectOnly === '1')
   })
   router.post('/api/app/setup', async (ctx) => {
     await setup(ctx, dependencies)

+ 9 - 3
src/server/serviceInstance.ts

@@ -60,9 +60,9 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
       } catch { /* Never remove a lock that can no longer be identified. */ }
       finally { lock.close() }
     },
-    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true, environment?: (ctx:Koa.Context)=>Promise<void>): Koa.Middleware {
+    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true, environment?: (ctx:Koa.Context)=>Promise<void>, activation?: {readonly required:boolean;activate():void|Promise<void>}): Koa.Middleware {
       return async (ctx, next) => {
-        const control = ctx.path === '/desktop/environment' || ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce'
+        const control = ctx.path === '/desktop/environment' || ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce' || ctx.path === '/desktop/service/activate'
         if (!control) {
           if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
           if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
@@ -80,6 +80,12 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
         if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
         ctx.set('Cache-Control', 'no-store')
         if(ctx.path==='/desktop/environment'){if(environment){try{await environment(ctx)}catch(error){ctx.status=(error as any).status??500;ctx.body={error:ctx.status<500?(error as Error).message:'桌面连接请求失败',code:(error as any).code??'desktop_error'}}}else ctx.status=404;return}
+        if (ctx.path === '/desktop/service/activate') {
+          if (ctx.method !== 'POST' || !activation) { ctx.status = 405; return }
+          if (stopping || quiesced()) { ctx.status = 503; ctx.body = { error: '后台服务暂时不能激活' }; return }
+          await activation.activate()
+          ctx.body = { activated: true }; return
+        }
         if (ctx.path.endsWith('/quiesce')) {
           if (ctx.method === 'POST') {
             if (pendingMutations || !idle()) { ctx.status = 409; ctx.body = { error: '正在等待任务完成', code: 'service_busy' }; return }
@@ -89,7 +95,7 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
           } else { ctx.status = 405; ctx.body = { error: '更新事务尚未结束' } }
         } else if (ctx.method === 'GET') {
           const { token: _token, ...publicRecord } = record
-          ctx.body = { ...publicRecord, quiesced: quiesced(), stopping }
+          ctx.body = { ...publicRecord, quiesced: quiesced(), stopping, activationRequired: activation?.required ?? false }
         } else if (ctx.method === 'DELETE' && desktopOwned) {
           ctx.body = { stopping: true }
           setImmediate(() => { void shutdown() })

+ 14 - 0
tests/client/conversationRecovery.test.ts

@@ -37,6 +37,20 @@ function successfulResponse(path: string) {
   if (path === '/api/app/workspace/snapshot') return { agents: [], conversations: [], details: [], cursor: 17 }
   throw new Error(`Unexpected request ${path}`)
 }
+it.each([undefined, null, { agents: [], conversations: [], details: [] }])('recovers incomplete Bot initialization data without requiring a virtual machine', async snapshot => {
+  let snapshots = 0
+  vi.mocked(apiRequest).mockImplementation(async path => {
+    if (path === '/api/app/workspace/snapshot' && snapshots++ === 0) return snapshot as never
+    return successfulResponse(path) as never
+  })
+  await mountView()
+  expect(sources).toHaveLength(0)
+  expect(wrapper!.get('[role="alert"]').text()).toContain('Bot 会话数据尚未就绪')
+  expect(wrapper!.text()).not.toContain('TypeError')
+  await vi.advanceTimersByTimeAsync(1500); await flushPromises()
+  expect(sources.map(source => source.url)).toEqual(['/api/app/events/stream?after=17'])
+  expect(vi.mocked(apiRequest).mock.calls.some(([path]) => /local-vm|computer/.test(path))).toBe(false)
+})
 it('recovers an initial server outage while the browser stays online', async () => {
   vi.mocked(apiRequest).mockRejectedValueOnce(new Error('服务暂不可用')).mockImplementation(async path => successfulResponse(path) as never)
   await mountView()

+ 8 - 0
tests/client/workspaceTranscript.test.ts

@@ -8,6 +8,14 @@ const detail = (): WorkspaceDetail => ({ conversation, messages: [message('old',
 const event = (seq: number, value: WorkspaceMessage): WorkspaceEvent => ({ seq, type: 'message.changed', conversationId: value.conversationId, data: value })
 
 describe('Bot transcript cache', () => {
+  it.each([undefined, null, { agents: [], conversations: [], details: [] }, { cursor: 0 }])('keeps the last transcript when a snapshot response is incomplete', snapshot => {
+    const store = new WorkspaceTranscriptStore(), initial = detail()
+    store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
+    expect(() => store.hydrate(snapshot as any)).toThrow('Bot 会话数据尚未就绪')
+    expect(store.cursor).toBe(10)
+    expect(store.conversations).toEqual([conversation])
+    expect(store.get('c')).toBe(initial)
+  })
   it('keeps independent clients ordered after snapshots, pin changes and new activity', () => {
     const rows: WorkspaceConversation[] = [
       { ...conversation, id: 'tie-b', lastMessageAt: 200 },

+ 17 - 0
tests/server/app.test.ts

@@ -106,6 +106,23 @@ function cookieHeader(response: request.Response): string {
 }
 
 describe('15300 BFF', () => {
+  it.each([
+    { create: createUnauthenticatedApplication, authenticated: false },
+    { create: createApplication, authenticated: true },
+    { create: createUserAuthenticatedApplication, authenticated: true },
+  ])('inspects local login state without binding a Hermes session (authenticated=$authenticated)', async ({ create, authenticated }) => {
+    const records: RecordedRequest[] = []
+    const runtime = create({ config: makeConfig(), fetchImpl: fakeGateway(records) })
+    runtimes.push(runtime)
+    const result = await request(runtime.app.callback()).get('/api/app/bootstrap?inspectOnly=1')
+      .set('Host', '127.0.0.1:15300').expect(200)
+    expect(result.body).toMatchObject({ authenticated, serverKind: 'yaoyao-web', profiles: [] })
+    expect(result.body.registrationAvailable).toEqual(expect.any(Boolean))
+    expect(runtime.csrf.verify(cookieHeader(result), result.body.csrfToken)).toBe(true)
+    expect(result.headers['cache-control']).toContain('no-store')
+    expect(records).toEqual([])
+  })
+
   it.each([
     { create: createUnauthenticatedApplication, userId: null },
     { create: createApplication, userId: 'test-admin' },

+ 89 - 0
tests/server/backgroundActivation.test.ts

@@ -0,0 +1,89 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it, vi } from 'vitest'
+import { createNodeServer } from '../../src/server/app'
+import { loadServerConfig } from '../../src/server/config'
+import { PushCoordinator } from '../../src/server/pushCoordinator'
+import { WorkspaceMemorySynthesis } from '../../src/server/workspaceMemorySynthesis'
+import { OpenVikingSessionSync } from '../../src/server/openVikingSessionSync'
+import { createAuthenticatedApplication } from './authenticatedApplication'
+
+afterEach(() => vi.restoreAllMocks())
+
+function fixture(deferBackground: boolean) {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-background-activation-'))
+  const memory = vi.spyOn(WorkspaceMemorySynthesis.prototype, 'start').mockImplementation(() => {})
+  const memorySync = vi.spyOn(OpenVikingSessionSync.prototype, 'start').mockImplementation(() => {})
+  const push = new PushCoordinator({ home, autoFlush: false,
+    fcmProviderFactory: () => ({ send: async () => ({ disposition: 'success', status: 200 }) }) })
+  const runtime = createAuthenticatedApplication({ deferBackground, push,
+    config: loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_ALLOWED_HOSTS: '127.0.0.1' }) })
+  const starts = {
+    memory, memorySync,
+    workspace: vi.spyOn(runtime.workspaceRuntime, 'start').mockImplementation(() => {}),
+    routines: vi.spyOn(runtime.workspaceRoutines, 'start').mockImplementation(() => {}),
+    grok: vi.spyOn(runtime.grokAuth, 'start').mockImplementation(() => {}),
+  }
+  const localVm = vi.spyOn(runtime.localVm, 'start').mockResolvedValue(undefined)
+  const chatStart = vi.spyOn(runtime.chatPushJobs, 'start').mockImplementation(() => {})
+  const chatStop = vi.spyOn(runtime.chatPushJobs, 'stop').mockImplementation(() => {})
+  const node = createNodeServer(runtime, { deferBackground })
+  const listen = () => new Promise<void>(resolve => node.server.listen(0, '127.0.0.1', resolve))
+  const close = async () => { try { await node.close() } finally { rmSync(home, { recursive: true, force: true }) } }
+  return { runtime, push, node, starts, localVm, chatStart, chatStop, listen, close }
+}
+
+it('keeps environment detection idle after listening and starts all background work once on activation', async () => {
+  const f = fixture(true)
+  try {
+    await f.listen()
+    for (const start of Object.values(f.starts)) expect(start).not.toHaveBeenCalled()
+    expect(f.localVm).not.toHaveBeenCalled()
+    expect(f.chatStart).not.toHaveBeenCalled()
+    expect(f.chatStop).not.toHaveBeenCalled()
+    await f.push.configureFCM({ serviceAccountFile: '/private/unused.json', projectId: 'fixture', packageName: 'cn.samien.yaoyao.hermes' })
+    expect(f.chatStart).not.toHaveBeenCalled()
+
+    f.node.startBackground()
+    f.node.startBackground()
+    for (const start of Object.values(f.starts)) expect(start).toHaveBeenCalledTimes(1)
+    expect(f.localVm).toHaveBeenCalledTimes(1)
+    const address = f.node.server.address()
+    expect(f.localVm).toHaveBeenCalledWith(`http://127.0.0.1:${typeof address === 'object' && address?.port}`)
+    expect(f.chatStart).toHaveBeenCalledTimes(1)
+    await f.push.configureFCM()
+    expect(f.chatStop).toHaveBeenCalledTimes(1)
+  } finally { await f.close() }
+  f.node.startBackground()
+  f.runtime.startBackground()
+  for (const start of Object.values(f.starts)) expect(start).toHaveBeenCalledTimes(1)
+})
+
+it('holds local VM startup until listening when activated before the HTTP server is ready', async () => {
+  const f = fixture(true)
+  try {
+    f.node.startBackground()
+    f.node.startBackground()
+    for (const start of Object.values(f.starts)) expect(start).toHaveBeenCalledTimes(1)
+    expect(f.localVm).not.toHaveBeenCalled()
+    await f.listen()
+    expect(f.localVm).toHaveBeenCalledTimes(1)
+    f.node.startBackground()
+    expect(f.localVm).toHaveBeenCalledTimes(1)
+  } finally { await f.close() }
+})
+
+it('preserves automatic background startup for ordinary servers', async () => {
+  const f = fixture(false)
+  try {
+    for (const start of Object.values(f.starts)) expect(start).toHaveBeenCalledTimes(1)
+    expect(f.chatStop).toHaveBeenCalledTimes(1)
+    expect(f.localVm).not.toHaveBeenCalled()
+    await f.listen()
+    expect(f.localVm).toHaveBeenCalledTimes(1)
+    f.node.startBackground()
+    for (const start of Object.values(f.starts)) expect(start).toHaveBeenCalledTimes(1)
+  } finally { await f.close() }
+})

+ 59 - 0
tests/server/desktopActivation.test.ts

@@ -0,0 +1,59 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DesktopActivation } from '../../src/server/desktopActivation'
+import { deferDesktopActivation, desktopActivationPath } from '../../bin/lib/desktop-activation.mjs'
+
+let home: string
+beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'yaoyao-runtime-activation-')) })
+afterEach(() => { rmSync(home, { recursive: true, force: true }) })
+
+describe('desktop environment preparation', () => {
+  it('keeps Hermes stopped until entering local mode and starts it once', () => {
+    const launch = vi.fn(), activation = new DesktopActivation(home, true, launch)
+    activation.start(); activation.start()
+    expect(activation.required).toBe(true)
+    expect(launch).not.toHaveBeenCalled()
+    expect(existsSync(desktopActivationPath(home))).toBe(false)
+    activation.activate(); activation.activate(); activation.start()
+    expect(activation.required).toBe(false)
+    expect(launch).toHaveBeenCalledTimes(1)
+    if (process.platform !== 'win32') expect(statSync(desktopActivationPath(home)).mode & 0o777).toBe(0o600)
+  })
+  it('resumes an authorized independent background process after restart', () => {
+    const launch = vi.fn(), previous = new DesktopActivation(home, true, launch)
+    previous.activate()
+    const replacement = new DesktopActivation(home, true, launch)
+    replacement.start()
+    expect(replacement.required).toBe(false)
+    expect(launch).toHaveBeenCalledTimes(2)
+  })
+  it('requires a fresh entry when a later environment check starts the service', () => {
+    const launch = vi.fn(), previous = new DesktopActivation(home, true, launch)
+    previous.activate()
+    deferDesktopActivation(home)
+    const replacement = new DesktopActivation(home, true, launch)
+    replacement.start()
+    expect(replacement.required).toBe(true)
+    expect(launch).toHaveBeenCalledTimes(1)
+    replacement.activate()
+    expect(launch).toHaveBeenCalledTimes(2)
+  })
+  it('preserves explicitly configured independent service supervision', () => {
+    const launch = vi.fn(), activation = new DesktopActivation(home, false, launch)
+    activation.start(); activation.activate()
+    expect(activation.required).toBe(false)
+    expect(launch).toHaveBeenCalledTimes(1)
+  })
+  it('does not trust a malformed or unsupported saved activation', () => {
+    for (const value of ['broken', '{}', '{"version":2}']) {
+      writeFileSync(desktopActivationPath(home), value)
+      const launch = vi.fn(), activation = new DesktopActivation(home, true, launch)
+      activation.start()
+      expect(activation.required).toBe(true)
+      expect(launch).not.toHaveBeenCalled()
+    }
+  })
+})

+ 58 - 0
tests/server/desktopBoot.test.ts

@@ -0,0 +1,58 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const native = vi.hoisted(() => ({ loaded: true, calls: [] as string[][] }))
+vi.mock('node:child_process', () => ({ execFileSync: (command: string, args: string[]) => {
+  native.calls.push([command, ...args])
+  if (args[0] === 'print' && !native.loaded) throw new Error('not loaded')
+  if (args[0] === 'bootout') native.loaded = false
+  if (args[0] === 'bootstrap') native.loaded = true
+  return ''
+} }))
+import { LaunchAgentService } from '../../bin/lib/service-update.mjs'
+
+let home: string
+beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'yaoyao-launchagent-boot-')); native.loaded = true; native.calls = [] })
+afterEach(() => { rmSync(home, { recursive: true, force: true }) })
+function fixture(pid?: number) {
+  const driver = new LaunchAgentService({ home, releaseRoot: join(home, 'releases'), label: 'fixture', environment: { HERMES_YAOYAO_DEFER_HERMES: '1' } })
+  driver.pid = () => pid
+  driver.prepareBoot = vi.fn((defer: boolean) => defer && !pid)
+  driver.writePlist = vi.fn()
+  return driver
+}
+const operations = () => native.calls.map(([, operation]) => operation)
+
+describe.skipIf(process.platform === 'win32')('desktop LaunchAgent boot', () => {
+  it('reloads a stopped but loaded job before starting it with the new deferred environment', async () => {
+    const driver = fixture()
+    await driver.boot()
+    expect(driver.prepareBoot).toHaveBeenCalledWith(true)
+    expect(operations()).toEqual(['print', 'bootout', 'bootstrap'])
+  })
+  it('keeps an already running service registered and preserves its active authorization', async () => {
+    const driver = fixture(1234)
+    await driver.boot()
+    expect(operations()).toEqual(['print', 'kickstart'])
+  })
+  it('restores the previous loaded job without clearing its recovered authorization', async () => {
+    const driver = fixture()
+    await driver.restore({ plist: { Label: 'fixture' }, wasRunning: true })
+    expect(driver.prepareBoot).toHaveBeenCalledWith(false)
+    expect(operations()).toEqual(['print', 'kickstart'])
+  })
+  it('bootstraps a new registration directly', async () => {
+    native.loaded = false
+    await fixture().boot()
+    expect(operations()).toEqual(['print', 'bootstrap'])
+  })
+  it('does not unload a service that starts while preparation is finishing', async () => {
+    const driver = fixture()
+    driver.prepareBoot = vi.fn(() => { driver.pid = () => 1234; return true })
+    await driver.boot()
+    expect(operations()).toEqual(['print', 'kickstart'])
+  })
+})

+ 32 - 0
tests/server/desktopHosts.test.ts

@@ -101,6 +101,38 @@ it('re-enables the exact disabled legacy computer after an explicit administrato
     .expect(403)
 })
 
+it.each(['disabled','deleted','rotated'] as const)('restores an installed computer with %s server credentials',async(state)=>{
+  const installId='44444444-4444-4444-8444-444444444444'
+  const enrollment={name:'书房 iMac',installId}
+  const first=(await request(app()).post('/api/app/admin/desktop-hosts').send(enrollment).expect(201)).body
+  const exchange=(id:string,credential:string)=>request(app()).post(`/api/desktop-host/v1/${id}/exchange`)
+    .set('Authorization','Bearer '+credential)
+    .set('x-desktop-host-protocol','1')
+    .send(body())
+  await exchange(first.host.id,first.token).expect(200)
+  let supersededToken:string|undefined
+  if(state==='disabled')await request(app()).delete(`/api/app/admin/desktop-hosts/${first.host.id}`).expect(200)
+  else if(state==='deleted')store.remove('_system','desktop-host',first.host.id)
+  else supersededToken=(await request(app()).post('/api/app/admin/desktop-hosts').send(enrollment).expect(201)).body.token
+  await exchange(first.host.id,first.token).expect(403)
+
+  const restored=(await request(app()).post('/api/app/admin/desktop-hosts')
+    .send({...enrollment,previousHostId:first.host.id}).expect(201)).body
+  expect(restored.host).toMatchObject({installId,enabled:true})
+  if(state==='deleted')expect(restored.host.id).not.toBe(first.host.id)
+  else expect(restored.host.id).toBe(first.host.id)
+  expect(restored.token).not.toBe(first.token)
+  await exchange(restored.host.id,restored.token).expect(200)
+  await exchange(first.host.id,first.token).expect(403)
+  await exchange(restored.host.id,first.token).expect(403)
+  if(supersededToken)await exchange(restored.host.id,supersededToken).expect(403)
+  expect(exchanged).toHaveLength(2)
+  const list=(await request(app()).get('/api/app/admin/desktop-hosts').expect(200)).body
+  expect(list.hosts).toHaveLength(1)
+  expect(list.hosts.filter((host:{enabled:boolean})=>host.enabled)).toHaveLength(1)
+  expect(list.hosts[0]).toMatchObject({id:restored.host.id,installId,enabled:true,online:true})
+})
+
 it('admits optional negotiated environment facts through the same remote transport schema',async()=>{
  await enroll()
  const input=body()

+ 48 - 0
tests/server/desktopSync.test.ts

@@ -9,6 +9,7 @@ import { assertRollbackCompatible, backupData, databaseSchema } from '../../bin/
 import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, stopDesktopService, transitionService, updateMutex, writeJSON, recoverTransition, LaunchAgentService } from '../../bin/lib/service-update.mjs'
 import { execFileSync } from 'node:child_process'
 import { RunnerHub } from '../../src/server/runnerHub'
+import { deferDesktopActivation, grantDesktopActivation, hasDesktopActivation } from '../../bin/lib/desktop-activation.mjs'
 
 const homes: string[] = []
 afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) })
@@ -22,6 +23,48 @@ function runtime(root: string, commit = 'b'.repeat(40), version = '0.3.32', ance
 }
 const clean = (commit: string, ancestors: string[] = [], version = '0.3.32') => ({ commit, ancestors, version, dirty: false })
 
+it('defers Hermes for both new and old desktop LaunchAgents while preserving other service settings', async () => {
+  const home = temporary(), root = join(home, 'runtime'), releaseRoot = join(home, 'releases')
+  runtime(root)
+  const driver = new LaunchAgentService({ home, releaseRoot, environment: {
+    HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1', HERMES_YAOYAO_DEFER_HERMES: '1',
+  } })
+  let saved: any, boots = 0
+  driver.writePlist = (plist: any) => { saved = structuredClone(plist) }
+  driver.boot = async () => { boots++ }
+  await driver.start(root)
+  expect(saved.EnvironmentVariables).toMatchObject({ HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1', HERMES_YAOYAO_DEFER_HERMES: '1' })
+  const existing = structuredClone(saved)
+  delete existing.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES
+  existing.EnvironmentVariables.CUSTOM_SETTING = 'keep'
+  await driver.start(root, { plist: existing })
+  expect(saved.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES).toBe('1')
+  expect(saved.EnvironmentVariables.HERMES_YAOYAO_SUPERVISE_DASHBOARD).toBe('1')
+  expect(saved.EnvironmentVariables.CUSTOM_SETTING).toBe('keep')
+  expect(existing.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES).toBeUndefined()
+  expect(boots).toBe(2)
+})
+
+it.skipIf(process.platform !== 'darwin')('clears a saved activation only when an inspection actually boots a stopped service', () => {
+  const home = temporary(), plistPath = join(home, 'fixture.plist')
+  const driver = new LaunchAgentService({ home, releaseRoot: join(home, 'releases'), plistPath, environment: { HERMES_YAOYAO_DEFER_HERMES: '1' } })
+  driver.writePlist({ EnvironmentVariables: { HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1', CUSTOM_SETTING: 'keep' } })
+  grantDesktopActivation(home)
+  driver.pid = () => process.pid
+  driver.prepareBoot(true)
+  expect(hasDesktopActivation(home)).toBe(true)
+  let plist = JSON.parse(execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8' }))
+  expect(plist.EnvironmentVariables.HERMES_YAOYAO_DEFER_HERMES).toBeUndefined()
+  driver.pid = () => undefined
+  driver.prepareBoot(true)
+  expect(hasDesktopActivation(home)).toBe(false)
+  plist = JSON.parse(execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', plistPath], { encoding: 'utf8' }))
+  expect(plist.EnvironmentVariables).toMatchObject({ HERMES_YAOYAO_DEFER_HERMES: '1', HERMES_YAOYAO_SUPERVISE_DASHBOARD: '1', CUSTOM_SETTING: 'keep' })
+  grantDesktopActivation(home)
+  driver.prepareBoot(false)
+  expect(hasDesktopActivation(home)).toBe(true)
+})
+
 it.skipIf(process.platform !== 'darwin')('persists official source migration while preserving custom sources and other service settings', () => {
   const home = temporary(), plistPath = join(home, 'fixture.plist')
   const driver = new LaunchAgentService({ home, port: 18899, releaseRoot: join(home, 'releases'), label: 'fixture', plistPath })
@@ -123,6 +166,10 @@ describe('transactional service switching', () => {
   it('restores both schema and data after a failed startup; preserves independent Runner state', async () => {
     const f = fixture(), schema = databaseSchema(f.home)
     mkdirSync(join(f.home, 'runner-state')); writeFileSync(join(f.home, 'runner-state', 'preserve'), 'runner')
+    grantDesktopActivation(f.home)
+    const start = f.driver.start
+    f.driver.start = async () => { deferDesktopActivation(f.home); await start() }
+    f.driver.restore = async () => { expect(hasDesktopActivation(f.home)).toBe(true) }
     f.fail()
     await expect(transitionService({ ...f, finalRoot: f.target })).rejects.toThrow('wrong running build')
     expect(currentRelease(f.releaseRoot)).toBe(f.old)
@@ -132,6 +179,7 @@ describe('transactional service switching', () => {
     expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('original')
     expect(existsSync(join(f.home, 'new-migration-file'))).toBe(false)
     expect(readFileSync(join(f.home, 'runner-state', 'preserve'), 'utf8')).toBe('runner')
+    expect(hasDesktopActivation(f.home)).toBe(true)
     expect(existsSync(join(f.home, 'updates', 'last-success.json'))).toBe(false)
   })
   it('does not stop or mark a busy service as synchronized', async () => {

+ 23 - 0
tests/server/serviceInstance.test.ts

@@ -6,6 +6,7 @@ import { join } from 'node:path'
 import Koa from 'koa'
 import request from 'supertest'
 import { acquireServiceInstance } from '../../src/server/serviceInstance'
+import { DesktopActivation } from '../../src/server/desktopActivation'
 
 let home: string
 let instances: ReturnType<typeof acquireServiceInstance>[]
@@ -14,6 +15,28 @@ afterEach(() => { for (const instance of instances) instance.release(); rmSync(h
 const acquire = (desktop = true) => { const instance = acquireServiceInstance(home, '0.3.32', desktop); instances.push(instance); return instance }
 
 describe('service ownership', () => {
+  it('inspection never activates Hermes, and activation requires the private local capability', async () => {
+    const instance = acquire(false), launch = vi.fn(), activation = new DesktopActivation(home, true, launch)
+    const app = new Koa().use(instance.middleware(async () => {}, () => true, undefined, activation))
+    const api = request(app.callback()), auth = { 'x-yaoyao-desktop-token': instance.record.token }
+    activation.start()
+    const inspection = await api.get('/desktop/service').set(auth).expect(200)
+    expect(inspection.body.activationRequired).toBe(true)
+    await api.post('/desktop/service/activate').expect(403)
+    await api.post('/desktop/service/activate').set(auth).set('Origin', 'http://localhost').expect(403)
+    await api.get('/desktop/service/activate').set(auth).expect(405)
+    expect(launch).not.toHaveBeenCalled()
+    await api.post('/desktop/service/quiesce').set(auth).expect(200)
+    await api.post('/desktop/service/activate').set(auth).expect(503)
+    await api.delete('/desktop/service/quiesce').set(auth).expect(200)
+    await api.post('/desktop/service/activate').set(auth).expect(200)
+    await api.post('/desktop/service/activate').set(auth).expect(200)
+    expect(launch).toHaveBeenCalledTimes(1)
+    const activated = await api.get('/desktop/service').set(auth).expect(200)
+    expect(activated.body.activationRequired).toBe(false)
+    instance.beginShutdown()
+    await api.post('/desktop/service/activate').set(auth).expect(503)
+  })
   it('rejects new application requests during shutdown without calling it an update',async()=>{
     const instance=acquire(false)
     const app=new Koa().use(instance.middleware(async()=>{})).use(ctx=>{ctx.body={ok:true}})

+ 31 - 1
tests/server/workspaceRoutes.test.ts

@@ -12,7 +12,7 @@ import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
 import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
 import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
-import type { WorkspaceAgent } from '../../src/shared/workspace'
+import type { WorkspaceAgent, WorkspaceConversation } from '../../src/shared/workspace'
 
 let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
 const first: LocalUser = {
@@ -123,6 +123,36 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it.each(['empty', 'existing'] as const)('serves a complete %s Bot snapshot before any Runner or virtual machine is created', async state => {
+    const store = runtime.workspace
+    expect(runtime.runners.records()).toEqual([])
+    expect(store.list('first', 'shared-computer')).toEqual([])
+    let conversation: WorkspaceConversation | undefined
+    if (state === 'existing') {
+      store.createAgent('first', { name: '尚未配置电脑的 Bot', profile: 'default' })
+      conversation = store.list<WorkspaceConversation>('first', 'conversation')[0]!
+      store.saveMessage('first', { id: randomUUID(), conversationId: conversation.id, seq: 0, role: 'assistant',
+        content: '没有虚拟机也能读取聊天记录', reasoning: '', status: 'complete', tools: [], attachments: [], createdAt: Date.now() })
+    }
+    const requestsBeforeSnapshot = [...upstream]
+    const response = await req('get', '/api/app/workspace/snapshot').expect(200).expect('Content-Type', /json/)
+    const snapshot = response.body
+    expect(Number.isSafeInteger(snapshot.cursor)).toBe(true)
+    expect(snapshot.cursor).toBeGreaterThanOrEqual(0)
+    for (const key of ['agents', 'conversations', 'details', 'projects']) expect(Array.isArray(snapshot[key])).toBe(true)
+    const client = new WorkspaceTranscriptStore()
+    expect(() => client.hydrate(snapshot)).not.toThrow()
+    expect(client.cursor).toBe(snapshot.cursor)
+    if (conversation) {
+      expect(client.conversations.map(row => row.id)).toEqual([conversation.id])
+      expect(client.get(conversation.id)?.messages.map(message => message.content)).toEqual(['没有虚拟机也能读取聊天记录'])
+    } else {
+      expect(snapshot).toMatchObject({ cursor: 0, agents: [], conversations: [], details: [] })
+    }
+    expect(runtime.runners.records()).toEqual([])
+    expect(store.list('first', 'shared-computer')).toEqual([])
+    expect(upstream).toEqual(requestsBeforeSnapshot)
+  })
   it('does not advertise retired remote Bot references and preserves their existing history', async () => {
     const store = runtime.workspace
     const agent = store.createAgent('first', { name: '历史远程 Bot', profile: 'default' })