修复AGC模型请求与对话登录态自动续期
统一模型等API的401续期并同步Rust和Runner会话 补齐DirectProject鉴权失败续期与同回合重试 保留鉴权失败提示并阻止账号切换后的旧请求重发 补充续期成功失败并发及权限边界测试 同步客户端实施文档与认证排障记忆
This commit is contained in:
@@ -245,6 +245,10 @@ import {
|
||||
captureAgentRuntimeError,
|
||||
invokeDiagnostic,
|
||||
} from './services/errorReporting';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
requestPlatformSessionRefresh,
|
||||
} from './services/platformSession';
|
||||
import type { HomeCreationType } from './view/home';
|
||||
import {
|
||||
type ProjectAgentResultSummary,
|
||||
@@ -258,6 +262,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<T>(operation: () => Promise<T>) {
|
||||
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',
|
||||
@@ -5605,10 +5638,20 @@ export function App({
|
||||
if (attachments?.length) {
|
||||
directTurnInput.attachments = attachments;
|
||||
}
|
||||
const reply = await directInvoke<string>(
|
||||
'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<string>(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
directTurnInput,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await persistDirectAssistantMessage(reply);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<T>(
|
||||
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);
|
||||
|
||||
@@ -2200,116 +2200,155 @@ export function registerHomeProjectCreationTests() {
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a readable reason when the direct Codex turn is rejected', async () => {
|
||||
const projectPath =
|
||||
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'failed-direct-project',
|
||||
'直连失败项目',
|
||||
);
|
||||
const persistedMessages: Array<Record<string, unknown>> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
expect(args).toEqual({ projectPath });
|
||||
return manifest;
|
||||
it.each([false, true])(
|
||||
'recovers a direct Codex auth failure or preserves its readable reason (refresh=%s)',
|
||||
async (refreshSucceeds) => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
if (String(input) === '/api/auth/refresh') {
|
||||
return new Response(
|
||||
JSON.stringify({ token: 'refreshed-direct-token' }),
|
||||
{
|
||||
status: refreshSucceeds ? 200 : 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
if (String(input) === '/api/auth/me') {
|
||||
return new Response(JSON.stringify({ user: testAuthUser }));
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
const message = args?.message as Record<string, unknown>;
|
||||
const messageId = String(args?.messageId ?? '');
|
||||
if (
|
||||
!messageId ||
|
||||
!persistedMessages.some(
|
||||
(candidate) => candidate.messageId === messageId,
|
||||
)
|
||||
) {
|
||||
persistedMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
...message,
|
||||
messageId,
|
||||
updatedAt: Number(
|
||||
message.updatedAt ?? persistedMessages.length + 1,
|
||||
),
|
||||
});
|
||||
throw new Error(`unexpected fetch ${String(input)}`);
|
||||
});
|
||||
let directAttempts = 0;
|
||||
const expectedReply = refreshSucceeds
|
||||
? '续期后对话完成'
|
||||
: '陶泥儿智能创作 鉴权失败,请重新登录后重试';
|
||||
const projectPath = `C:\\Users\\tester\\Documents\\Genarrative GameAgent\\failed-direct-project-${refreshSucceeds}`;
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'failed-direct-project',
|
||||
'直连失败项目',
|
||||
);
|
||||
const persistedMessages: Array<Record<string, unknown>> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
expect(args).toEqual({ projectPath });
|
||||
return manifest;
|
||||
}
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
throw new Error('codex-app-server-error:unauthorized');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
const message = args?.message as Record<string, unknown>;
|
||||
const messageId = String(args?.messageId ?? '');
|
||||
if (
|
||||
!messageId ||
|
||||
!persistedMessages.some(
|
||||
(candidate) => candidate.messageId === messageId,
|
||||
)
|
||||
) {
|
||||
persistedMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
...message,
|
||||
messageId,
|
||||
updatedAt: Number(
|
||||
message.updatedAt ?? persistedMessages.length + 1,
|
||||
),
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
directAttempts += 1;
|
||||
if (refreshSucceeds && directAttempts === 2) {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'install_platform_account_session',
|
||||
expect.objectContaining({
|
||||
userId: testAuthUser.id,
|
||||
accessToken: 'refreshed-direct-token',
|
||||
}),
|
||||
);
|
||||
return expectedReply;
|
||||
}
|
||||
throw new Error('codex-app-server-error:unauthorized');
|
||||
}
|
||||
if (
|
||||
command === 'read_platform_account_session_generation' ||
|
||||
command === 'install_platform_account_session' ||
|
||||
command === 'clear_platform_account_session'
|
||||
)
|
||||
return null;
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
initialSupervisorMessage: '生成一个游戏',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
|
||||
).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(persistedMessages).toHaveLength(2);
|
||||
});
|
||||
expect(persistedMessages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', content: '生成一个游戏' }),
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
initialSupervisorMessage: '生成一个游戏',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(persistedMessages)).not.toContain(
|
||||
'codex-app-server-error:unauthorized',
|
||||
);
|
||||
cleanup();
|
||||
);
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
expect(await screen.findByText(expectedReply)).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(persistedMessages).toHaveLength(2);
|
||||
});
|
||||
expect(persistedMessages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', content: '生成一个游戏' }),
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: expectedReply,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(persistedMessages)).not.toContain(
|
||||
'codex-app-server-error:unauthorized',
|
||||
);
|
||||
cleanup();
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
|
||||
expect(await screen.findByText(expectedReply)).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
),
|
||||
).toHaveLength(refreshSucceeds ? 2 : 1);
|
||||
if (refreshSucceeds) {
|
||||
const attempts = invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
);
|
||||
expect(attempts[1]?.[1]).toEqual(attempts[0]?.[1]);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerRecentProjectsTests() {
|
||||
|
||||
@@ -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<Response>((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);
|
||||
});
|
||||
@@ -4984,6 +4984,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 端口。
|
||||
|
||||
@@ -1265,6 +1265,7 @@ game-project/
|
||||
本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。
|
||||
|
||||
DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`;provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。
|
||||
|
||||
## 2026-08-24 AGC UI 原型桥接与自主 UI workflow
|
||||
|
||||
- 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。
|
||||
@@ -1287,3 +1288,8 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
||||
|
||||
- `/api/llm/responses` 与 `/api/llm/chat/completions` 的正式请求体上限为 `32 MiB`。两个路由必须显式配置 Axum `DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)`;不能依赖 handler 内的 `Bytes / Json` 后置检查,否则 Axum 默认 `2 MiB` 会先拒绝 Direct Codex 携带图片工具结果的大上下文请求。超过 `32 MiB` 仍返回 `413 PAYLOAD_TOO_LARGE`。
|
||||
- Codex app-server 的 failed turn 需要把上游 / 连接层 HTTP 413、`PAYLOAD_TOO_LARGE` 和 provider proxy 的 `provider request too large` 映射为稳定分类 `codex-app-server-error:request-too-large`;用户可见文案固定为“模型请求体过大,请减少参考图或上下文后重试”,不得落入 `other` 或泛化成权限 / 安全策略错误。
|
||||
|
||||
## 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 建立连接,避免长时间运行后必须重新登录。
|
||||
|
||||
Reference in New Issue
Block a user