瀏覽代碼

完善 Grok Bot 浏览器授权、自动续期与安全断开

LSamien 1 月之前
父節點
當前提交
86a945a66c

+ 23 - 0
docs/verification/2026-09-10-grok-auth/README.md

@@ -0,0 +1,23 @@
+# Grok Bot 浏览器登录授权
+
+机器人电脑侧栏现在通过浏览器登录 Cursor 账号,使用 PKCE(SHA-256 challenge + 私有 verifier)和服务端轮询取得授权。无需在 Web、桌面端或 iOS 主界面粘贴访问令牌。官方登录说明:https://prod.cursor.com/help/grok-bot/sign-in 。
+
+## 行为
+
+- 授权请求绑定当前本地账号、授权版本和一次性会话;有效期 10 分钟。关闭页面或手机进入后台后,服务端继续轮询,重启后恢复尚未过期的请求。
+- 提供继续授权、取消、手动检查、重新授权和断开连接。显示账号名称、邮箱、有效期与自动续期状态。
+- PKCE verifier、access token 和 refresh token 均仅在服务端保存,使用工作区密钥加密;回给客户端的状态不含这些凭据。
+- 访问凭据临近过期时合并并发续期,持久化轮换后的刷新令牌。临时网络/服务错误保留原有凭据并退避重试;明确撤销时要求重新登录,自动电脑模式不会静默改到其他环境。
+- 取消、权限变更、断开和重新授权后,迟到的登录或续期结果不能恢复旧连接。重新授权须匹配原 Grok Bot 账号;切换账号前先断开。
+- Grok Bot 上游拒绝与夭夭本地登录分开处理,不再把上游 401 转成客户端退出登录。协议版本更新错误也不删除已有凭据。
+- 断开会删除当前服务器上的授权与账号占用记录;存在云端任务或人工接管时要求先结束操作。不会删除或重置云端虚拟机。
+- 旧的服务器登录导入保留在兼容入口。只有 access token 的旧连接能继续使用,但需要浏览器重新授权才能自动续期。
+
+## 验证
+
+- 全量 Vitest:883 项通过,7 项按原配置跳过。其中 12 项专门覆盖 PKCE、归属、取消竞争、过期恢复、账号不匹配、轮换、临时错误和断开竞争。最后的原子提交调整又通过 20 项针对性测试。
+- 实际 Web 页面端到端测试通过:打开授权页、取消重试、完成授权、刷新后保留账号、上游拒绝不退出夭夭、375px 深色模式和断开清理。授权提供方为完全隔离的 fixture。
+- iOS 本地签名构建通过;34 项 WorkspaceChatTests 通过,授权界面端到端测试通过。
+- 真实官方登录页面 HTTP 200;未确认会话的官方轮询返回预期 HTTP 404。使用原有有效访问凭据在临时数据库验证了官方账号接口,可读取名称和邮箱,未导出凭据或修改原 Grok Bot 登录。
+
+隔离端到端测试不代表真实账号已通过新页面完成授权。实际首次使用仍需用户在官方浏览器页面完成登录与确认。截图只包含测试账号。

二進制
docs/verification/2026-09-10-grok-auth/desktop-authorized.png


二進制
docs/verification/2026-09-10-grok-auth/desktop-waiting.png


二進制
docs/verification/2026-09-10-grok-auth/phone-authorized.png


+ 7 - 0
playwright.grok-auth.config.ts

@@ -0,0 +1,7 @@
+import {defineConfig,devices} from '@playwright/test'
+export default defineConfig({
+ testDir:'./tests/e2e',testMatch:'grok-auth.spec.ts',workers:1,timeout:45000,reporter:'list',
+ use:{baseURL:'http://127.0.0.1:18842',viewport:{width:1440,height:900},trace:'retain-on-failure',screenshot:'only-on-failure'},
+ projects:[{name:'chromium',use:{...devices['Desktop Chrome']}}],
+ webServer:{command:'WORKSPACE_FIXTURE_GROK_AUTH=1 WORKSPACE_FIXTURE_PORT=18842 WORKSPACE_FIXTURE_UPSTREAM_PORT=19142 node --import tsx tests/fixtures/workspace-server.ts',url:'http://127.0.0.1:18842/healthz',reuseExistingServer:false,timeout:60000},
+})

File diff suppressed because it is too large
+ 59 - 0
src/client/components/workspace/GrokAuthPanel.vue


+ 6 - 11
src/client/components/workspace/LocalVmChatPanel.vue

@@ -2,6 +2,7 @@
 import {ref,computed,watch,onBeforeUnmount} from 'vue'
 import {apiRequest} from '@/api/client'
 import AppIcon from '@/components/common/AppIcon.vue'
+import GrokAuthPanel from './GrokAuthPanel.vue'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerFrame} from '@shared/computerControl'
 import type {LocalVmInstance,LocalVmAction} from '@shared/localVm'
@@ -9,7 +10,7 @@ const props=defineProps<{agents:WorkspaceAgent[];isAdmin:boolean;active?:boolean
 const emit=defineEmits<{close:[];changed:[];settings:[];desktop:[agent:WorkspaceAgent];workspace:[agent:WorkspaceAgent]}>()
 const selected=ref(''),state=ref<LocalVmInstance&{enabled:boolean;controlMode?:string}>(),frame=ref<ComputerFrame>(),error=ref(''),busy=ref(false),loading=ref(true)
 const agent=computed(()=>props.agents.find(a=>a.id===selected.value))
-const computerTabs=ref<HTMLElement>(),cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),token=ref(''),version=ref('0.47.0'),connectionSettings=ref(false)
+const computerTabs=ref<HTMLElement>(),cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
 const cloudSelected=computed(()=>agent.value?.computer==='cloud'||(agent.value?.computer==='auto'&&agent.value.execution!=='computer'&&cloud.value?.configured))
 const computerChoice=computed(()=>state.value?.fixedCapacity?(state.value.desktopId??'off'):agent.value?.computer??(state.value?.enabled?'vm':'off'))
 const computerChoices=computed(()=>[
@@ -58,7 +59,6 @@ function choose(value:string){
  }
  void run(()=>apiRequest(`/api/app/agents/${selected.value}/computer-selection`,{method:'PUT',body:{computer:value}}))
 }
-async function connectCloud(importLocal=false){await run(async()=>{await apiRequest('/api/app/grok-cloud/connect',{method:'POST',body:{version:version.value,...(importLocal?{importLocal:true}:{accessToken:token.value})}});token.value='';connectionSettings.value=false})}
 async function openDesktop(){if(!agent.value)return;if(cloudSelected.value){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}emit('desktop',agent.value)}
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
@@ -95,8 +95,8 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
     <template v-if="cloudSelected">
      <p>多个机器人共用同一台 Grok Bot 云端虚拟机、工作文件和浏览器登录。基础机器人和聊天记录保持独立。</p>
      <button v-if="cloud?.configured" class="primary" :disabled="busy" @click="openDesktop">{{state?.controlMode==='human'?'打开控制中的桌面':'接管云端电脑'}}</button>
-     <button v-if="isAdmin" @click="connectionSettings=!connectionSettings">{{cloud?.configured?'云端连接设置':'连接 Grok Bot'}}</button>
-     <p v-else-if="!cloud?.configured" class="hint">请由管理员为当前账号配置 Grok Bot 连接。</p>
+     <button @click="connectionSettings=!connectionSettings">{{cloud?.configured?'云端连接设置':'连接 Grok Bot'}}</button>
+     <p v-if="!isAdmin&&!cloud?.configured" class="hint">请由管理员为当前账号配置 Grok Bot 连接。</p>
     </template>
     <template v-else-if="state?.enabled">
      <p>{{agent.temporaryGoalId?'临时助手的电脑由当前任务管理':state.mode==='shared'?'共享虚拟机 · 与其他成员共用桌面和工作文件':'此机器人的独立虚拟机'}}</p>
@@ -113,13 +113,8 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
      <button v-if="isAdmin" class="settings-link" @click="emit('settings')"><AppIcon name="settings" :size="14"/>本地虚拟机设置</button>
     </template>
     </div>
-    <button v-if="!cloudSelected&&isAdmin" class="settings-link" @click="connectionSettings=!connectionSettings"><AppIcon name="globe" :size="14"/>Grok Bot 云端连接</button>
-    <div v-if="connectionSettings&&isAdmin" class="cloud-settings">
-     <label>Grok Bot 版本<input v-model="version" autocomplete="off"></label>
-     <label>登录凭据<input v-model="token" type="password" autocomplete="off" placeholder="仅保存在服务器"></label>
-     <button :disabled="busy||!token" @click="connectCloud()">连接云端账号</button>
-     <button :disabled="busy" @click="connectCloud(true)">使用服务器上的 Grok Bot 登录</button>
-    </div>
+    <button v-if="!cloudSelected" class="settings-link" @click="connectionSettings=!connectionSettings"><AppIcon name="globe" :size="14"/>Grok Bot 云端连接</button>
+    <GrokAuthPanel v-if="connectionSettings" :is-admin="isAdmin" @changed="emit('changed');refresh()"/>
    </template>
   </div>
  </aside>

+ 8 - 2
src/server/app.ts

@@ -1,3 +1,4 @@
+import type {GrokAuth} from './grokAuth.js'
 import {GrokCloud} from './grokCloud.js'
 import {WorkspaceInspector} from './workspaceInspector.js'
 import {WorkspaceRoutines} from './workspaceRoutines.js'
@@ -61,6 +62,7 @@ import { RunnerHub } from './runnerHub.js'
 export interface ApplicationOptions {
   config?: ServerConfig
   fetchImpl?: typeof fetch
+  grokFetch?:typeof fetch
   csrfSecret?: Buffer
   pairings?: NodePairingStore
   uploads?: UploadStore
@@ -99,6 +101,7 @@ export interface ApplicationRuntime {
   pushEventCoordinator: PushCoordinatorEventAdapter
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
+  grokAuth:GrokAuth
   workspaceRoutines:WorkspaceRoutines
   localVm: LocalVmService
   close(): void
@@ -225,7 +228,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
-  const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers)
+  const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
   workspaceRuntime.cloud=grokCloud
   const workspaceInspector=new WorkspaceInspector(workspace,auth)
   workspaceRuntime.inspector=workspaceInspector
@@ -402,7 +405,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
-  for(const router of [grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router()]){app.use(router.routes());app.use(router.allowedMethods())}
+  for(const router of [grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router()]){app.use(router.routes());app.use(router.allowedMethods())}
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
@@ -469,6 +472,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     workspace,
     workspaceRuntime,
     workspaceRoutines,
+    grokAuth:grokCloud.authorization,
     realtime,
     app,
     config,
@@ -491,6 +495,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     close: () => {
       runners.close()
       workspaceAssets.close()
+      grokCloud.authorization.close()
       workspaceRoutines.close()
       workspaceRuntime.close()
       workspaceNodes.close()
@@ -528,6 +533,7 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
   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 removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
   const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)

+ 186 - 0
src/server/grokAuth.ts

@@ -0,0 +1,186 @@
+import {createHash,randomBytes,randomUUID} from 'node:crypto'
+import Router from '@koa/router'
+import {z} from 'zod'
+import {HttpError} from './errors.js'
+import {parse,type WorkspaceStore} from './workspaceStore.js'
+import type {WorkspaceNodes} from './workspaceGateway.js'
+import type {LocalAuthStore} from './localAuth.js'
+import type {GrokAuthAttempt,GrokAuthSnapshot} from '../shared/grokAuth.js'
+
+const CLIENT_ID='KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB'
+export const GROK_CLIENT_VERSION='0.47.0'
+const LOGIN_TTL=10*60000,REFRESH_LEEWAY=5*60000
+const versionSchema=z.string().regex(/^\d+\.\d+\.\d+$/)
+const tokensSchema=z.object({accessToken:z.string().min(32).max(16000),refreshToken:z.string().min(1).max(16000)})
+function readTokens(value:unknown){const result=tokensSchema.safeParse(value);if(!result.success)throw new HttpError(502,'Grok Bot 授权服务返回的登录信息不完整,请重试。','grok_auth_response_invalid');return result.data}
+interface Credential {accessToken:string;refreshToken?:string;account:string;subject?:string;version:string;expiresAt?:number;email?:string;name?:string;needsLogin?:boolean}
+interface StoredAttempt extends Omit<GrokAuthAttempt,'loginUrl'> {requestId:string;uuid:string;challenge:string;sealedVerifier?:string;authVersion:number;epoch:number;expectedAccount?:string;nextPollAt:number;failures:number}
+interface AuthOptions {
+  fetcher?:typeof fetch;broker?:string;website?:string
+  beforeChange?(owner:string,nextAccount?:string):void
+  onChange?(owner:string,identityChanged:boolean):void
+}
+const digest=(v:string)=>createHash('sha256').update(v).digest('hex')
+export function grokHeaders(accessToken:string,version:string){return {authorization:`Bearer ${accessToken}`,'content-type':'application/json','connect-protocol-version':'1','x-cursor-client-type':'sand','x-cursor-client-version':version,'x-sand-box-namespace':'prod','x-ghost-mode':'true'}}
+function identity(accessToken:string){
+  try{const value=JSON.parse(Buffer.from(accessToken.split('.')[1]!,'base64url').toString());if(typeof value.sub!=='string'||!value.sub||!Number.isFinite(value.exp)||value.exp*1000<=Date.now())throw new Error();return {subject:value.sub,account:digest(value.sub),expiresAt:value.exp*1000,...(typeof value.email==='string'?{email:value.email.slice(0,254)}:{})}}
+  catch{throw new HttpError(400,'Grok Bot 返回的登录凭据无效或已过期,请重新授权。','grok_credential_invalid')}
+}
+/** Browser authorization follows Cursor's PKCE + authenticated polling flow.
+ * The verifier, access token and refresh token are never sent to our clients. */
+export class GrokAuth {
+  private readonly fetcher:typeof fetch
+  private readonly broker:string
+  private readonly website:string
+  private readonly polling=new Map<string,Promise<void>>()
+  private readonly refreshing=new Map<string,Promise<Credential>>()
+  private readonly requests=new Set<AbortController>()
+  private readonly retryAfter=new Map<string,number>()
+  private timer?:ReturnType<typeof setInterval>
+  private closed=false
+  constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly options:AuthOptions={}){
+    this.fetcher=options.fetcher??fetch;this.broker=options.broker??'https://api2.cursor.sh';this.website=options.website??'https://cursor.com'
+  }
+  configured(owner:string){return !!this.store.get(owner,'grok-cloud','connection')}
+  private read(owner:string):{sealed:string;value:Credential}|undefined {const row=this.store.get<{sealed:string}>(owner,'grok-cloud','connection');return row?{sealed:row.sealed,value:this.nodes.open<Credential>(row.sealed)}:undefined}
+  private epoch(owner:string){return this.store.get<{value:number}>(owner,'grok-auth-epoch','current')?.value??0}
+  private advance(owner:string){const value=this.epoch(owner)+1;this.store.put(owner,'grok-auth-epoch','current',{value});return value}
+  private assertOwner(owner:string,version:number,epoch:number,admin=false){if(this.closed||!this.auth.isUserActive(owner)||(admin&&!this.auth.isAdminActive(owner))||this.auth.pushAuthorizationVersion(owner)!==version||this.epoch(owner)!==epoch)throw new HttpError(409,'这次 Grok Bot 授权已取消或账号权限已变化。','grok_auth_superseded')}
+  private activeAttempt(owner:string,id?:string){const attempt=this.store.get<StoredAttempt>(owner,'grok-auth-attempt','current');if(!attempt||(id&&attempt.id!==id))throw new HttpError(404,'授权请求不存在或已被替换。','grok_auth_not_found');return attempt}
+  private loginUrl(a:StoredAttempt){const url=new URL('/loginDeepControl',this.website);url.search=new URLSearchParams({challenge:a.challenge,uuid:a.uuid,mode:'login',redirectTarget:'cli'}).toString();return url.href}
+  private publicAttempt(a:StoredAttempt):GrokAuthAttempt {return {id:a.id,status:a.status,expiresAt:a.expiresAt,...(a.status==='pending'?{loginUrl:this.loginUrl(a)}:{}),...(a.error?{error:a.error}:{})}}
+  private expire(owner:string,attempt:StoredAttempt){if(attempt.status==='pending'&&attempt.expiresAt<=Date.now()){attempt.status='expired';attempt.error='授权链接已过期,请重新登录。';delete attempt.sealedVerifier;this.store.put(owner,'grok-auth-attempt','current',attempt)}}
+  snapshot(owner:string):GrokAuthSnapshot {
+    const credential=this.read(owner)?.value,attempt=this.store.get<StoredAttempt>(owner,'grok-auth-attempt','current')
+    if(attempt)this.expire(owner,attempt)
+    let expiresAt=credential?.expiresAt
+    if(!expiresAt&&credential?.accessToken)try{expiresAt=identity(credential.accessToken).expiresAt}catch{}
+    const expired=!!credential&&(credential.needsLogin||!credential.accessToken||((expiresAt??0)<=Date.now()&&!credential.refreshToken))
+    const error=this.store.get<{message:string}>(owner,'grok-auth-error','current')?.message
+    return {configured:!!credential,status:!credential?'disconnected':expired?'reauthorization-required':this.refreshing.has(owner)?'refreshing':'connected',automaticRefresh:!!credential?.refreshToken&&!credential.needsLogin,version:credential?.version??GROK_CLIENT_VERSION,...(credential?{account:{email:credential.email,name:credential.name},expiresAt}:{}),...(error?{error}:{}),...(attempt?{attempt:this.publicAttempt(attempt)}:{})}
+  }
+  private async request(url:string,init:RequestInit={}){
+    const controller=new AbortController();this.requests.add(controller)
+    try{return await this.fetcher(url,{...init,cache:'no-store',redirect:'error',signal:AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])})}
+    catch{throw new HttpError(503,'暂时无法连接 Grok Bot 授权服务,请稍后重试。','grok_auth_network')}
+    finally{this.requests.delete(controller)}
+  }
+  private async body(response:Response){try{return await response.json() as Record<string,any>}catch{throw new HttpError(502,'Grok Bot 授权服务返回了无效数据。','grok_auth_response_invalid')}}
+  private async verify(accessToken:string,version:string){
+    const response=await this.request(this.broker+'/aiserver.v1.GrokBotService/GetSandBoxRunState',{method:'POST',headers:grokHeaders(accessToken,version),body:'{}'})
+    if(response.ok){await this.body(response);return}
+    if(!response.ok){const data=await this.body(response).catch(()=>({} as Record<string,any>));if(response.headers.get('x-automation-failure-hint')==='SAND_CLIENT_UPDATE_REQUIRED'||/version.*(?:supported|update)/i.test(String(data.message)))throw new HttpError(409,'Grok Bot 客户端协议版本需要更新,已有登录仍保留。','grok_client_update_required');throw new HttpError(response.status>=500?503:409,response.status>=500?'Grok Bot 服务暂不可用,请稍后重试。':'Grok Bot 未接受这次授权,请确认登录账号具有访问权限。','grok_authorization_failed')}
+  }
+  private async profile(accessToken:string,version:string){
+    try{const response=await this.request(this.broker+'/aiserver.v1.DashboardService/GetMe',{method:'POST',headers:grokHeaders(accessToken,version),body:'{}'});if(!response.ok)return {};const me=await this.body(response);return {...(typeof me.email==='string'?{email:me.email.slice(0,254)}:{}),name:[me.firstName,me.lastName].filter(v=>typeof v==='string').join(' ').slice(0,100)||undefined}}
+    catch{return {}}
+  }
+  private commit(owner:string,credential:Credential){
+    this.store.atomic(()=>{
+      const old=this.read(owner)?.value,claim=this.store.get<{owner:string}>('_system','grok-account',credential.account)
+      if(claim&&claim.owner!==owner)throw new HttpError(409,'该 Grok Bot 账号已连接其他用户,不能共享登录授权。','grok_account_in_use')
+      this.options.beforeChange?.(owner,credential.account)
+      if(old&&old.account!==credential.account&&this.store.get<{owner:string}>('_system','grok-account',old.account)?.owner===owner)this.store.remove('_system','grok-account',old.account)
+      this.store.put(owner,'grok-cloud','connection',{sealed:this.nodes.seal(credential)});this.store.put('_system','grok-account',credential.account,{owner})
+      this.store.remove(owner,'grok-auth-error','current');this.retryAfter.delete(owner);this.options.onChange?.(owner,old?.account!==credential.account)
+    })
+  }
+  async configure(owner:string,accessToken:string,version=GROK_CLIENT_VERSION,refreshToken?:string){
+    version=parse(versionSchema,version)
+    const who=identity(accessToken),epoch=this.epoch(owner),authVersion=this.auth.pushAuthorizationVersion(owner)??0
+    this.options.beforeChange?.(owner,who.account)
+    await this.verify(accessToken,version);const profile=await this.profile(accessToken,version)
+    this.store.atomic(()=>{this.assertOwner(owner,authVersion,epoch);this.commit(owner,{accessToken,refreshToken,version,...who,...profile});this.advance(owner);this.store.remove(owner,'grok-auth-attempt','current')})
+    return this.snapshot(owner)
+  }
+  begin(owner:string,requestId:string){
+    const current=this.store.get<StoredAttempt>(owner,'grok-auth-attempt','current')
+    if(current?.requestId===requestId){this.expire(owner,current);return this.snapshot(owner)}
+    if(current?.status==='pending'&&current.expiresAt>Date.now())throw new HttpError(409,'已有进行中的授权,请继续完成或先取消。','grok_auth_pending')
+    const verifier=randomBytes(32).toString('base64url'),uuid=randomUUID(),epoch=this.advance(owner)
+    const attempt:StoredAttempt={id:randomUUID(),requestId,uuid,challenge:createHash('sha256').update(verifier).digest('base64url'),sealedVerifier:this.nodes.seal({verifier}),status:'pending',expiresAt:Date.now()+LOGIN_TTL,authVersion:this.auth.pushAuthorizationVersion(owner)??0,epoch,expectedAccount:this.read(owner)?.value.account,nextPollAt:Date.now(),failures:0}
+    this.assertOwner(owner,attempt.authVersion,epoch,true)
+    this.store.put(owner,'grok-auth-attempt','current',attempt)
+    return this.snapshot(owner)
+  }
+  cancel(owner:string,id:string){const attempt=this.activeAttempt(owner,id);if(attempt.status==='pending'){this.advance(owner);attempt.status='cancelled';attempt.error=undefined;delete attempt.sealedVerifier;this.store.put(owner,'grok-auth-attempt','current',attempt)}return this.snapshot(owner)}
+  async poll(owner:string,id:string){
+    const a=this.activeAttempt(owner,id);this.expire(owner,a)
+    if(a.status!=='pending')return this.snapshot(owner)
+    const existing=this.polling.get(owner);if(existing){await existing;return this.snapshot(owner)}
+    const work=this.pollOnce(owner,a).finally(()=>{if(this.polling.get(owner)===work)this.polling.delete(owner)})
+    this.polling.set(owner,work);await work;return this.snapshot(owner)
+  }
+  private async pollOnce(owner:string,attempt:StoredAttempt){
+    const current=()=>{const a=this.activeAttempt(owner,attempt.id);this.expire(owner,a);this.assertOwner(owner,a.authVersion,a.epoch,true);if(a.status!=='pending')throw new HttpError(409,'授权请求已结束。','grok_auth_superseded');return a}
+    try{
+      current()
+      const {verifier}=this.nodes.open<{verifier:string}>(attempt.sealedVerifier!)
+      const url=new URL('/auth/poll',this.broker);url.search=new URLSearchParams({uuid:attempt.uuid,verifier}).toString()
+      const response=await this.request(url.href,{headers:{'content-type':'application/json'}})
+      const active=current()
+      if(response.status===404||response.status===202){active.failures=0;active.error=undefined;active.nextPollAt=Date.now()+2000;this.store.put(owner,'grok-auth-attempt','current',active);return}
+      if(!response.ok){if(response.status===429||response.status>=500)throw new HttpError(503,'授权服务暂时繁忙,正在重试。','grok_auth_network');throw new HttpError(409,'授权被拒绝或登录已失效,请重新登录。','grok_authorization_failed')}
+      const raw=await this.body(response),parsed=readTokens({accessToken:raw.accessToken??raw.access_token,refreshToken:raw.refreshToken??raw.refresh_token}),who=identity(parsed.accessToken)
+      if(active.expectedAccount&&active.expectedAccount!==who.account)throw new HttpError(409,'请使用当前已连接的 Grok Bot 账号重新授权;切换账号前请先断开连接。','grok_account_mismatch')
+      const version=this.read(owner)?.value.version??GROK_CLIENT_VERSION
+      await this.verify(parsed.accessToken,version);const profile=await this.profile(parsed.accessToken,version)
+      this.store.atomic(()=>{const final=current();this.commit(owner,{...parsed,version,...who,...profile});final.status='complete';final.error=undefined;delete final.sealedVerifier;this.store.put(owner,'grok-auth-attempt','current',final)})
+    }catch(error){
+      if(this.closed)return
+      const latest=this.store.get<StoredAttempt>(owner,'grok-auth-attempt','current')
+      if(!latest||latest.id!==attempt.id||latest.status!=='pending'||latest.epoch!==this.epoch(owner))return
+      latest.failures++;latest.error=error instanceof HttpError?error.message:'授权未能完成,请重试。'
+      if(error instanceof HttpError&&error.code==='grok_auth_network'&&latest.expiresAt>Date.now()){latest.nextPollAt=Date.now()+Math.min(30000,2000*2**Math.min(latest.failures,4))}
+      else{latest.status='failed';delete latest.sealedVerifier}
+      this.store.put(owner,'grok-auth-attempt','current',latest)
+    }
+  }
+  async credentials(owner:string,forceRefresh=false):Promise<Credential>{
+    if(!this.auth.isUserActive(owner))throw new HttpError(403,'当前账号不可用。','grok_auth_forbidden')
+    const stored=this.read(owner)
+    if(!stored||stored.value.needsLogin||!stored.value.accessToken)throw new HttpError(409,'请登录 Grok Bot 完成授权。','grok_login_required')
+    const value=stored.value,expiresAt=value.expiresAt??(()=>{try{return identity(value.accessToken).expiresAt}catch{return 0}})()
+    if(!forceRefresh&&expiresAt>Date.now()+REFRESH_LEEWAY)return value
+    if(!value.refreshToken){if(expiresAt>Date.now()&&!forceRefresh)return value;this.invalidate(owner,stored.sealed,'当前连接缺少自动续期授权,请重新登录 Grok Bot。');throw new HttpError(409,'Grok Bot 登录已过期,请重新授权。','grok_login_required')}
+    const pending=this.refreshing.get(owner);if(pending)return pending
+    if(!forceRefresh&&(this.retryAfter.get(owner)??0)>Date.now()){if(expiresAt>Date.now())return value;throw new HttpError(503,'Grok Bot 自动续期暂不可用,正在等待重试。','grok_auth_network')}
+    const work=this.refresh(owner,stored).finally(()=>{if(this.refreshing.get(owner)===work)this.refreshing.delete(owner)})
+    this.refreshing.set(owner,work);return work
+  }
+  private invalidate(owner:string,sealed:string,message:string){const current=this.read(owner);if(current?.sealed!==sealed)return;this.store.put(owner,'grok-cloud','connection',{sealed:this.nodes.seal({...current.value,accessToken:'',refreshToken:undefined,needsLogin:true})});this.store.put(owner,'grok-auth-error','current',{message});this.options.onChange?.(owner,true)}
+  private async refresh(owner:string,stored:{sealed:string;value:Credential}){
+    const authVersion=this.auth.pushAuthorizationVersion(owner)??0
+    const assertCurrent=()=>{if(this.closed||!this.auth.isUserActive(owner)||this.auth.pushAuthorizationVersion(owner)!==authVersion||this.read(owner)?.sealed!==stored.sealed)throw new HttpError(409,'Grok Bot 连接已更新,旧续期结果已丢弃。','grok_auth_superseded')}
+    try{
+      assertCurrent()
+      const response=await this.request(this.broker+'/oauth/token',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({client_id:CLIENT_ID,grant_type:'refresh_token',refresh_token:stored.value.refreshToken})})
+      assertCurrent();const body=await this.body(response)
+      if(response.status===429||response.status>=500)throw new HttpError(503,'Grok Bot 自动续期暂不可用,已有连接保留。','grok_auth_network')
+      if(!response.ok||body.shouldLogout===true||body.error==='invalid_grant'){
+        this.invalidate(owner,stored.sealed,'Grok Bot 已撤销登录授权,请重新登录。');throw new HttpError(409,'Grok Bot 登录授权已失效,请重新登录。','grok_login_required')
+      }
+      const parsed=readTokens({accessToken:body.access_token,refreshToken:body.refresh_token??stored.value.refreshToken}),who=identity(parsed.accessToken)
+      if(who.account!==stored.value.account){this.invalidate(owner,stored.sealed,'续期账号与原账号不一致,请重新授权。');throw new HttpError(409,'Grok Bot 续期账号不匹配。','grok_account_mismatch')}
+      assertCurrent();const value={...stored.value,...parsed,...who};this.store.put(owner,'grok-cloud','connection',{sealed:this.nodes.seal(value)});this.store.remove(owner,'grok-auth-error','current');this.retryAfter.delete(owner);return value
+    }catch(error){
+      if(error instanceof HttpError&&['grok_auth_network','grok_auth_response_invalid'].includes(error.code??'')&&this.read(owner)?.sealed===stored.sealed){this.retryAfter.set(owner,Date.now()+15000);this.store.put(owner,'grok-auth-error','current',{message:error.message});try{if(identity(stored.value.accessToken).expiresAt>Date.now())return stored.value}catch{}}
+      throw error
+    }
+  }
+  disconnect(owner:string){
+    this.options.beforeChange?.(owner,undefined)
+    this.store.atomic(()=>{this.advance(owner);const old=this.read(owner)?.value;if(old&&this.store.get<{owner:string}>('_system','grok-account',old.account)?.owner===owner)this.store.remove('_system','grok-account',old.account);this.store.remove(owner,'grok-cloud','connection');this.store.remove(owner,'grok-auth-error','current');this.store.remove(owner,'grok-auth-attempt','current');this.retryAfter.delete(owner);this.options.onChange?.(owner,true)})
+    return this.snapshot(owner)
+  }
+  start(){if(this.timer)return;const tick=()=>{if(this.closed)return;for(const owner of this.store.owners()){const a=this.store.get<StoredAttempt>(owner,'grok-auth-attempt','current');if(a?.status==='pending'&&a.nextPollAt<=Date.now())void this.poll(owner,a.id).catch(()=>{})}};tick();this.timer=setInterval(tick,1000);this.timer.unref()}
+  close(){this.closed=true;clearInterval(this.timer);for(const request of this.requests)request.abort()}
+  router(){const router=new Router()
+    router.get('/api/app/grok-cloud/auth',ctx=>{ctx.set('Cache-Control','no-store');ctx.body=this.snapshot(this.auth.require(ctx).id)})
+    router.post('/api/app/grok-cloud/auth/start',ctx=>{const owner=this.auth.requireAdmin(ctx).id,input=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body);ctx.body=this.begin(owner,input.requestId)})
+    router.post('/api/app/grok-cloud/auth/:id/poll',async ctx=>{const owner=this.auth.requireAdmin(ctx).id;ctx.body=await this.poll(owner,parse(z.string().uuid(),ctx.params.id))})
+    router.post('/api/app/grok-cloud/auth/:id/cancel',ctx=>{const owner=this.auth.requireAdmin(ctx).id;ctx.body=this.cancel(owner,parse(z.string().uuid(),ctx.params.id))})
+    router.delete('/api/app/grok-cloud/auth',ctx=>{ctx.body=this.disconnect(this.auth.require(ctx).id)})
+    return router
+  }
+}

+ 41 - 21
src/server/grokCloud.ts

@@ -1,3 +1,4 @@
+import {GrokAuth,grokHeaders} from './grokAuth.js'
 import Router from '@koa/router'
 import {createHash,randomBytes,randomUUID,timingSafeEqual} from 'node:crypto'
 import {readFile} from 'node:fs/promises'
@@ -32,6 +33,8 @@ export const GROK_COMPUTER_RULES='当前电脑为 Grok Bot 云端共享虚拟机
  * account addresses the same existing cloud computer; robots keep their own
  * Hermes sessions and permissions. No cloud provisioning happens in GETs. */
 export class GrokCloud {
+  readonly authorization:GrokAuth
+  private connectionEpoch=new Map<string,number>()
   private descriptors=new Map<string,{value:Descriptor;expiresAt:number}>()
   private connecting=new Map<string,Promise<Descriptor>>()
   private queue=new Map<string,Promise<unknown>>()
@@ -44,7 +47,18 @@ export class GrokCloud {
   private executing=new Map<string,number>()
   private stateCache=new Map<string,{at:number;value:{running:boolean}}>()
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly shared:SharedComputers,
-    readonly fetcher:typeof fetch=fetch,readonly broker='https://api2.cursor.sh'){}
+    readonly fetcher:typeof fetch=fetch,readonly broker='https://api2.cursor.sh'){
+    this.authorization=new GrokAuth(store,auth,nodes,{fetcher,broker,
+      beforeChange:(owner,nextAccount)=>{const old=this.configured(owner)?this.credential(owner).account:undefined;if(old===nextAccount&&nextAccount)return
+        const active=new Set(this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>this.selected(owner,a)).map(a=>a.id))
+        if(this.current(owner)||this.queue.has(owner)||(this.activity.get(owner)??0)>0||this.store.list<{agentId:string;status:string}>(owner,'turn').some(t=>active.has(t.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(t.status)))throw new HttpError(409,'请先停止机器人的云端任务并交还控制权,再切换账号或断开连接。','computer_busy')
+      },
+      onChange:(owner,identityChanged)=>{this.stateCache.delete(owner);if(!identityChanged)return
+        this.connectionEpoch.set(owner,(this.connectionEpoch.get(owner)??0)+1);this.descriptors.delete(owner);this.connecting.delete(owner);this.frames.delete(owner)
+        const manual=this.manual.get(owner);if(!manual&&(!this.configured(owner)||this.credential(owner).accessToken)){this.paused.delete(owner);this.handoff.delete(owner)}if(manual){this.store.remove('_system','computer-control',manual.id);this.manual.delete(owner);this.paused.add(owner)}
+      },
+    })
+  }
   configured(owner:string){return !!this.store.get(owner,'grok-cloud','connection')}
   selected(owner:string,agent:WorkspaceAgent){return agent.computer==='cloud'||(agent.computer==='auto'&&agent.execution!=='computer'&&this.configured(owner))}
   private credential(owner:string):Credential {const row=this.store.get<{sealed:string}>(owner,'grok-cloud','connection');if(!row)throw new HttpError(409,'请先连接 Grok Bot 云端账号','grok_cloud_not_configured');return this.nodes.open<Credential>(row.sealed)}
@@ -53,25 +67,24 @@ export class GrokCloud {
   private async response(response:Response){
     const text=await response.text()
     let value:any;try{value=JSON.parse(text)}catch{throw new HttpError(502,'Grok Bot 返回了无效响应','grok_response_invalid')}
-    if(!response.ok)throw new HttpError(response.status===401?401:502,response.status===401?'Grok Bot 登录已失效或客户端版本不再受支持,请重新连接。':`Grok Bot 请求失败(${response.status})`,'grok_request_failed')
+    if(!response.ok){
+      if(response.headers.get('x-automation-failure-hint')==='SAND_CLIENT_UPDATE_REQUIRED'||/version.*(?:supported|update)/i.test(String(value.message)))throw new HttpError(409,'Grok Bot 客户端协议需要更新,已有登录仍保留。','grok_client_update_required')
+      throw new HttpError(response.status===401||response.status===403?409:502,response.status===401?'Grok Bot 未接受当前授权,请重新登录或检查账号权限。':response.status===403?'该 Grok Bot 账号没有当前云端资源的访问权限。':`Grok Bot 请求失败(${response.status})`,'grok_request_failed')
+    }
     return value
   }
   private async rpc(owner:string,method:string,body:unknown={}){
-    const credential=this.credential(owner)
-    return this.response(await this.fetcher(`${this.broker}/aiserver.v1.GrokBotService/${method}`,{method:'POST',redirect:'error',headers:{authorization:`Bearer ${credential.accessToken}`,'content-type':'application/json','connect-protocol-version':'1','x-cursor-client-type':'sand','x-cursor-client-version':credential.version,'x-sand-box-namespace':'prod','x-ghost-mode':'true'},body:JSON.stringify(body),signal:AbortSignal.timeout(30000)}))
-  }
-  async configure(owner:string,accessToken:string,version:string){
-    if(this.current(owner)||this.queue.has(owner)||(this.activity.get(owner)??0)>0)throw new HttpError(409,'请先结束云端电脑操作,再更新连接','computer_busy')
-    let account:string;try{const value=JSON.parse(Buffer.from(accessToken.split('.')[1]!,'base64url').toString());if(!value.sub||value.exp*1000<=Date.now())throw new Error();account=hash(value.sub)}catch{throw new HttpError(400,'请使用有效的 Grok Bot 登录凭据','grok_credential_invalid')}
-    const claim=this.store.get<{owner:string}>('_system','grok-account',account)
-    if(claim&&claim.owner!==owner)throw new HttpError(409,'该云端账号已连接其他用户','grok_account_in_use')
-    const old=this.store.get<{sealed:string}>(owner,'grok-cloud','connection')
-    this.store.put(owner,'grok-cloud','connection',{sealed:this.nodes.seal({accessToken,account,version})})
-    try{await this.rpc(owner,'GetSandBoxRunState')}
-    catch(error){if(old)this.store.put(owner,'grok-cloud','connection',old);else this.store.remove(owner,'grok-cloud','connection');throw error}
-    this.store.put('_system','grok-account',account,{owner});this.descriptors.delete(owner);this.stateCache.delete(owner)
-    return {configured:true,version}
+    let credential=await this.authorization.credentials(owner)
+    const request=()=>this.fetcher(`${this.broker}/aiserver.v1.GrokBotService/${method}`,{method:'POST',redirect:'error',headers:grokHeaders(credential.accessToken,credential.version),body:JSON.stringify(body),signal:AbortSignal.timeout(30000)})
+    let response=await request()
+    if(response.status===401){
+      const value=await response.clone().json().catch(()=>({})) as any
+      const update=response.headers.get('x-automation-failure-hint')==='SAND_CLIENT_UPDATE_REQUIRED'||/version.*(?:supported|update)/i.test(String(value.message))
+      if(!update){const current=await this.authorization.credentials(owner);credential=await this.authorization.credentials(owner,current.accessToken===credential.accessToken);response=await request()}
+    }
+    return this.response(response)
   }
+  async configure(owner:string,accessToken:string,version:string,refreshToken?:string){return this.authorization.configure(owner,accessToken,version,refreshToken)}
   async state(owner:string){
     if(!this.configured(owner))return {configured:false,running:false}
     const cached=this.stateCache.get(owner);if(cached&&Date.now()-cached.at<5000)return {configured:true,...cached.value}
@@ -79,19 +92,24 @@ export class GrokCloud {
     this.stateCache.set(owner,{at:Date.now(),value:state});return {configured:true,...state}
   }
   async connect(owner:string){
+    await this.authorization.credentials(owner)
+    const epoch=this.connectionEpoch.get(owner)??0
     const cached=this.descriptors.get(owner);if(cached&&cached.expiresAt>Date.now())return cached.value
     const pending=this.connecting.get(owner);if(pending)return pending
     const work=(async()=>{
       const value=await this.rpc(owner,'EnsureSandBox') as Descriptor
       for(const key of ['gatewayUrl','execDaemonUrl'] as const){const url=new URL(value[key]);if(url.protocol!=='https:'||!url.hostname.endsWith('.cursorvm.com')||url.username||url.password)throw new HttpError(502,'云端电脑地址无效','grok_endpoint_invalid')}
       if(!value.gatewayToken||!value.networkToken||!value.execDaemonAuthToken)throw new HttpError(502,'云端电脑连接信息不完整','grok_endpoint_invalid')
+      if((this.connectionEpoch.get(owner)??0)!==epoch||!this.configured(owner))throw new HttpError(409,'Grok Bot 连接已改变,旧连接已丢弃。','grok_auth_superseded')
       this.descriptors.set(owner,{value,expiresAt:Date.now()+5*60000});return value
-    })().finally(()=>this.connecting.delete(owner))
+    })().finally(()=>{if(this.connecting.get(owner)===work)this.connecting.delete(owner)})
     this.connecting.set(owner,work);return work
   }
   private descriptor(owner:string){const value=this.descriptors.get(owner);if(!value||value.expiresAt<=Date.now())throw new HttpError(409,'请打开云端电脑以建立连接','grok_connection_required');return value.value}
   private headers(d:Descriptor){return {authorization:`Bearer ${d.execDaemonAuthToken}`,'x-anyrun-network-token':d.networkToken,'connect-protocol-version':'1'}}
   async execute(owner:string,command:string,args:string[],signal?:AbortSignal){
+    await this.authorization.credentials(owner)
+    const epoch=this.connectionEpoch.get(owner)??0
     const d=this.descriptor(owner),json=Buffer.from(JSON.stringify({command,args,cwd:'/workspace'})),body=Buffer.alloc(5+json.length);body.writeUInt32BE(json.length,1);json.copy(body,5)
     const response=await this.fetcher(`${d.execDaemonUrl.replace(/\/$/,'')}/agent.v1.ControlService/Exec`,{method:'POST',redirect:'error',headers:{...this.headers(d),'content-type':'application/connect+json'},body,signal:signal?AbortSignal.any([signal,AbortSignal.timeout(60000)]):AbortSignal.timeout(60000)})
     if(!response.ok)throw new HttpError(502,`云端命令未被接受(${response.status})`,'grok_exec_failed')
@@ -104,9 +122,11 @@ export class GrokCloud {
       }
     }
     if(buffer.length||exitCode===undefined)throw new HttpError(502,'云端命令的完成状态不确定,未自动重试','grok_exec_uncertain')
+    if((this.connectionEpoch.get(owner)??0)!==epoch)throw new HttpError(409,'执行期间云端授权已变化,请核对原执行结果,未自动重试。','grok_exec_uncertain')
     return {stdout,stderr,exitCode}
   }
   async capture(owner:string):Promise<ComputerFrame>{
+    await this.authorization.credentials(owner)
     const cached=this.frames.get(owner),generation=this.generations.get(owner)??0
     if(cached&&Date.now()-cached.capturedAt<900&&cached.generation===generation)return cached
     const script="import os,io,base64,json; os.environ['DISPLAY']=':1'; from PIL import ImageGrab; im=ImageGrab.grab(xdisplay=':1'); b=io.BytesIO(); im.save(b,format='PNG'); print(json.dumps({'data':base64.b64encode(b.getvalue()).decode(),'width':im.width,'height':im.height}))"
@@ -123,7 +143,7 @@ export class GrokCloud {
     while(this.current(owner)||this.paused.has(owner)){signal?.throwIfAborted();this.authorize(owner,agentId);await new Promise(resolve=>setTimeout(resolve,200))}
     signal?.throwIfAborted();this.authorize(owner,agentId)
   }
-  async requireAvailable(owner:string,agent:WorkspaceAgent,target:GatewayTarget){this.credential(owner);await requireTeamToolBridge(target,agent.profile)}
+  async requireAvailable(owner:string,agent:WorkspaceAgent,target:GatewayTarget){await this.authorization.credentials(owner);await requireTeamToolBridge(target,agent.profile)}
   async call(owner:string,agentId:string,id:string,input:unknown,signal?:AbortSignal){
     this.activity.set(owner,(this.activity.get(owner)??0)+1)
     try{const result=await this.serial(owner,async()=>{
@@ -155,9 +175,9 @@ export class GrokCloud {
   private grant(owner:string,agentId:string,body:unknown){const value=parse(z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough(),body),manual=this.current(owner);if(!manual||manual.id!==value.controlId||manual.agentId!==agentId||!timingSafeEqual(manual.tokenHash,Buffer.from(hash(value.token),'hex')))throw new HttpError(410,'云端控制权已失效','computer_control_expired');this.authorize(owner,agentId);return manual}
   router(){const router=new Router()
     router.get('/api/app/grok-cloud',async ctx=>{const owner=this.auth.require(ctx).id;ctx.body={...await this.state(owner),version:this.configured(owner)?this.credential(owner).version:'0.47.0'}})
-    router.post('/api/app/grok-cloud/connect',async ctx=>{const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({accessToken:z.string().min(32).max(16000).optional(),version:z.string().regex(/^\d+\.\d+\.\d+$/).default('0.47.0'),importLocal:z.boolean().optional()}).strict(),(ctx.request as any).body);let token=body.accessToken
-      if(body.importLocal){try{token=JSON.parse(await readFile(join(homedir(),'.grokbot','local-docker-credential','inference.json'),'utf8')).accessToken}catch{throw new HttpError(409,'没有找到本机 Grok Bot 登录,请粘贴登录凭据','grok_login_missing')}}
-      if(!token)throw new HttpError(400,'请提供 Grok Bot 登录凭据','grok_login_missing');ctx.body=await this.configure(owner,token,body.version)
+    router.post('/api/app/grok-cloud/connect',async ctx=>{const owner=this.auth.requireAdmin(ctx).id,body=parse(z.object({accessToken:z.string().min(32).max(16000).optional(),refreshToken:z.string().min(1).max(16000).optional(),version:z.string().regex(/^\d+\.\d+\.\d+$/).default('0.47.0'),importLocal:z.boolean().optional()}).strict(),(ctx.request as any).body);let token=body.accessToken,refreshToken=body.refreshToken
+      if(body.importLocal){try{const legacy=JSON.parse(await readFile(join(homedir(),'.grokbot','local-docker-credential','inference.json'),'utf8'));token=legacy.accessToken;refreshToken=legacy.refreshToken}catch{throw new HttpError(409,'未找到可导入的服务器登录,请使用浏览器授权。','grok_login_missing')}}
+      if(!token)throw new HttpError(400,'请先通过浏览器完成 Grok Bot 授权。','grok_login_missing');ctx.body=await this.configure(owner,token,body.version,refreshToken)
     })
     router.put('/api/app/agents/:id/computer-selection',async ctx=>{
       const owner=this.auth.require(ctx).id,agent=this.agent(owner,ctx.params.id),body=parse(z.object({computer:z.enum(['auto','cloud','vm','off'])}).strict(),(ctx.request as any).body)

+ 17 - 0
src/shared/grokAuth.ts

@@ -0,0 +1,17 @@
+export interface GrokAuthAttempt {
+  id:string
+  status:'pending'|'complete'|'cancelled'|'expired'|'failed'
+  loginUrl?:string
+  expiresAt:number
+  error?:string
+}
+export interface GrokAuthSnapshot {
+  configured:boolean
+  status:'disconnected'|'connected'|'refreshing'|'reauthorization-required'
+  account?:{email?:string;name?:string}
+  expiresAt?:number
+  automaticRefresh:boolean
+  version:string
+  error?:string
+  attempt?:GrokAuthAttempt
+}

+ 34 - 0
tests/e2e/grok-auth.spec.ts

@@ -0,0 +1,34 @@
+import {test,expect} from '@playwright/test'
+import {mkdirSync} from 'node:fs'
+test('browser authorization completes, survives reload and never signs Yaoyao out on Grok rejection',async({page,context})=>{
+ const evidence='docs/verification/2026-09-10-grok-auth';mkdirSync(evidence,{recursive:true})
+ await context.route('https://cursor.com/loginDeepControl**',route=>route.fulfill({contentType:'text/html; charset=utf-8',body:'<!doctype html><html lang="zh"><meta charset="utf-8"><title>Isolated authorization fixture</title><body><h1>Grok Bot 授权测试</h1><p>此页面仅用于隔离测试,不连接真实账号。</p><button onclick="this.textContent=\'已确认\'">完成本次授权</button></body></html>'}))
+ await page.goto('/conversations');await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture');await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass');await page.getByRole('button',{name:'登录',exact:true}).click()
+ await page.locator('.desktop-sidebar .sidebar-create-trigger').click();await page.getByRole('menuitem',{name:'新建 Bot',exact:true}).click()
+ const editor=page.getByRole('dialog');await editor.getByRole('textbox',{name:'名称',exact:true}).fill('云端授权验收');await editor.getByRole('button',{name:'保存',exact:true}).click()
+ await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await page.getByRole('button',{name:'Grok Bot 云端连接',exact:true}).click()
+ const auth=page.getByRole('region',{name:'Grok Bot 登录授权'})
+ await expect(auth.getByText('尚未登录',{exact:true})).toBeVisible()
+ expect((await page.request.post('/api/app/grok-cloud/auth/start',{data:{requestId:crypto.randomUUID()}})).status()).toBe(403)
+ const popupPromise=context.waitForEvent('page');await auth.getByRole('button',{name:'使用 Cursor 账号登录',exact:true}).click();const popup=await popupPromise
+ await expect(popup.getByRole('button',{name:'完成本次授权',exact:true})).toBeVisible()
+ await expect(auth.getByText('等待浏览器授权',{exact:true})).toBeVisible();await auth.scrollIntoViewIfNeeded();await page.screenshot({path:evidence+'/desktop-waiting.png'})
+ const pending=await(await page.request.get('/api/app/grok-cloud/auth')).json();expect(pending.attempt.status).toBe('pending');expect(JSON.stringify(pending)).not.toContain('verifier')
+ await auth.getByRole('button',{name:'取消授权',exact:true}).click();await popup.close();await expect(auth.getByText('尚未登录',{exact:true})).toBeVisible()
+ const nextPopup=context.waitForEvent('page');await auth.getByRole('button',{name:'使用 Cursor 账号登录',exact:true}).click();const authorize=await nextPopup
+ await authorize.getByRole('button',{name:'完成本次授权',exact:true}).click()
+ const bootstrap=await(await page.request.get('/api/app/bootstrap')).json(),headers={'X-CSRF-Token':bootstrap.csrfToken,Origin:'http://127.0.0.1:18842'}
+ expect((await page.request.post('/__test/grok-auth/complete',{headers,data:{}})).ok()).toBe(true)
+ await authorize.close();await page.bringToFront();await expect(auth.getByText('已授权',{exact:true})).toBeVisible({timeout:15000});await expect(auth.getByText('grok-fixture@example.test',{exact:true})).toBeVisible()
+ await auth.scrollIntoViewIfNeeded();await page.screenshot({path:evidence+'/desktop-authorized.png'})
+ const connected=await(await page.request.get('/api/app/grok-cloud/auth')).json();expect(connected.automaticRefresh).toBe(true);expect(JSON.stringify(connected)).not.toMatch(/accessToken|refreshToken|verifier/)
+ await page.reload();await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click();await page.getByRole('button',{name:'云端连接设置',exact:true}).click();await expect(auth.getByText('grok-fixture@example.test',{exact:true})).toBeVisible()
+ const nextBootstrap=await(await page.request.get('/api/app/bootstrap')).json();headers['X-CSRF-Token']=nextBootstrap.csrfToken
+ await page.request.post('/__test/grok-auth/reject',{headers,data:{}})
+ await expect.poll(async()=>(await page.request.get('/api/app/grok-cloud')).status(),{timeout:12000}).toBe(409)
+ expect((await(await page.request.get('/api/app/bootstrap')).json()).authenticated).toBe(true)
+ await page.setViewportSize({width:375,height:812});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'})
+ await auth.scrollIntoViewIfNeeded();await page.screenshot({path:evidence+'/phone-authorized.png'})
+ await auth.getByRole('button',{name:'断开连接',exact:true}).click();await expect(auth.getByText('尚未登录',{exact:true})).toBeVisible()
+ expect((await(await page.request.get('/api/app/grok-cloud/auth')).json()).configured).toBe(false)
+})

+ 17 - 0
tests/fixtures/grok-auth-provider.ts

@@ -0,0 +1,17 @@
+/** Isolated OAuth provider for browser acceptance. Never delegates to network. */
+export class FixtureGrokAuthProvider {
+  private accepted=new Set<string>()
+  rejectCloud=false
+  pollCount=0
+  refreshCount=0
+  accept(uuid:string){this.accepted.add(uuid)}
+  private token(){return 'fixture.'+Buffer.from(JSON.stringify({sub:'grok-auth-fixture',email:'grok-fixture@example.test',exp:Math.floor(Date.now()/1000)+3600})).toString('base64url')+'.signature'}
+  fetch:typeof fetch=async(input,init={})=>{
+    const url=new URL(String(input))
+    if(url.pathname==='/auth/poll'){this.pollCount++;return this.accepted.has(url.searchParams.get('uuid')??'')?Response.json({accessToken:this.token(),refreshToken:'grok-fixture-refresh'}):new Response('',{status:404})}
+    if(url.pathname==='/oauth/token'){this.refreshCount++;return Response.json({access_token:this.token(),refresh_token:'grok-fixture-refresh-rotated'})}
+    if(url.pathname.endsWith('/GetSandBoxRunState'))return this.rejectCloud?Response.json({code:'unauthenticated'},{status:401}):Response.json({state:'SAND_BOX_RUN_STATE_RUNNING'})
+    if(url.pathname.endsWith('/GetMe'))return Response.json({email:'grok-fixture@example.test',firstName:'Grok',lastName:'Fixture'})
+    throw new Error('Unexpected isolated Grok auth request: '+url.pathname)
+  }
+}

+ 14 - 1
tests/fixtures/workspace-server.ts

@@ -1,3 +1,4 @@
+import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
 /** Isolated, deterministic Hermes fixture. Optional team-tool readiness is only for editor UI tests. */
 import { createServer } from 'node:http'
 import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
@@ -334,7 +335,8 @@ if (!existsSync(usersPath)) {
   )
 }
 const auth = new LocalAuthStore(home, false)
-const runtime = createApplication({ config, auth }),
+const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuthProvider():undefined
+const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch }),
   node = createNodeServer(runtime)
 runtime.app.use((ctx,next) => {
   if (ctx.path !== '/__test/task-plan' || ctx.method !== 'POST') return next()
@@ -354,6 +356,17 @@ runtime.app.use((ctx,next) => {
   runtime.workspace.saveMessage(owner,{id:randomUUID(),conversationId:source.id,seq:0,role:'system',content:`任务已有结果。[打开任务](/conversations/${team.id}?taskId=${first.id})`,reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()})
   ctx.body={conversationId:team.id,firstTaskId:first.id,secondTaskId:second.id,sourceConversationId:source.id}
 })
+runtime.app.use((ctx,next)=>{
+ if(!grokAuth||!ctx.path.startsWith('/__test/grok-auth/')||ctx.method!=='POST')return next()
+ const owner=auth.requireAdmin(ctx).id
+ if(ctx.path==='/__test/grok-auth/complete'){
+  const attempt=runtime.grokAuth.snapshot(owner).attempt
+  if(!attempt?.loginUrl){ctx.status=409;ctx.body={error:'No pending fixture authorization'};return}
+  grokAuth.accept(new URL(attempt.loginUrl).searchParams.get('uuid')!);ctx.body={ok:true};return
+ }
+ if(ctx.path==='/__test/grok-auth/reject'){grokAuth.rejectCloud=true;ctx.body={ok:true};return}
+ return next()
+})
 const computerFixtures=new Map<string,{mode:string;generation:number;controlId?:string;actions:unknown[]}>()
 const originalComputer=runtime.runners.computer.bind(runtime.runners),originalRunner=runtime.runners.computerRunner.bind(runtime.runners),originalSharedRunner=runtime.runners.sharedComputerRunner.bind(runtime.runners)
 runtime.runners.sharedComputerRunner=(owner,agent)=>computerFixtures.has(agent.id)?{id:'11111111-1111-4111-8111-111111111111'} as any:originalSharedRunner(owner,agent)

+ 144 - 0
tests/server/grokAuth.test.ts

@@ -0,0 +1,144 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {createHash,randomUUID} from 'node:crypto'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {loadServerConfig} from '../../src/server/config'
+import {GrokAuth} from '../../src/server/grokAuth'
+import {HttpError} from '../../src/server/errors'
+
+let home:string,store:WorkspaceStore,nodes:WorkspaceNodes,service:GrokAuth,version:number,active:boolean
+let exchanges:Map<string,{accessToken:string;refreshToken:string}>,calls:Array<{url:URL;init:RequestInit}>
+let oauth:(init:RequestInit)=>Promise<Response>,pollResponse:((url:URL)=>Promise<Response>)|undefined,verifyStatus:number
+const token=(subject='account-a',seconds=3600)=>'header.'+Buffer.from(JSON.stringify({sub:subject,exp:Math.floor(Date.now()/1000)+seconds,email:subject+'@example.test'})).toString('base64url')+'.signature'
+const auth={
+ require:(ctx:any)=>({id:ctx.get('x-user')||'owner',role:ctx.get('x-user')==='member'?'user':'admin'}),
+ requireAdmin:(ctx:any)=>{if(ctx.get('x-user')==='member')throw new HttpError(403,'仅限管理员','admin_required');return {id:ctx.get('x-user')||'owner',role:'admin'}},
+ isUserActive:()=>active,isAdminActive:(owner:string)=>active&&owner!=='member',pushAuthorizationVersion:()=>version,
+} as any
+let fetcher:typeof fetch
+beforeEach(()=>{
+ home=mkdtempSync(join(tmpdir(),'grok-auth-test-'));store=new WorkspaceStore(home);nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
+ version=1;active=true;exchanges=new Map();calls=[];pollResponse=undefined;verifyStatus=200
+ oauth=async()=>Response.json({access_token:token(),refresh_token:'rotated-refresh-token'})
+ fetcher=(async(input,init={})=>{const url=new URL(String(input));calls.push({url,init})
+  if(url.pathname==='/auth/poll'){if(pollResponse)return pollResponse(url);const tokens=exchanges.get(url.searchParams.get('uuid')!);return tokens?Response.json(tokens):new Response('',{status:404})}
+  if(url.pathname==='/oauth/token')return oauth(init)
+  if(url.pathname.endsWith('/GetSandBoxRunState'))return Response.json({state:'SAND_BOX_RUN_STATE_RUNNING'},{status:verifyStatus})
+  if(url.pathname.endsWith('/GetMe'))return Response.json({email:'authorized@example.test',firstName:'Test',lastName:'Account'})
+  throw new Error('Unexpected auth request')
+ }) as typeof fetch
+ service=new GrokAuth(store,auth,nodes,{fetcher})
+})
+afterEach(()=>{service.close();nodes.close();store.close();rmSync(home,{recursive:true,force:true});vi.useRealTimers()})
+function begin(){return service.begin('owner',randomUUID()).attempt!}
+function accept(a:ReturnType<typeof begin>,subject='account-a',seconds=3600){exchanges.set(new URL(a.loginUrl!).searchParams.get('uuid')!,{accessToken:token(subject,seconds),refreshToken:'private-refresh-token'})}
+function stored(){return nodes.open<any>(store.require<any>('owner','grok-cloud','connection').sealed)}
+function app(){const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(error:any){ctx.status=error.status??500;ctx.body={error:error.message,code:error.code}}});a.use(bodyParser());a.use(service.router().routes());return a.callback()}
+
+it('creates owner-bound PKCE challenges without sending the verifier or credentials to clients',async()=>{
+ const a=begin(),url=new URL(a.loginUrl!),internal=store.require<any>('owner','grok-auth-attempt','current'),verifier=nodes.open<any>(internal.sealedVerifier).verifier
+ expect(url.origin).toBe('https://cursor.com');expect(url.pathname).toBe('/loginDeepControl');expect(url.searchParams.get('redirectTarget')).toBe('cli')
+ expect(url.searchParams.get('challenge')).toBe(createHash('sha256').update(verifier).digest('base64url'))
+ expect(JSON.stringify(a)).not.toContain(verifier);expect(JSON.stringify(internal)).not.toContain(verifier)
+ await service.poll('owner',a.id);expect(service.snapshot('owner').attempt?.status).toBe('pending')
+ accept(a);const result=await service.poll('owner',a.id)
+ expect(result).toMatchObject({status:'connected',automaticRefresh:true,account:{email:'authorized@example.test',name:'Test Account'},attempt:{status:'complete'}})
+ expect(JSON.stringify(result)).not.toContain('private-refresh-token');expect(JSON.stringify(result)).not.toContain('accessToken')
+ expect(JSON.stringify(store.list('owner','grok-cloud'))).not.toContain('private-refresh-token')
+ expect(stored().refreshToken).toBe('private-refresh-token')
+ expect(store.require<any>('owner','grok-auth-attempt','current').sealedVerifier).toBeUndefined()
+})
+
+it('makes start retries idempotent and refuses a competing pending attempt',()=>{
+ const id=randomUUID(),a=service.begin('owner',id)
+ expect(service.begin('owner',id).attempt?.id).toBe(a.attempt?.id)
+ expect(()=>service.begin('owner',randomUUID())).toThrow('已有进行中')
+ expect(calls).toEqual([])
+})
+
+it('does not let a late browser result restore a cancelled or replaced login',async()=>{
+ const a=begin();let deliver!:(r:Response)=>void
+ pollResponse=()=>new Promise(resolve=>{deliver=resolve})
+ const polling=service.poll('owner',a.id);await Promise.resolve()
+ service.cancel('owner',a.id);const next=begin()
+ deliver(Response.json({accessToken:token(),refreshToken:'late-refresh'}));await polling
+ expect(service.configured('owner')).toBe(false);expect(service.snapshot('owner').attempt?.id).toBe(next.id)
+})
+
+it('expires links, removes verifiers and recovers an unexpired authorization after server restart',async()=>{
+ vi.useFakeTimers();const a=begin();service.close();service=new GrokAuth(store,auth,nodes,{fetcher});accept(a)
+ expect((await service.poll('owner',a.id)).status).toBe('connected')
+ const b=begin();vi.advanceTimersByTime(10*60000+1)
+ expect(service.snapshot('owner').attempt?.status).toBe('expired');expect(store.require<any>('owner','grok-auth-attempt','current').sealedVerifier).toBeUndefined()
+ expect(begin().id).not.toBe(b.id)
+})
+
+it('refuses other owners and non-admin account linking',async()=>{
+ const a=begin()
+ await expect(service.poll('other',a.id)).rejects.toThrow('不存在')
+ expect(()=>service.cancel('other',a.id)).toThrow('不存在')
+ expect((await request(app()).post('/api/app/grok-cloud/auth/start').set('x-user','member').send({requestId:randomUUID()})).status).toBe(403)
+ expect((await request(app()).get('/api/app/grok-cloud/auth').set('x-user','other')).body.configured).toBe(false)
+})
+
+it('cannot finish an authorization after local account permissions have changed',async()=>{
+ const a=begin();accept(a);version++
+ expect((await service.poll('owner',a.id)).attempt?.status).toBe('failed')
+ expect(service.configured('owner')).toBe(false)
+})
+
+it('coalesces token refreshes and persists rotation without exposing tokens',async()=>{
+ const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
+ const results=await Promise.all([service.credentials('owner'),service.credentials('owner'),service.credentials('owner')])
+ expect(calls.filter(c=>c.url.pathname==='/oauth/token')).toHaveLength(1)
+ expect(new Set(results.map(r=>r.accessToken)).size).toBe(1)
+ expect(stored().refreshToken).toBe('rotated-refresh-token')
+ const payload=JSON.parse(String(calls.find(c=>c.url.pathname==='/oauth/token')!.init.body))
+ expect(payload).toMatchObject({client_id:'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB',grant_type:'refresh_token',refresh_token:'private-refresh-token'})
+ expect(JSON.stringify(service.snapshot('owner'))).not.toContain('refreshToken')
+})
+
+it('keeps usable credentials on transient refresh failure and backs off retries',async()=>{
+ const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
+ oauth=async()=>Response.json({error:'unavailable'},{status:503});const original=stored().accessToken
+ expect((await service.credentials('owner')).accessToken).toBe(original)
+ expect((await service.credentials('owner')).accessToken).toBe(original)
+ expect(calls.filter(c=>c.url.pathname==='/oauth/token')).toHaveLength(1)
+ expect(service.snapshot('owner')).toMatchObject({configured:true,status:'connected',automaticRefresh:true})
+})
+
+it('marks revoked authorization as requiring login without turning cloud auto selection off',async()=>{
+ const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
+ oauth=async()=>Response.json({shouldLogout:true,error:'invalid_grant'})
+ await expect(service.credentials('owner')).rejects.toMatchObject({status:409,code:'grok_login_required'})
+ expect(service.snapshot('owner')).toMatchObject({configured:true,status:'reauthorization-required',automaticRefresh:false})
+ expect(stored().accessToken).toBe('');expect(stored().refreshToken).toBeUndefined()
+})
+
+it('never restores a connection from a refresh response arriving after disconnect',async()=>{
+ const a=begin();accept(a,'account-a',60);await service.poll('owner',a.id)
+ let deliver!:(r:Response)=>void;oauth=()=>new Promise(resolve=>{deliver=resolve})
+ const refreshing=service.credentials('owner');const rejection=expect(refreshing).rejects.toMatchObject({code:'grok_auth_superseded'});await Promise.resolve()
+ service.disconnect('owner');deliver(Response.json({access_token:token(),refresh_token:'late-refresh'}));await rejection
+ expect(service.snapshot('owner').status).toBe('disconnected');expect(store.list('_system','grok-account')).toEqual([])
+})
+
+it('refuses a different account during reauthorization and preserves the original connection',async()=>{
+ const first=begin();accept(first);await service.poll('owner',first.id);const old=stored().account
+ const a=begin();accept(a,'different-account');const result=await service.poll('owner',a.id)
+ expect(result.attempt?.status).toBe('failed');expect(result.attempt?.error).toContain('当前已连接')
+ expect(stored().account).toBe(old)
+})
+
+it('validates new credentials before replacing an existing connection',async()=>{
+ await service.configure('owner',token(),'0.47.0','refresh-a');const old=store.require<any>('owner','grok-cloud','connection').sealed
+ verifyStatus=403;await expect(service.configure('owner',token('second'),'0.47.0','refresh-b')).rejects.toThrow('未接受')
+ expect(store.require<any>('owner','grok-cloud','connection').sealed).toBe(old)
+})

Some files were not shown because too many files changed in this diff