From fafe6b63cd31e2cc86d7f2a93757b7e9381aa2a5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 11 Sep 2026 18:09:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E4=B8=8E=E5=AF=B9=E8=AF=9D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=80=81=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更说明 - AGC 客户端配套后端 API 在 401 时自动刷新登录态并重试一次,覆盖模型目录请求。 - DirectProject 对话鉴权失效时刷新平台会话、同步 Rust/Runner 凭据并重试同一 clientTurnId。 - 403 权限拒绝不触发续期;账号切换后不重发旧请求。 - 补充并发续期、失败保留原错误、权限边界和 Direct 对话重试测试。 ## 验证 - npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/clientApi.test.ts apps/ai-game-creator-shell/tests/clientHttp.test.ts apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx - npm run agc:typecheck - npm run check:encoding - git diff --check Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/329 Co-authored-by: kdletters Co-committed-by: kdletters --- apps/ai-game-creator-shell/src/App.tsx | 51 +++++- .../src/services/clientApi.ts | 53 +++++-- .../tests/clientApi.test.ts | 146 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 5 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 + 5 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/clientApi.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 5400b1017..8efc759d8 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -251,6 +251,10 @@ import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWo import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { captureAgentRuntimeError } from './services/errorReporting'; +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, +} from './services/platformSession'; import type { HomeCreationType } from './view/home'; import { type ProjectAgentResultSummary, @@ -264,6 +268,35 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +function isDirectCodexAuthenticationRequired(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('authentication-required') || + message.includes('codex-app-server-error:unauthorized') || + /kind=codex-app-server-unauthorized(?=\s|$)/.test(message) || + message.includes('登录已失效') + ); +} + +async function withDirectCodexSessionRefresh(operation: () => Promise) { + const generation = currentPlatformSessionGeneration(); + try { + return await operation(); + } catch (error) { + if (!isDirectCodexAuthenticationRequired(error)) throw error; + if (currentPlatformSessionGeneration() !== generation) throw error; + const refresh = await requestPlatformSessionRefresh(); + if (refresh.status === 'failed') throw error; + if ( + refresh.status !== 'refreshed' || + currentPlatformSessionGeneration() !== refresh.generation + ) { + throw new Error('登录账号已变化,原对话请求已停止'); + } + return operation(); + } +} + const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ 'accepted', 'running', @@ -5933,10 +5966,20 @@ export function App({ if (attachments?.length) { directTurnInput.attachments = attachments; } - const reply = await directInvoke( - 'chat_with_game_creator_direct_codex', - directTurnInput, - ); + const reply = await withDirectCodexSessionRefresh(() => { + // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 + activeDirectCodexTurnRef.current = { + projectPath: directProjectPath, + turnId: clientTurnId, + lastSequence: -1, + receivedDirectUpdate: false, + }; + setDirectCodexStatus('accepted'); + return directInvoke( + 'chat_with_game_creator_direct_codex', + directTurnInput, + ); + }); // Rust already persisted the complete raw response items. Invalidate // any history snapshot captured before the turn completed. if (localProjectPathRef.current === directProjectPath) { diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 086e95210..ef7af14f7 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -10,6 +10,10 @@ import { } from '../../../../packages/shared/src'; import { fetchClientHttp } from './clientHttp'; import { captureClientError } from './errorReporting'; +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, +} from './platformSession'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; @@ -84,23 +88,40 @@ export async function requestClientApi( fallbackMessage: string, options: { skipAuth?: boolean } = {}, ) { - const headers = new Headers(init.headers); - headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); - if (!options.skipAuth) { - const token = getStoredAuthAccessToken(); - if (token) { - headers.set('Authorization', `Bearer ${token}`); + const generation = currentPlatformSessionGeneration(); + const request = async () => { + const headers = new Headers(init.headers); + headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); + if (!options.skipAuth) { + const token = getStoredAuthAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + } + try { + return await fetchClientHttp(url, { + ...init, + credentials: 'same-origin', + headers, + }); + } catch (error) { + throw apiNetworkError(url, error); + } + }; + + let response = await request(); + // Access tokens are short lived. Refresh the cookie-backed session once and + // retry the original request so callers do not need to handle token expiry. + if (!options.skipAuth && response.status === 401) { + if (currentPlatformSessionGeneration() === generation) { + const refresh = await requestPlatformSessionRefresh(); + if ( + refresh.status === 'refreshed' && + currentPlatformSessionGeneration() === refresh.generation + ) { + response = await request(); + } } - } - let response: Response; - try { - response = await fetchClientHttp(url, { - ...init, - credentials: 'same-origin', - headers, - }); - } catch (error) { - throw apiNetworkError(url, error); } if (!response.ok) { captureApiErrorStatus(url, response); diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts new file mode 100644 index 000000000..529680372 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -0,0 +1,146 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; +import { + requestClientApi, + setStoredAuthAccessToken, +} from '../src/services/clientApi'; +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + resetPlatformSessionStateForTests, +} from '../src/services/platformSession'; + +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); +vi.mock('../src/services/errorReporting', () => ({ + captureClientError: vi.fn(), +})); + +const user = { id: 'session-user' } as AuthUser; +const nativeInvoke = vi.fn(async () => null); +const catalog = { models: [{ id: 'quality', displayName: '高质量' }] }; +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status }); + +beforeEach(async () => { + resetPlatformSessionStateForTests(); + window.localStorage.clear(); + nativeInvoke.mockClear(); + window.__TAURI__ = { core: { invoke: nativeInvoke } }; + setStoredAuthAccessToken('expired-token'); + await commitAuthenticatedPlatformSession( + user, + currentPlatformSessionGeneration(), + ); + nativeInvoke.mockClear(); +}); + +afterEach(() => { + resetPlatformSessionStateForTests(); + window.localStorage.clear(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => { + let refreshCalls = 0; + let modelCalls = 0; + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + if (input === '/api/auth/refresh') { + refreshCalls += 1; + return json({ token: 'fresh-token' }); + } + if (input === '/api/auth/me') return json({ user }); + modelCalls += 1; + const token = new Headers(init?.headers).get('Authorization'); + if (token === 'Bearer expired-token') return json({}, 401); + expect(token).toBe('Bearer fresh-token'); + expect(nativeInvoke).toHaveBeenCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + accessToken: 'fresh-token', + userId: user.id, + }), + ); + return json(catalog); + }); + + const results = await Promise.all([ + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ]); + expect(results).toEqual([catalog, catalog]); + expect(refreshCalls).toBe(1); + expect(modelCalls).toBe(4); + expect(fetch).toHaveBeenCalledTimes(6); +}); + +it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(json({}, status)) + .mockResolvedValueOnce(json({}, 401)); + await expect( + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ).rejects.toMatchObject({ status }); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it('跳过鉴权的请求不触发续期', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401)); + await expect( + requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }), + ).rejects.toMatchObject({ status: 401 }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it('403 权限拒绝不触发续期或重发写请求', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403)); + await expect( + requestClientApi( + '/api/example', + { method: 'POST', body: '{}' }, + '权限不足', + ), + ).rejects.toMatchObject({ status: 403 }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(nativeInvoke).not.toHaveBeenCalled(); +}); + +it('续期成功后的再次未授权不循环重试', async () => { + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json({ token: 'fresh-token' })) + .mockResolvedValueOnce(json({ user })) + .mockResolvedValueOnce(json({}, 401)); + await expect( + requestClientApi('/api/llm/models', {}, '读取失败'), + ).rejects.toMatchObject({ status: 401 }); + expect(fetch).toHaveBeenCalledTimes(4); +}); + +it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => { + let finish!: (response: Response) => void; + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = requestClientApi('/api/llm/models', {}, '读取失败'); + const rejection = expect(pending).rejects.toMatchObject({ status: 401 }); + const generation = beginPlatformSessionTransition(); + setStoredAuthAccessToken('other-token'); + await commitAuthenticatedPlatformSession( + { ...user, id: 'other-user' }, + generation, + ); + finish(json({}, 401)); + await rejection; + expect(fetch).toHaveBeenCalledTimes(1); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 451a00738..8eb7964ef 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5036,6 +5036,11 @@ - 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。 - 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。 +## AGC 登录态续期必须同步本地运行时 + +- 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。 +- 普通 API 仅在 `401` 时续期并至多重试一次;`403` 权限拒绝不重发。对话续期失败保留原机器可读鉴权错误,避免用户提示退化为普通执行失败;账号代次变化时停止旧请求。 + ## AGC 前端等待超时与 worker 端口冲突 - `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5842afc88..6d0008076 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1350,3 +1350,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted`(`waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。 - 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker(`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 - 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 + +## 2026-09-11 AGC 登录态自动续期 + +- AGC 前端请求客户端配套后端的鉴权 API(包括 `/api/llm/models`)收到 `401` 时,共享进行中的 refresh 请求;确认当前用户并安装 Rust / Runner 会话后,用新 access token 最多重试原请求一次。`403` 权限拒绝不触发续期;续期失败保留原鉴权错误,账号切换或登出后不重发旧请求。 +- DirectProject 的 Rust/app-server 对话调用返回鉴权失效时,前端先刷新客户端平台会话并重新提交同一 `clientTurnId`;平台会话代次变化后由 app-server pool 使用新 access token 建立连接,避免长时间运行后必须重新登录。