// 每个 Run 一个短期令牌:引擎调用网关时携带,网关据此确定会话、工作区与访问策略;Run 结束即作废。 // Codex 的会话在 app-server 中保持加载,MCP 连接沿用第一次对话时的请求头(thread/resume 不会更新), // 所以 Codex 每个会话使用固定的令牌字符串:每次 Run 开始时重新绑定到当前 Run,结束后失效,下次 Run 再绑定 import crypto from 'node:crypto' import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared' import type { ResolvedAccess } from '../services/access-policy' import type { InteractionOutcome, NewApproval } from '../services/interactions' export interface GatewayRunContext { sessionId: string runId: string provider: 'claude' | 'codex' workspaceDir: string access: ResolvedAccess permissionMode: PermissionMode settings: AppSettings knowledgeRoots: KnowledgeRootDto[] /** Profile 勾选的第三方 MCP 服务 */ mcpServerIds: string[] audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied' | 'approval', summary: string, detail?: unknown) => void /** 发起审批(与引擎侧的审批相同:显示交互卡片并等待回复) */ interact: (req: NewApproval) => Promise } const MAX_AGE_MS = 24 * 3600 * 1000 export class RunTokenRegistry { private tokens = new Map() /** 会话 → 固定令牌(只用于 Codex) */ private sessionTokens = new Map() issue(ctx: GatewayRunContext, opts: { stablePerSession?: boolean } = {}): string { let token = opts.stablePerSession ? this.sessionTokens.get(ctx.sessionId) : undefined if (!token) { token = `rt_${crypto.randomBytes(32).toString('base64url')}` if (opts.stablePerSession) this.sessionTokens.set(ctx.sessionId, token) } this.tokens.set(token, { ctx, issuedAt: Date.now() }) return token } get(token: string | undefined): GatewayRunContext | null { if (!token) return null const e = this.tokens.get(token) if (!e) return null if (Date.now() - e.issuedAt > MAX_AGE_MS) { this.tokens.delete(token) return null } return e.ctx } /** Run 结束:令牌失效(固定令牌的字符串保留,下次 Run 重新绑定) */ revoke(token: string) { this.tokens.delete(token) } /** 会话删除时调用 */ forgetSession(sessionId: string) { const token = this.sessionTokens.get(sessionId) if (token) this.tokens.delete(token) this.sessionTokens.delete(sessionId) } }