mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
Keep desktop replies stable across turns and fix OAuth callback routing
The desktop chat store was dropping the previous assistant draft when a new user turn began because streaming text was cleared before it was flushed into message history. At the same time, the desktop OAuth flow was using an unregistered /api/haha-oauth/callback redirect URI, which caused provider authorization failures. This change flushes visible assistant drafts before starting a new turn, restores the OAuth redirect to the registered localhost callback, and adds a root callback handler while keeping the legacy API callback path compatible. Constraint: The OAuth provider only accepts the registered localhost /callback redirect URI Rejected: Keep the desktop-specific /api/haha-oauth/callback path | provider rejects unsupported redirect URIs Rejected: Rely on message_complete to persist visible assistant text | next user turn can begin before that event arrives Confidence: high Scope-risk: moderate Directive: Any UI-visible assistant draft must be flushed into messages before a new user turn resets streaming state Tested: bun test src/server/__tests__/haha-oauth-service.test.ts src/server/__tests__/haha-oauth-api.test.ts src/server/__tests__/conversation-service.test.ts Tested: cd desktop && bun run test src/stores/chatStore.test.ts Tested: cd desktop && bun run lint Not-tested: Manual end-to-end desktop OAuth login after reinstall
This commit is contained in:
parent
d83efc1b04
commit
9d29d8e32c
@ -287,6 +287,44 @@ describe('chatStore history mapping', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('flushes the previous assistant draft before starting a new user turn', () => {
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {
|
||||||
|
[TEST_SESSION_ID]: {
|
||||||
|
messages: [],
|
||||||
|
chatState: 'streaming',
|
||||||
|
connectionState: 'connected',
|
||||||
|
streamingText: '上一次分析结果 **还在流式区域**',
|
||||||
|
streamingToolInput: '',
|
||||||
|
activeToolUseId: null,
|
||||||
|
activeToolName: null,
|
||||||
|
activeThinkingId: null,
|
||||||
|
pendingPermission: null,
|
||||||
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
elapsedSeconds: 0,
|
||||||
|
statusVerb: '',
|
||||||
|
slashCommands: [],
|
||||||
|
agentTaskNotifications: {},
|
||||||
|
elapsedTimer: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useChatStore.getState().sendMessage(TEST_SESSION_ID, '你是什么模型?')
|
||||||
|
|
||||||
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: '上一次分析结果 **还在流式区域**',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'user_text',
|
||||||
|
content: '你是什么模型?',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
it('routes member-session messages through team mailbox delivery instead of websocket', async () => {
|
it('routes member-session messages through team mailbox delivery instead of websocket', async () => {
|
||||||
const memberSessionId = 'team-member:security-reviewer@test-team'
|
const memberSessionId = 'team-member:security-reviewer@test-team'
|
||||||
getMemberBySessionIdMock.mockReturnValue({
|
getMemberBySessionIdMock.mockReturnValue({
|
||||||
|
|||||||
@ -180,8 +180,25 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
|
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const session = s.sessions[sessionId] ?? createDefaultSessionState()
|
const session = s.sessions[sessionId] ?? createDefaultSessionState()
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer)
|
||||||
|
flushTimer = null
|
||||||
|
}
|
||||||
|
const bufferedDelta = pendingDelta
|
||||||
|
pendingDelta = ''
|
||||||
|
const pendingAssistantText = `${session.streamingText}${bufferedDelta}`.trim()
|
||||||
|
|
||||||
const newMessages = [...session.messages]
|
const newMessages = pendingAssistantText
|
||||||
|
? [
|
||||||
|
...session.messages,
|
||||||
|
{
|
||||||
|
id: nextId(),
|
||||||
|
type: 'assistant_text' as const,
|
||||||
|
content: pendingAssistantText,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [...session.messages]
|
||||||
if (!isMemberSession && allTasksDone) {
|
if (!isMemberSession && allTasksDone) {
|
||||||
newMessages.push({
|
newMessages.push({
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
|
|||||||
@ -55,7 +55,7 @@ describe('POST /api/haha-oauth/start', () => {
|
|||||||
const data = (await res.json()) as { authorizeUrl: string; state: string }
|
const data = (await res.json()) as { authorizeUrl: string; state: string }
|
||||||
expect(data.authorizeUrl).toContain('code_challenge_method=S256')
|
expect(data.authorizeUrl).toContain('code_challenge_method=S256')
|
||||||
expect(data.authorizeUrl).toContain(
|
expect(data.authorizeUrl).toContain(
|
||||||
encodeURIComponent('http://localhost:54321/api/haha-oauth/callback'),
|
encodeURIComponent('http://localhost:54321/callback'),
|
||||||
)
|
)
|
||||||
expect(data.state).toMatch(/^[A-Za-z0-9_-]+$/)
|
expect(data.state).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -82,7 +82,7 @@ describe('HahaOAuthService — session management', () => {
|
|||||||
expect(session.authorizeUrl).toContain(`state=${encodeURIComponent(session.state)}`)
|
expect(session.authorizeUrl).toContain(`state=${encodeURIComponent(session.state)}`)
|
||||||
expect(session.authorizeUrl).toContain('redirect_uri=')
|
expect(session.authorizeUrl).toContain('redirect_uri=')
|
||||||
expect(session.authorizeUrl).toContain(encodeURIComponent(
|
expect(session.authorizeUrl).toContain(encodeURIComponent(
|
||||||
'http://localhost:54321/api/haha-oauth/callback',
|
'http://localhost:54321/callback',
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
* Haha OAuth REST API
|
* Haha OAuth REST API
|
||||||
*
|
*
|
||||||
* POST /api/haha-oauth/start — 生成 PKCE+state,返回 authorize URL
|
* POST /api/haha-oauth/start — 生成 PKCE+state,返回 authorize URL
|
||||||
* GET /api/haha-oauth/callback — 用户浏览器 redirect 到此,完成 token 交换
|
* GET /callback — 用户浏览器 redirect 到此,完成 token 交换
|
||||||
|
* GET /api/haha-oauth/callback — 兼容旧路径
|
||||||
* GET /api/haha-oauth — 查询当前登录状态(不回传 token 本体)
|
* GET /api/haha-oauth — 查询当前登录状态(不回传 token 本体)
|
||||||
* GET /api/haha-oauth/status — 同上(legacy path)
|
* GET /api/haha-oauth/status — 同上(legacy path)
|
||||||
* DELETE /api/haha-oauth — 登出,删除 token 文件
|
* DELETE /api/haha-oauth — 登出,删除 token 文件
|
||||||
@ -52,24 +53,7 @@ export async function handleHahaOAuthApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'callback' && req.method === 'GET') {
|
if (action === 'callback' && req.method === 'GET') {
|
||||||
const code = url.searchParams.get('code')
|
return handleHahaOAuthCallback(url)
|
||||||
const state = url.searchParams.get('state')
|
|
||||||
const error = url.searchParams.get('error')
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return html(renderCallbackPage(false, `OAuth provider returned: ${error}`))
|
|
||||||
}
|
|
||||||
if (!code || !state) {
|
|
||||||
return html(renderCallbackPage(false, 'Missing code or state parameter'))
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await hahaOAuthService.completeSession(code, state)
|
|
||||||
return html(renderCallbackPage(true, null))
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
return html(renderCallbackPage(false, msg))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((action === undefined || action === 'status') && req.method === 'GET') {
|
if ((action === undefined || action === 'status') && req.method === 'GET') {
|
||||||
@ -96,6 +80,27 @@ export async function handleHahaOAuthApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleHahaOAuthCallback(url: URL): Promise<Response> {
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
const state = url.searchParams.get('state')
|
||||||
|
const error = url.searchParams.get('error')
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return html(renderCallbackPage(false, `OAuth provider returned: ${error}`))
|
||||||
|
}
|
||||||
|
if (!code || !state) {
|
||||||
|
return html(renderCallbackPage(false, 'Missing code or state parameter'))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await hahaOAuthService.completeSession(code, state)
|
||||||
|
return html(renderCallbackPage(true, null))
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return html(renderCallbackPage(false, msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderCallbackPage(success: boolean, errorMsg: string | null): string {
|
function renderCallbackPage(success: boolean, errorMsg: string | null): string {
|
||||||
if (success) {
|
if (success) {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { teamWatcher } from './services/teamWatcher.js'
|
|||||||
import { cronScheduler } from './services/cronScheduler.js'
|
import { cronScheduler } from './services/cronScheduler.js'
|
||||||
import { handleProxyRequest } from './proxy/handler.js'
|
import { handleProxyRequest } from './proxy/handler.js'
|
||||||
import { ProviderService } from './services/providerService.js'
|
import { ProviderService } from './services/providerService.js'
|
||||||
|
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||||
|
|
||||||
function readArgValue(flag: string): string | undefined {
|
function readArgValue(flag: string): string | undefined {
|
||||||
const args = process.argv.slice(2)
|
const args = process.argv.slice(2)
|
||||||
@ -128,6 +129,10 @@ export function startServer(port = PORT, host = HOST) {
|
|||||||
return new Response('WebSocket upgrade failed', { status: 400 })
|
return new Response('WebSocket upgrade failed', { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/callback') {
|
||||||
|
return handleHahaOAuthCallback(url)
|
||||||
|
}
|
||||||
|
|
||||||
// REST API
|
// REST API
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
// Enforce authentication when required
|
// Enforce authentication when required
|
||||||
|
|||||||
@ -53,6 +53,7 @@ type FetchProfileFn = (
|
|||||||
) => Promise<{ subscriptionType: SubscriptionType | null }>
|
) => Promise<{ subscriptionType: SubscriptionType | null }>
|
||||||
|
|
||||||
const SESSION_TTL_MS = 5 * 60 * 1000
|
const SESSION_TTL_MS = 5 * 60 * 1000
|
||||||
|
const OAUTH_CALLBACK_PATH = '/callback'
|
||||||
|
|
||||||
export class HahaOAuthService {
|
export class HahaOAuthService {
|
||||||
private sessions = new Map<string, OAuthSession>()
|
private sessions = new Map<string, OAuthSession>()
|
||||||
@ -108,14 +109,13 @@ export class HahaOAuthService {
|
|||||||
const codeChallenge = generateCodeChallenge(codeVerifier)
|
const codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
const state = generateState()
|
const state = generateState()
|
||||||
|
|
||||||
const baseUrl = buildAuthUrl({
|
const authorizeUrl = buildAuthUrl({
|
||||||
codeChallenge,
|
codeChallenge,
|
||||||
state,
|
state,
|
||||||
port: serverPort,
|
port: serverPort,
|
||||||
isManual: false,
|
isManual: false,
|
||||||
loginWithClaudeAi: true,
|
loginWithClaudeAi: true,
|
||||||
})
|
})
|
||||||
const authorizeUrl = this.rewriteCallbackPath(baseUrl, serverPort)
|
|
||||||
|
|
||||||
const session: OAuthSession = {
|
const session: OAuthSession = {
|
||||||
state,
|
state,
|
||||||
@ -128,15 +128,6 @@ export class HahaOAuthService {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
private rewriteCallbackPath(url: string, port: number): string {
|
|
||||||
const u = new URL(url)
|
|
||||||
u.searchParams.set(
|
|
||||||
'redirect_uri',
|
|
||||||
`http://localhost:${port}/api/haha-oauth/callback`,
|
|
||||||
)
|
|
||||||
return u.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
getSession(state: string): OAuthSession | null {
|
getSession(state: string): OAuthSession | null {
|
||||||
const s = this.sessions.get(state)
|
const s = this.sessions.get(state)
|
||||||
if (!s) return null
|
if (!s) return null
|
||||||
@ -197,7 +188,7 @@ export class HahaOAuthService {
|
|||||||
const requestBody = {
|
const requestBody = {
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: `http://localhost:${port}/api/haha-oauth/callback`,
|
redirect_uri: `http://localhost:${port}${OAUTH_CALLBACK_PATH}`,
|
||||||
client_id: getOauthConfig().CLIENT_ID,
|
client_id: getOauthConfig().CLIENT_ID,
|
||||||
code_verifier: verifier,
|
code_verifier: verifier,
|
||||||
state,
|
state,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user