feat(auth): document authentication flow and add session tracking

**Changes:**
1. Added comprehensive authentication flow documentation to AGENTS.md
   - Documented 8-step auth process: login → token exchange → persistence → refresh → renewal
   - Included key files reference for token management and deep link handling
   - Explained 30-day rolling refresh token mechanism

2. Implemented session tracking in OpenAI compatibility proxy
   - Added `setCoworkProxySessionId()` to track active cowork sessions
   - Integrated session ID tracking in OpenClawRuntimeAdapter lifecycle
   - Extract and log last user message text for better observability
   - Added regex patterns to strip Claude SDK time context and metadata prefixes

3. Enhanced proxy request logging
   - Log session ID and user message preview with each upstream request
   - Improved debugging capability for multi-session scenarios

**Why:**
- Documentation helps developers understand the complete auth lifecycle
- Session tracking enables better request attribution and debugging
- Message extraction provides context for proxy operations without exposing full payloads
This commit is contained in:
liliangcai
2026-03-24 03:20:30 +08:00
parent 8acd2e63c2
commit 7a00b2ecf6
4 changed files with 74 additions and 0 deletions
+16
View File
@@ -44,6 +44,22 @@ LobsterAI is an Electron + React desktop application with two primary modes:
Uses strict process isolation with IPC communication.
### Authentication Flow
1. **登录:** 打开系统浏览器 → Portal 登录页 → URS 登录成功 → deep link `lobsterai://auth/callback?code=<authCode>`
2. **换取令牌:** `POST /api/auth/exchange` 消费一次性 authCode → 返回 `accessToken`(2h) + `refreshToken`(30d)
3. **持久化:** SQLite kv store `auth_tokens` 存储双 token,应用重启后自动恢复登录态
4. **请求认证:** `fetchWithAuth()` 在每个 API 请求附加 `Authorization: Bearer <accessToken>`
5. **被动刷新:** 收到 HTTP 401 → 使用 refreshToken 调用 `POST /api/auth/refresh` → 获取新 accessToken → 重试原请求
6. **主动刷新:** 定期检查 accessToken 距 exp < 5 分钟 → 后台静默刷新,避免请求失败
7. **滚动续期:** 每次 refresh 签发新 refreshToken(新 30 天有效期),连续使用不掉线
8. **退出条件:** 连续 30 天不使用(refreshToken 过期)→ 清除本地 token → 用户需重新登录
**关键文件:**
- Token 存储与请求:`src/renderer/services/api.ts`(`fetchWithAuth()`、token 管理)
- 登录流程:`src/main/main.ts`(deep link 处理 `lobsterai://` 协议)
- 持久化:`src/main/sqliteStore.ts`(kv 表存储 `auth_tokens`)
### Process Model
**Main Process** (`src/main/main.ts`):
@@ -27,6 +27,7 @@ import {
extractGatewayMessageText,
} from '../openclawHistory';
import { buildOpenClawLocalTimeContextPrompt } from '../openclawLocalTimeContextPrompt';
import { setCoworkProxySessionId } from '../coworkOpenAICompatProxy';
import { OPENCLAW_AGENT_TIMEOUT_SECONDS } from '../openclawConfigSync';
import { t } from '../../i18n';
@@ -964,6 +965,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
this.rememberSessionKey(sessionId, sessionKey);
this.store.updateSession(sessionId, { status: 'running' });
setCoworkProxySessionId(sessionId);
await this.ensureGatewayClientReady();
this.startChannelPolling();
@@ -3287,6 +3289,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
});
}
this.activeTurns.delete(sessionId);
setCoworkProxySessionId(null);
// NOTE: Do NOT clear lastSystemPromptBySession here — it must persist
// across turns so that the system prompt is only injected on the first
// turn of a session (or when it actually changes). Cleanup happens in
+51
View File
@@ -75,9 +75,50 @@ let proxyServer: http.Server | null = null;
let proxyPort: number | null = null;
let upstreamConfig: OpenAICompatUpstreamConfig | null = null;
let lastProxyError: string | null = null;
let currentCoworkSessionId: string | null = null;
const toolCallExtraContentById = new Map<string, unknown>();
export function setCoworkProxySessionId(sessionId: string | null): void {
currentCoworkSessionId = sessionId;
}
const MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 1024;
// Regex to strip Claude SDK-injected time context prefix from user messages
const TIME_CONTEXT_PREFIX_RE = /^\[.*?\]\s*##\s*Local Time Context[\s\S]*?(?=\n\S|$)/;
// Regex to strip "Sender (untrusted metadata):" JSON block prefix
const METADATA_PREFIX_RE = /^Sender \(untrusted metadata\):\s*```json\s*\{[^}]*}\s*```\s*/s;
function extractLastUserMessageText(body: unknown): string | null {
const obj = toOptionalObject(body);
if (!obj) return null;
const messages = obj.messages;
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = toOptionalObject(messages[i]);
if (!msg || msg.role !== 'user') continue;
let text: string | null = null;
if (typeof msg.content === 'string') {
text = msg.content;
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
const b = toOptionalObject(block);
if (b && b.type === 'text' && typeof b.text === 'string') {
text = b.text;
break;
}
}
}
if (text) {
text = text.replace(METADATA_PREFIX_RE, '').replace(TIME_CONTEXT_PREFIX_RE, '').trim();
if (text.length > 100) text = text.substring(0, 100);
return text || null;
}
}
return null;
}
function toOptionalObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
@@ -2196,6 +2237,16 @@ async function handleRequest(
const upstreamAPIType = resolveUpstreamAPIType(upstreamConfig.provider);
const openAIRequest = anthropicToOpenAI(parsedRequestBody);
// Inject session_id and user_message for server-side logging
if (currentCoworkSessionId) {
openAIRequest.session_id = currentCoworkSessionId;
}
const extractedUserMessage = extractLastUserMessageText(parsedRequestBody);
if (extractedUserMessage) {
openAIRequest.user_message = extractedUserMessage;
}
if (!openAIRequest.model) {
openAIRequest.model = upstreamConfig.model;
}
+4
View File
@@ -13,6 +13,7 @@ import { coworkLog, getCoworkLogPath } from './coworkLogger';
import { ensurePythonPipReady, ensurePythonRuntimeReady } from './pythonRuntime';
import { isQuestionLikeMemoryText, type CoworkMemoryGuardLevel } from './coworkMemoryExtractor';
import { SCHEDULED_TASK_SWITCH_MESSAGE } from './scheduledTaskEnginePrompt';
import { setCoworkProxySessionId } from './coworkOpenAICompatProxy';
import { z } from 'zod';
const ATTACHMENT_LINE_RE = /^\s*(?:[-*]\s*)?(输入文件|input\s*file)\s*[::]\s*(.+?)\s*$/i;
@@ -1499,6 +1500,7 @@ export class CoworkRunner extends EventEmitter {
effectivePrompt = this.injectLocalHistoryPrompt(sessionId, prompt, effectivePrompt);
}
setCoworkProxySessionId(sessionId);
await this.runClaudeCode(activeSession, effectivePrompt, sessionCwd, effectiveSystemPrompt, options.imageAttachments);
} catch (error) {
console.error('Cowork session error:', error);
@@ -1584,6 +1586,7 @@ export class CoworkRunner extends EventEmitter {
try {
const promptPrefix = this.buildPromptPrefix();
const effectivePrompt = promptPrefix ? `${promptPrefix}\n\n---\n\n${prompt}` : prompt;
setCoworkProxySessionId(sessionId);
await this.runClaudeCode(activeSession, effectivePrompt, sessionCwd, effectiveSystemPrompt, options.imageAttachments);
} catch (error) {
console.error('Cowork continue error:', error);
@@ -1600,6 +1603,7 @@ export class CoworkRunner extends EventEmitter {
}
this.clearPendingPermissions(sessionId);
this.store.updateSession(sessionId, { status: 'idle' });
setCoworkProxySessionId(null);
}
onSessionDeleted(sessionId: string): void {