|
@@ -1,3 +1,4 @@
|
|
|
|
|
+import {GrokAuth,grokHeaders} from './grokAuth.js'
|
|
|
import Router from '@koa/router'
|
|
import Router from '@koa/router'
|
|
|
import {createHash,randomBytes,randomUUID,timingSafeEqual} from 'node:crypto'
|
|
import {createHash,randomBytes,randomUUID,timingSafeEqual} from 'node:crypto'
|
|
|
import {readFile} from 'node:fs/promises'
|
|
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
|
|
* account addresses the same existing cloud computer; robots keep their own
|
|
|
* Hermes sessions and permissions. No cloud provisioning happens in GETs. */
|
|
* Hermes sessions and permissions. No cloud provisioning happens in GETs. */
|
|
|
export class GrokCloud {
|
|
export class GrokCloud {
|
|
|
|
|
+ readonly authorization:GrokAuth
|
|
|
|
|
+ private connectionEpoch=new Map<string,number>()
|
|
|
private descriptors=new Map<string,{value:Descriptor;expiresAt:number}>()
|
|
private descriptors=new Map<string,{value:Descriptor;expiresAt:number}>()
|
|
|
private connecting=new Map<string,Promise<Descriptor>>()
|
|
private connecting=new Map<string,Promise<Descriptor>>()
|
|
|
private queue=new Map<string,Promise<unknown>>()
|
|
private queue=new Map<string,Promise<unknown>>()
|
|
@@ -44,7 +47,18 @@ export class GrokCloud {
|
|
|
private executing=new Map<string,number>()
|
|
private executing=new Map<string,number>()
|
|
|
private stateCache=new Map<string,{at:number;value:{running:boolean}}>()
|
|
private stateCache=new Map<string,{at:number;value:{running:boolean}}>()
|
|
|
constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly shared:SharedComputers,
|
|
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')}
|
|
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))}
|
|
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)}
|
|
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){
|
|
private async response(response:Response){
|
|
|
const text=await response.text()
|
|
const text=await response.text()
|
|
|
let value:any;try{value=JSON.parse(text)}catch{throw new HttpError(502,'Grok Bot 返回了无效响应','grok_response_invalid')}
|
|
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
|
|
return value
|
|
|
}
|
|
}
|
|
|
private async rpc(owner:string,method:string,body:unknown={}){
|
|
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){
|
|
async state(owner:string){
|
|
|
if(!this.configured(owner))return {configured:false,running:false}
|
|
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}
|
|
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}
|
|
this.stateCache.set(owner,{at:Date.now(),value:state});return {configured:true,...state}
|
|
|
}
|
|
}
|
|
|
async connect(owner:string){
|
|
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 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 pending=this.connecting.get(owner);if(pending)return pending
|
|
|
const work=(async()=>{
|
|
const work=(async()=>{
|
|
|
const value=await this.rpc(owner,'EnsureSandBox') as Descriptor
|
|
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')}
|
|
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(!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
|
|
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
|
|
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 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'}}
|
|
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){
|
|
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 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)})
|
|
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')
|
|
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(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}
|
|
return {stdout,stderr,exitCode}
|
|
|
}
|
|
}
|
|
|
async capture(owner:string):Promise<ComputerFrame>{
|
|
async capture(owner:string):Promise<ComputerFrame>{
|
|
|
|
|
+ await this.authorization.credentials(owner)
|
|
|
const cached=this.frames.get(owner),generation=this.generations.get(owner)??0
|
|
const cached=this.frames.get(owner),generation=this.generations.get(owner)??0
|
|
|
if(cached&&Date.now()-cached.capturedAt<900&&cached.generation===generation)return cached
|
|
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}))"
|
|
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))}
|
|
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)
|
|
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){
|
|
async call(owner:string,agentId:string,id:string,input:unknown,signal?:AbortSignal){
|
|
|
this.activity.set(owner,(this.activity.get(owner)??0)+1)
|
|
this.activity.set(owner,(this.activity.get(owner)??0)+1)
|
|
|
try{const result=await this.serial(owner,async()=>{
|
|
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}
|
|
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(){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.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=>{
|
|
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)
|
|
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)
|